Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
1 change: 0 additions & 1 deletion .github/README.md

This file was deleted.

5 changes: 1 addition & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,4 @@ export_presets.cfg
*.import

# Imported translations (automatically generated from CSV files)
*.translation

# Ignore project in addon repo
project.godot
*.translation
38 changes: 32 additions & 6 deletions addons/gdscript-interfaces/README.md → README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,35 @@ Improvements and suggestions are very welcome. If you have the time and know-how

## Usage

Add the `addons/Interfaces.gd` script to your project's autoloaded singletons. Any script which "implements" an interface should have a property constant set called `implements`, which is an Array listing all its implementations (as GDScript references, so simply write the class name).

Add the plugin to your `addons` folder or install it via the asset library (COMING SOON). Then enable it in via Project > Project Settings > Plugins.
Any script which "implements" an interface should have a property constant set called `implements`, which is an Array listing all its implementations.

Previously this could be done as GDScript references (simply writing the ``class_name``).
Unfortunately Godot 4.0+ no longer allows GDScript globals (the ``class_name``) to be used as consts directly.
This means you need to use prelaod instead, as this precompiles the script to make it a const.

Using preload your ``implements`` statements will look like this.
```GDScript
const implements = [
preload("path/to/interface/can_take_damage.gd"),
preload("path/to/interface/can_heal.gd")
]
```
As a workaround there is now a second way of defining implements easily, by enabling the experimental feature ``allow_string_classes`` in the autoload script `addons/gdscript-interfaces/interfaces.gd`.
This enables using strings in the implements array like this:
```GDScript
const implements = [
CanTakeDamage,
CanHeal
"CanTakeDamage",
"CanHeal"
]
```
This feature should be used with caution, as it runs the interface names as gdscript code arbitrarily. This should not (as interface names can't be changed at runtime), but could enable some form of arbitrary code execution exploit.
_The code in question can be found in line 175-181 of `interfaces.gd` if you are interested_.
<br>You have been warned.

Then, wherever you wish to check for an implementation, you call the function `implements` on the singleton (the function can either take a single GDScript reference or an array of GDScripts).
Here you can use the ``class_name`` again!

```GDScript
func _on_body_entered(body : Node):
Expand All @@ -34,8 +53,10 @@ var destroyable = Interfaces.implementations([obj1, obj2, obj3], CanTakeDamage)
```

An interface is just a GDScript, defined with a `class_name`, that details the properties, signals, and methods that the implementations must provide.
If you want to use ``allow_string_classes`` the ``# Interface`` or ``# interface`` needs to be present. Otherwise the script is not added to the cache.

```GDScript
# Interface
class_name CanTakeDamage extends Object

var required
Expand All @@ -52,15 +73,20 @@ Since GDScript doesn't provide introspection, the validation can only take the e

By default, the script validates all found GDScripts in the project when the application is loaded, since this mimics the expected behavior from other languages most closely. However, a few options may be tweaked to change this behavior (these are properties on the singleton).

#### export(bool) var runtime_validation = false
#### @export var runtime_validation: bool = false

This toggles whether all implementations should be validated immediately upon load, or if they should first be validated when they're tested against. If you have a lot of classes that may not always be loaded in a play session then this might be preferable for performance reasons, but it introduces the risk of never discovering incomplete implementations.

#### export(Array, String) var validate_dirs = ["res://"]
#### @export var allow_string_classes: bool = false

If this is true, a list of all interfaces is saved in memory to enable using "const implements = ['InterfaceName']" instead of preloads only.
For big projects with lots of "class_name" scripts this should be off to safe memory (preloads have to be used in that case).

#### @export var validate_dirs: Array[String] = ["res://"]

This option sets what directories the library should scan for classes that implements interfaces in. By default it's set to the project root, but it should preferably be changed to something more specific like "res://src/", or "res://src/contracts/". The option has no effect if the library is configured to only do runtime validation.

#### export(bool) var strict_validation = true
#### @export var strict_validation: bool = true

If strict validation is off, the `implements` method will only check if an entity has the provided interfaces in its `implements` constant. This may be preferable if proper validation turns out to incur a significant performance penalty (I haven't tested this system on larger projects). However, each check are usually only run once, since the results of validations are cached. Note that disabling strict validation pretty much removes the benefits of having interfaces in the first place.

Expand Down
11 changes: 11 additions & 0 deletions addons/gdscript-interfaces/gdscript-interfaces.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@tool
extends EditorPlugin

const AUTOLOAD_NAME = "Interfaces"

func _enter_tree() -> void:
add_autoload_singleton(AUTOLOAD_NAME, "res://addons/gdscript-interfaces/Interfaces.gd")


func _exit_tree() -> void:
remove_autoload_singleton(AUTOLOAD_NAME)
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@ extends Node
## @tutorial: https://github.com/nsrosenqvist/gdscript-interfaces/tree/main/addons/gdscript-interfaces#readme
##

export(bool) var runtime_validation = false
export(bool) var strict_validation = true
export(Array, String) var validate_dirs = ["res://"]
@export var runtime_validation: bool = false
@export var allow_string_classes: bool = false
@export var strict_validation: bool = true
@export var validate_dirs: Array[String] = ["res://"]

var _interfaces = {}
var _identifiers = {}
var _implements = {}
var _interfaces := {}
var _identifiers := {}
var _implements := {}

## Validate that an entity implements an interface
##
## enitity [Object]: Any GDscript or a node with script attached
## implementation [Object]: Any GDscript or a node with script attached
## interfaces [GDScript|Array]: The interface(s) to validate against
## validate [bool]: Whether validation should run or if only the
## implements constant should be checked
Expand Down Expand Up @@ -88,7 +89,7 @@ func _validate_all_implementations() -> void:
for d in validate_dirs:
files.append_array(_files(d, true))

var scripts = _filter(files, funcref(self, "_only_scripts"))
var scripts = _filter(files, _only_scripts)

# Validate all scripts that has the constant "implements"
for s in scripts:
Expand All @@ -104,31 +105,30 @@ func _only_scripts(file : String) -> bool:

func _files(path : String, recursive = false) -> Array:
var result = []
var dir = Directory.new()

if dir.open(path) == OK:
var dir = DirAccess.open(path)
if dir:
dir.list_dir_begin()
var file_name = dir.get_next()

while file_name != "":
if not (file_name == "." or file_name == ".."):
if dir.current_is_dir():
if recursive:
result.append_array(_files(path.plus_file(file_name)))
result.append_array(_files(path.path_join(file_name)))
else:
result.append(path.plus_file(file_name))
result.append(path.path_join(file_name))

file_name = dir.get_next()
else:
printerr("An error occurred when trying to access '"+path+"'")

return result

func _filter(objects : Array, function : FuncRef) -> Array:
func _filter(objects : Array, function : Callable) -> Array:
var result = []

for object in objects:
if function.call_func(object):
if function.call(object):
result.append(object)

return result
Expand All @@ -153,15 +153,34 @@ func _get_implements(implementation) -> Array:

if _implements.has(lookup):
return _implements[lookup]

# Get implements constant from script
var consts : Dictionary = script.get_script_constant_map()

_implements[lookup] = consts["implements"] if consts.has("implements") else []
if consts.has("implements"):
var interfaces: Array[GDScript] = []
for interface in consts["implements"]:
if interface is String:
if not allow_string_classes:
assert(false, "Cannot use string type in implements as 'allow_string_classes' is false. ('%s' in %s)" % [interface, lookup])
interfaces.append(_get_interface_script(interface))
elif interface is GDScript:
interfaces.append(interface)
_implements[lookup] = interfaces
else:
_implements[lookup] = []

return _implements[lookup]

func _get_identifier(implementation) -> String:
func _get_interface_script(interface_name):
var script = GDScript.new()
script.set_source_code("func eval(): return " + interface_name)
script.reload()
var ref = RefCounted.new()
ref.set_script(script)
return ref.eval()

func _get_identifier(implementation, strict = false) -> String:
var script : GDScript = _get_script(implementation)
var lookup : String = str(script)

Expand All @@ -177,12 +196,12 @@ func _get_identifier(implementation) -> String:
if result:
_identifiers[lookup] = result.get_string().substr(11)
else:
_identifiers[lookup] = script.resource_path
_identifiers[lookup] = "" if strict else script.resource_path

return _identifiers[lookup]

return "Unknown"

func _validate_implementation(script : GDScript, interface : GDScript, assert_on_fail = false) -> bool:
var implementation_id = _get_identifier(script)
var interface_id = _get_identifier(interface)
Expand Down Expand Up @@ -219,6 +238,8 @@ func _validate_implementation(script : GDScript, interface : GDScript, assert_on
var props = _column(script.get_script_property_list(), "name")

for p in _column(interface.get_script_property_list(), "name"):
if (p.ends_with(".gd")):
continue
if not (p in props):
if assert_on_fail:
assert(false, implementation_id + ' does not implement the property "'+p+'" on the interface ' + interface_id)
Expand Down
8 changes: 8 additions & 0 deletions addons/gdscript-interfaces/plugin.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[plugin]

name="GDScript Interfaces"
description="Adds a very basic implementation of interfaces to GDScript.
Only validates at runtime."
author="nsrosenqvist, Mastermori"
version="2.0.0"
script="gdscript-interfaces.gd"
8 changes: 8 additions & 0 deletions example/can_take_damage.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class_name CanTakeDamage extends Object

var tester

signal foobar

func deal_damage():
pass
15 changes: 15 additions & 0 deletions example/killable_object.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class_name KillableObject extends Node

const implements = [preload("res://example/can_take_damage.gd")]
#const implements = ["CanTakeDamage"]


var tester

signal foobar

func deal_damage() -> void:
pass

func _ready() -> void:
print(Interfaces.implements(self, CanTakeDamage))
6 changes: 6 additions & 0 deletions example/test_scene.tscn
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cl36mbv4b32tt"]

[ext_resource type="Script" path="res://example/killable_object.gd" id="1_el8tc"]

[node name="TestScene" type="Node2D"]
script = ExtResource("1_el8tc")
23 changes: 23 additions & 0 deletions project.godot
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters

config_version=5

[application]

config/name="Gdscript-interfaces"
config/features=PackedStringArray("4.2", "Forward Plus")
config/icon="res://icon.svg"

[autoload]

Interfaces="*res://addons/gdscript-interfaces/interfaces.gd"

[editor_plugins]

enabled=PackedStringArray("res://addons/gdscript-interfaces/plugin.cfg")