diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.github/README.md b/.github/README.md deleted file mode 120000 index a33540b..0000000 --- a/.github/README.md +++ /dev/null @@ -1 +0,0 @@ -../addons/gdscript-interfaces/README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5588062..b5cb4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,4 @@ export_presets.cfg *.import # Imported translations (automatically generated from CSV files) -*.translation - -# Ignore project in addon repo -project.godot +*.translation \ No newline at end of file diff --git a/addons/gdscript-interfaces/README.md b/README.md similarity index 63% rename from addons/gdscript-interfaces/README.md rename to README.md index df2a0b4..8635ebe 100644 --- a/addons/gdscript-interfaces/README.md +++ b/README.md @@ -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_. +
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): @@ -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 @@ -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. diff --git a/addons/gdscript-interfaces/gdscript-interfaces.gd b/addons/gdscript-interfaces/gdscript-interfaces.gd new file mode 100644 index 0000000..a628f70 --- /dev/null +++ b/addons/gdscript-interfaces/gdscript-interfaces.gd @@ -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) diff --git a/addons/gdscript-interfaces/Interfaces.gd b/addons/gdscript-interfaces/interfaces.gd similarity index 80% rename from addons/gdscript-interfaces/Interfaces.gd rename to addons/gdscript-interfaces/interfaces.gd index 32a20e4..dd4a260 100644 --- a/addons/gdscript-interfaces/Interfaces.gd +++ b/addons/gdscript-interfaces/interfaces.gd @@ -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 @@ -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: @@ -104,9 +105,8 @@ 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() @@ -114,9 +114,9 @@ func _files(path : String, recursive = false) -> Array: 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: @@ -124,11 +124,11 @@ func _files(path : String, recursive = false) -> Array: 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 @@ -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) @@ -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) @@ -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) diff --git a/addons/gdscript-interfaces/plugin.cfg b/addons/gdscript-interfaces/plugin.cfg new file mode 100644 index 0000000..6054a4f --- /dev/null +++ b/addons/gdscript-interfaces/plugin.cfg @@ -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" diff --git a/example/can_take_damage.gd b/example/can_take_damage.gd new file mode 100644 index 0000000..d129df3 --- /dev/null +++ b/example/can_take_damage.gd @@ -0,0 +1,8 @@ +class_name CanTakeDamage extends Object + +var tester + +signal foobar + +func deal_damage(): + pass diff --git a/example/killable_object.gd b/example/killable_object.gd new file mode 100644 index 0000000..a21d5fe --- /dev/null +++ b/example/killable_object.gd @@ -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)) diff --git a/example/test_scene.tscn b/example/test_scene.tscn new file mode 100644 index 0000000..6fa2fb7 --- /dev/null +++ b/example/test_scene.tscn @@ -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") diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..e1574d3 --- /dev/null +++ b/project.godot @@ -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")