diff --git a/example/project.godot b/example/project.godot index 280cbc7..ec8298b 100644 --- a/example/project.godot +++ b/example/project.godot @@ -37,5 +37,4 @@ paths=["res://addons/gode/binary/gode.gdextension"] [rendering] rendering_device/driver.windows="d3d12" -renderer/rendering_method="forward_plus" textures/vram_compression/import_etc2_astc=true diff --git a/example/scripts/tests/runtime_integration_test.ts b/example/scripts/tests/runtime_integration_test.ts index f913adb..9757144 100644 --- a/example/scripts/tests/runtime_integration_test.ts +++ b/example/scripts/tests/runtime_integration_test.ts @@ -35,6 +35,14 @@ const PROPERTY_HINT_RESOURCE_TYPE = 17; const PROPERTY_HINT_NODE_TYPE = 34; const RUNTIME_ARRAY_RESOURCE_SCRIPT_PATH = "res://scripts/tests/runtime_array_resource.ts"; +interface RuntimeExportInterfaceArrayElement { + label: string; + amount: number; + enabled: boolean; + tags: string[]; + metadata: Record; +} + function assert(condition: boolean, message: string): void { if (!condition) { throw new Error(message); @@ -163,6 +171,9 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase { @Export() editor_namespace_alias_resource_array: RuntimeExportTypes.RuntimeImportedResourceArray = []; + @Export() + editor_interface_array: RuntimeExportInterfaceArrayElement[] = []; + @Export() editor_mixed_union: "automatic" | number = "automatic"; @@ -393,6 +404,7 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase { "editor_imported_generic_external_resource_array", "editor_imported_generic_resource_dictionary", "editor_namespace_alias_resource_array", + "editor_interface_array", "editor_mixed_union", "editor_mixed_object_union", "editor_explicit_hint_enum", @@ -443,6 +455,7 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase { assertArrayExportMetadata("editor_imported_generic_external_resource_array", `${VARIANT_TYPE_OBJECT}/${PROPERTY_HINT_RESOURCE_TYPE}:RuntimeExternalResource`); assertDictionaryExportMetadata("editor_imported_generic_resource_dictionary", `${VARIANT_TYPE_STRING}:;${VARIANT_TYPE_OBJECT}/${PROPERTY_HINT_RESOURCE_TYPE}:RuntimeArrayResource`); assertArrayExportMetadata("editor_namespace_alias_resource_array", `${VARIANT_TYPE_OBJECT}/${PROPERTY_HINT_RESOURCE_TYPE}:RuntimeArrayResource`); + assertArrayExportMetadata("editor_interface_array", `${VARIANT_TYPE_OBJECT}/${PROPERTY_HINT_RESOURCE_TYPE}:TypeScriptInterfaceResource`); assertArrayExportMetadata("static_number_array", `${VARIANT_TYPE_FLOAT}:`); assertArrayExportMetadata("static_custom_resource_array", `${VARIANT_TYPE_OBJECT}/${PROPERTY_HINT_RESOURCE_TYPE}:RuntimeArrayResource`); assertDictionaryExportMetadata("static_record_dictionary", `${VARIANT_TYPE_STRING}:;${VARIANT_TYPE_FLOAT}:`); diff --git a/example/scripts/tests/tests_runner.gd b/example/scripts/tests/tests_runner.gd index ba7c516..3c68c58 100644 --- a/example/scripts/tests/tests_runner.gd +++ b/example/scripts/tests/tests_runner.gd @@ -61,6 +61,28 @@ func _run_next() -> void: if current_test.get("editor_record_dictionary") != {"from_gdscript": 8}: _fail("RuntimeIntegrationTest typed string-key Dictionary did not round-trip through ScriptInstance") return + if not current_test.get("editor_interface_array").is_empty(): + _fail("RuntimeIntegrationTest interface array default was not empty") + return + var interface_element: Object = ClassDB.instantiate("TypeScriptInterfaceResource") + current_test.set("editor_interface_array", [interface_element]) + var interface_fields: Array[String] = [] + for property in interface_element.get_property_list(): + interface_fields.append(str(property.name)) + if not interface_fields.has("label") or not interface_fields.has("amount") or not interface_fields.has("enabled") or not interface_fields.has("tags") or not interface_fields.has("metadata"): + _fail("RuntimeIntegrationTest interface array element did not expose interface fields") + return + if interface_element.get("label") != "" or interface_element.get("amount") != 0 or interface_element.get("enabled") != false: + _fail("RuntimeIntegrationTest interface array scalar fields did not use Godot defaults") + return + if not interface_element.get("tags").is_empty() or not interface_element.get("metadata").is_empty(): + _fail("RuntimeIntegrationTest interface array container fields did not use Godot defaults") + return + interface_element.set("label", "configured") + interface_element.set("amount", 9) + if interface_element.get("label") != "configured" or interface_element.get("amount") != 9: + _fail("RuntimeIntegrationTest interface array element edits did not round-trip") + return var int_key_dictionary := {} int_key_dictionary[7] = "seven" current_test.set("editor_int_key_map", int_key_dictionary) diff --git a/include/script/typescript_interface_resource.h b/include/script/typescript_interface_resource.h new file mode 100644 index 0000000..bb2d2c9 --- /dev/null +++ b/include/script/typescript_interface_resource.h @@ -0,0 +1,50 @@ +#ifndef GODE_TYPESCRIPT_INTERFACE_RESOURCE_H +#define GODE_TYPESCRIPT_INTERFACE_RESOURCE_H + +#include +#include +#include +#include +#include + +namespace gode { + +class TypeScriptInterfaceResource : public godot::Resource { + GDCLASS(TypeScriptInterfaceResource, godot::Resource) + + struct Schema { + godot::StringName interface_name; + godot::HashMap> interfaces; + }; + + static godot::HashMap schemas; + + godot::StringName schema_id; + godot::Vector fields; + godot::HashMap nested_interfaces; + godot::HashMap nested_interface_arrays; + godot::HashMap default_values; + godot::HashMap values; + + void apply_schema(); + void configure_nested_value(const godot::StringName &p_name, const godot::Variant &p_value); + +protected: + static void _bind_methods(); + bool _set(const godot::StringName &p_name, const godot::Variant &p_value); + bool _get(const godot::StringName &p_name, godot::Variant &r_value) const; + void _get_property_list(godot::List *p_list) const; + +public: + static void register_schema( + const godot::StringName &p_schema_id, + const godot::StringName &p_interface_name, + const godot::HashMap> &p_interfaces); + void configure(const godot::StringName &p_schema_id); + int64_t get_interface_field_count() const { return fields.size(); } + godot::StringName get_interface_field_name(int64_t p_index) const; +}; + +} // namespace gode + +#endif // GODE_TYPESCRIPT_INTERFACE_RESOURCE_H diff --git a/include/script/typescript_script.h b/include/script/typescript_script.h index 754660e..e5643fa 100644 --- a/include/script/typescript_script.h +++ b/include/script/typescript_script.h @@ -41,6 +41,7 @@ class TypeScriptScript : public godot::ScriptExtension { mutable godot::HashMap signals; mutable godot::HashMap properties; mutable godot::Vector property_list; + mutable godot::HashMap interface_array_schemas; mutable godot::HashMap property_defaults; mutable godot::HashMap constants; mutable godot::HashMap member_lines; diff --git a/src/register_runtime_types.cpp b/src/register_runtime_types.cpp index 2023929..a4dc396 100644 --- a/src/register_runtime_types.cpp +++ b/src/register_runtime_types.cpp @@ -2,6 +2,7 @@ #include "runtime/gode_runtime_bridge.h" #include "runtime/node_runtime.h" #include "script/typescript_language.h" +#include "script/typescript_interface_resource.h" #include "script/typescript_loader.h" #include "script/typescript_saver.h" #include "script/typescript_script.h" @@ -22,6 +23,7 @@ void initialize_gode_runtime_module(godot::ModuleInitializationLevel p_level) { return; } GDREGISTER_CLASS(gode::TypeScriptScript); + GDREGISTER_CLASS(gode::TypeScriptInterfaceResource); GDREGISTER_CLASS(gode::TypeScriptLanguage); GDREGISTER_CLASS(gode::TypeScriptSaver); GDREGISTER_CLASS(gode::TypeScriptLoader); diff --git a/src/script/script_instance.cpp b/src/script/script_instance.cpp index b1e3edb..9b13206 100644 --- a/src/script/script_instance.cpp +++ b/src/script/script_instance.cpp @@ -2,6 +2,7 @@ #include "runtime/napi_error_utils.h" #include "runtime/node_runtime.h" #include "runtime/value_convert.h" +#include "script/typescript_interface_resource.h" #include "script/typescript_script.h" #include #include @@ -332,6 +333,16 @@ void ScriptInstance::reload(bool p_keep_state) { } bool ScriptInstance::set(const StringName &p_name, const Variant &p_value) { + if (script.is_valid() && script->interface_array_schemas.has(p_name) && p_value.get_type() == Variant::ARRAY) { + const StringName schema_id = script->interface_array_schemas[p_name]; + Array array = p_value; + for (int64_t i = 0; i < array.size(); i++) { + TypeScriptInterfaceResource *resource = Object::cast_to(array[i]); + if (resource) { + resource->configure(schema_id); + } + } + } if (placeholder) { placeholder_properties[p_name] = p_value; return true; diff --git a/src/script/typescript_interface_resource.cpp b/src/script/typescript_interface_resource.cpp new file mode 100644 index 0000000..1582c5c --- /dev/null +++ b/src/script/typescript_interface_resource.cpp @@ -0,0 +1,229 @@ +#include "script/typescript_interface_resource.h" + +#include +#include + +#include + +using namespace godot; + +namespace gode { + +namespace { + +constexpr const char *SCHEMA_PROPERTY = "_gode_interface_schema"; + +static std::string trim_type_text(const std::string &value) { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) { + start++; + } + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) { + end--; + } + return value.substr(start, end - start); +} + +static std::string interface_array_element(const StringName &class_name) { + std::string type = trim_type_text(String(class_name).utf8().get_data()); + if (type.size() > 2 && type.substr(type.size() - 2) == "[]") { + return trim_type_text(type.substr(0, type.size() - 2)); + } + if (type.rfind("Array<", 0) == 0 && type.back() == '>') { + return trim_type_text(type.substr(6, type.size() - 7)); + } + if (type.rfind("ReadonlyArray<", 0) == 0 && type.back() == '>') { + return trim_type_text(type.substr(14, type.size() - 15)); + } + return {}; +} + +static Variant default_value_for_type(Variant::Type type) { + switch (type) { + case Variant::BOOL: return false; + case Variant::INT: return int64_t(0); + case Variant::FLOAT: return 0.0; + case Variant::STRING: return String(); + case Variant::VECTOR2: return Vector2(); + case Variant::VECTOR2I: return Vector2i(); + case Variant::RECT2: return Rect2(); + case Variant::RECT2I: return Rect2i(); + case Variant::VECTOR3: return Vector3(); + case Variant::VECTOR3I: return Vector3i(); + case Variant::TRANSFORM2D: return Transform2D(); + case Variant::VECTOR4: return Vector4(); + case Variant::VECTOR4I: return Vector4i(); + case Variant::PLANE: return Plane(); + case Variant::QUATERNION: return Quaternion(); + case Variant::AABB: return AABB(); + case Variant::BASIS: return Basis(); + case Variant::TRANSFORM3D: return Transform3D(); + case Variant::PROJECTION: return Projection(); + case Variant::COLOR: return Color(); + case Variant::STRING_NAME: return StringName(); + case Variant::NODE_PATH: return NodePath(); + case Variant::RID: return RID(); + case Variant::CALLABLE: return Callable(); + case Variant::SIGNAL: return Signal(); + case Variant::DICTIONARY: return Dictionary(); + case Variant::ARRAY: return Array(); + case Variant::PACKED_BYTE_ARRAY: return PackedByteArray(); + case Variant::PACKED_INT32_ARRAY: return PackedInt32Array(); + case Variant::PACKED_INT64_ARRAY: return PackedInt64Array(); + case Variant::PACKED_FLOAT32_ARRAY: return PackedFloat32Array(); + case Variant::PACKED_FLOAT64_ARRAY: return PackedFloat64Array(); + case Variant::PACKED_STRING_ARRAY: return PackedStringArray(); + case Variant::PACKED_VECTOR2_ARRAY: return PackedVector2Array(); + case Variant::PACKED_VECTOR3_ARRAY: return PackedVector3Array(); + case Variant::PACKED_COLOR_ARRAY: return PackedColorArray(); + case Variant::PACKED_VECTOR4_ARRAY: return PackedVector4Array(); + case Variant::NIL: + case Variant::OBJECT: + case Variant::VARIANT_MAX: + return Variant(); + } + return Variant(); +} + +} // namespace + +HashMap TypeScriptInterfaceResource::schemas; + +void TypeScriptInterfaceResource::_bind_methods() { +} + +void TypeScriptInterfaceResource::register_schema( + const StringName &p_schema_id, + const StringName &p_interface_name, + const HashMap> &p_interfaces) { + Schema schema; + schema.interface_name = p_interface_name; + schema.interfaces = p_interfaces; + schemas[p_schema_id] = schema; +} + +void TypeScriptInterfaceResource::configure(const StringName &p_schema_id) { + if (schema_id == p_schema_id && !fields.is_empty()) { + return; + } + schema_id = p_schema_id; + apply_schema(); + notify_property_list_changed(); +} + +void TypeScriptInterfaceResource::apply_schema() { + fields.clear(); + nested_interfaces.clear(); + nested_interface_arrays.clear(); + default_values.clear(); + + const Schema *schema = schemas.getptr(schema_id); + if (!schema || !schema->interfaces.has(schema->interface_name)) { + return; + } + set_name(String(schema->interface_name)); + + for (const PropertyInfo &source_field : schema->interfaces[schema->interface_name]) { + PropertyInfo field = source_field; + const StringName structural_type = source_field.class_name; + if (!structural_type.is_empty() && schema->interfaces.has(structural_type)) { + nested_interfaces[field.name] = structural_type; + field.type = Variant::OBJECT; + field.hint = PROPERTY_HINT_RESOURCE_TYPE; + field.hint_string = get_class_static(); + field.class_name = get_class_static(); + } else if (field.type == Variant::ARRAY && !structural_type.is_empty()) { + const StringName element_type(interface_array_element(structural_type).c_str()); + if (!element_type.is_empty() && schema->interfaces.has(element_type)) { + nested_interface_arrays[field.name] = element_type; + field.hint = PROPERTY_HINT_ARRAY_TYPE; + field.hint_string = String::num_int64(Variant::OBJECT) + "/" + String::num_int64(PROPERTY_HINT_RESOURCE_TYPE) + ":" + String(get_class_static()); + field.class_name = StringName(); + } else { + field.class_name = StringName(); + } + } else if (field.type != Variant::OBJECT) { + field.class_name = StringName(); + } + fields.push_back(field); + default_values[field.name] = default_value_for_type(field.type); + } +} + +void TypeScriptInterfaceResource::configure_nested_value(const StringName &p_name, const Variant &p_value) { + const Schema *schema = schemas.getptr(schema_id); + if (!schema) { + return; + } + if (nested_interfaces.has(p_name)) { + TypeScriptInterfaceResource *resource = Object::cast_to(p_value); + if (resource) { + const StringName nested_schema_id(String(schema_id) + "::" + String(nested_interfaces[p_name])); + register_schema(nested_schema_id, nested_interfaces[p_name], schema->interfaces); + resource->configure(nested_schema_id); + } + return; + } + if (nested_interface_arrays.has(p_name) && p_value.get_type() == Variant::ARRAY) { + const StringName nested_name = nested_interface_arrays[p_name]; + const StringName nested_schema_id(String(schema_id) + "::" + String(nested_name)); + register_schema(nested_schema_id, nested_name, schema->interfaces); + Array array = p_value; + for (int64_t i = 0; i < array.size(); i++) { + TypeScriptInterfaceResource *resource = Object::cast_to(array[i]); + if (resource) { + resource->configure(nested_schema_id); + } + } + } +} + +bool TypeScriptInterfaceResource::_set(const StringName &p_name, const Variant &p_value) { + if (p_name == StringName(SCHEMA_PROPERTY)) { + configure(StringName(String(p_value))); + return true; + } + for (const PropertyInfo &field : fields) { + if (field.name == p_name) { + configure_nested_value(p_name, p_value); + values[p_name] = p_value; + emit_changed(); + return true; + } + } + return false; +} + +bool TypeScriptInterfaceResource::_get(const StringName &p_name, Variant &r_value) const { + if (p_name == StringName(SCHEMA_PROPERTY)) { + r_value = String(schema_id); + return true; + } + if (values.has(p_name)) { + r_value = values[p_name]; + return true; + } + if (default_values.has(p_name)) { + r_value = default_values[p_name]; + return true; + } + return false; +} + +void TypeScriptInterfaceResource::_get_property_list(List *p_list) const { + PropertyInfo schema_property(Variant::STRING, SCHEMA_PROPERTY, PROPERTY_HINT_NONE, "", PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_INTERNAL); + p_list->push_back(schema_property); + for (const PropertyInfo &field : fields) { + p_list->push_back(field); + } +} + +StringName TypeScriptInterfaceResource::get_interface_field_name(int64_t p_index) const { + if (p_index < 0 || p_index >= fields.size()) { + return StringName(); + } + return fields[p_index].name; +} + +} // namespace gode diff --git a/src/script/typescript_script.cpp b/src/script/typescript_script.cpp index bbdaa7c..80b099a 100644 --- a/src/script/typescript_script.cpp +++ b/src/script/typescript_script.cpp @@ -2,6 +2,7 @@ #include "runtime/node_runtime.h" #include "runtime/value_convert.h" #include "script/typescript_compile_service.h" +#include "script/typescript_interface_resource.h" #include "script/typescript_language.h" #include @@ -193,6 +194,10 @@ static StringName class_name_from_class_node(TSNode class_node, const std::strin return StringName(node_text(source, name_node).c_str()); } +static StringName interface_name_from_interface_node(TSNode interface_node, const std::string &source) { + return class_name_from_class_node(interface_node, source); +} + static bool node_text_is_default(TSNode node, const std::string &source) { return !ts_node_is_null(node) && node_text(source, node) == "default"; } @@ -300,6 +305,32 @@ static TSNode find_class_declaration_by_name(TSNode root_node, uint32_t child_co return {}; } +static TSNode find_interface_declaration_by_name(TSNode root_node, uint32_t child_count, const std::string &source, const StringName &name) { + if (name.is_empty()) { + return {}; + } + + for (uint32_t i = 0; i < child_count; i++) { + TSNode child = ts_node_child(root_node, i); + if (strcmp(ts_node_type(child), "class_declaration") == 0) { + if (class_name_from_class_node(child, source) == name) { + return child; + } + continue; + } + if (strcmp(ts_node_type(child), "export_statement") != 0) { + continue; + } + for (uint32_t j = 0; j < ts_node_child_count(child); j++) { + TSNode exported_child = ts_node_child(child, j); + if (strcmp(ts_node_type(exported_child), "interface_declaration") == 0 && interface_name_from_interface_node(exported_child, source) == name) { + return exported_child; + } + } + } + return {}; +} + struct TypeAliasDefinition { std::string value; std::vector type_parameters; @@ -745,6 +776,7 @@ enum class ExportTypeKind { OBJECT, RESOURCE, NODE, + INTERFACE }; struct ExportTypeClassification { @@ -1554,6 +1586,12 @@ static void apply_export_type_classification(PropertyInfo &property, const Expor } return; } + case ExportTypeKind::INTERFACE: { + property.type = Variant::OBJECT; + property.hint = PROPERTY_HINT_RESOURCE_TYPE; + property.hint_string = TypeScriptInterfaceResource::get_class_static(); + property.class_name = TypeScriptInterfaceResource::get_class_static(); + } break; case ExportTypeKind::OBJECT: case ExportTypeKind::RESOURCE: case ExportTypeKind::NODE: @@ -2277,9 +2315,9 @@ static bool parse_int_metadata_value(TSNode value, const std::string &source, in static bool resolve_property_hint_member(const std::string &member_name, PropertyHint &r_hint) { #define MATCH_PROPERTY_HINT(m_hint) \ - if (member_name == #m_hint) { \ - r_hint = m_hint; \ - return true; \ + if (member_name == #m_hint) { \ + r_hint = m_hint; \ + return true; \ } MATCH_PROPERTY_HINT(PROPERTY_HINT_NONE) MATCH_PROPERTY_HINT(PROPERTY_HINT_RANGE) @@ -2546,7 +2584,7 @@ static bool method_node_is_accessor(TSNode method_node, TSNode name_node) { return false; } -static void parse_class_members(TSNode class_node, const std::string &source, const String &file_path, TSNode root_node, uint32_t child_count, HashMap &properties, Vector &property_list, HashMap &property_defaults, HashMap &methods, HashMap &static_methods, HashMap &signals, HashMap &rpc_configs, HashMap &member_lines, const HashMap> &interfaces) { +static void parse_class_members(TSNode class_node, const std::string &source, const String &file_path, TSNode root_node, uint32_t child_count, HashMap &properties, Vector &property_list, HashMap &interface_array_schemas, HashMap &property_defaults, HashMap &methods, HashMap &static_methods, HashMap &signals, HashMap &rpc_configs, HashMap &member_lines, const HashMap> &interfaces) { TSNode body_node = ts_node_child_by_field_name(class_node, "body", 4); if (ts_node_is_null(body_node)) { return; @@ -2707,6 +2745,27 @@ static void parse_class_members(TSNode class_node, const std::string &source, co if (!ts_node_is_null(default_object_node) && strcmp(ts_node_type(default_object_node), "object") == 0) { parse_object_defaults(default_object_node, source, prefix, property_defaults); } + } else if (!type_str.empty() && iface_key.contains("[]") && interfaces.has(iface_key.substr(0, iface_key.length() - 2))) { + StringName inner_type = iface_key.substr(0, iface_key.length() - 2); + if (interfaces.has(inner_type)) { + const StringName schema_id(String(file_path) + "::" + String(inner_type)); + // The container remains a regular Godot Array. Its typed element is a + // Resource whose dynamic property list is populated from the interface. + pi.type = Variant::ARRAY; + pi.hint = PROPERTY_HINT_ARRAY_TYPE; + pi.hint_string = String::num_int64(Variant::OBJECT) + "/" + String::num_int64(PROPERTY_HINT_RESOURCE_TYPE) + ":" + String(TypeScriptInterfaceResource::get_class_static()); + pi.class_name = StringName(); + interface_array_schemas[field_name] = schema_id; + TypeScriptInterfaceResource::register_schema(schema_id, inner_type, interfaces); + properties[field_name] = pi; + property_list.push_back(pi); + if (!ts_node_is_null(field_value_node)) { + Variant default_value; + if (parse_default_value(field_value_node, source, pi.type, default_value)) { + property_defaults[field_name] = default_value; + } + } + } } else { properties[field_name] = pi; property_list.push_back(pi); @@ -2979,6 +3038,7 @@ bool TypeScriptScript::compile() const { base_class_name = StringName(); base_script_path = String(); property_list.clear(); + interface_array_schemas.clear(); methods.clear(); static_methods.clear(); signals.clear(); @@ -3047,7 +3107,7 @@ bool TypeScriptScript::compile() const { base_class_qualifier = qualifier_from_extends_node(base_class_node, source); } base_script_path = resolve_imported_class_path(get_path(), source, root_node, child_count, base_class_name, base_class_qualifier); - parse_class_members(class_node, source, get_path(), root_node, child_count, properties, property_list, property_defaults, methods, static_methods, signals, rpc_configs, member_lines, interfaces); + parse_class_members(class_node, source, get_path(), root_node, child_count, properties, property_list, interface_array_schemas, property_defaults, methods, static_methods, signals, rpc_configs, member_lines, interfaces); parse_static_exports(class_node, source, get_path(), root_node, child_count, properties, property_list, property_defaults); parse_exported_field_defaults(class_node, source, properties, property_defaults); collect_parent_properties(base_class_name, base_class_qualifier, source, root_node, child_count, get_path(), properties, property_list, property_defaults);