diff --git a/CHANGELOG-ZH.md b/CHANGELOG-ZH.md index 2cb437f..d9457b2 100644 --- a/CHANGELOG-ZH.md +++ b/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 2.4.4 + +- 新增导出 `interface[]` 属性的 Inspector 编辑,支持嵌套字段和默认值。 +- 新增 `Signal` 类型检查和运行时绑定,恢复全局 `Signal` 声明。 +- 修复 ESM 单例共享及显式 `super()` 的脚本对象绑定。 +- 修复内置运算符的 `number`/`bigint` 重载选择。 + ## 2.4.3 - 增加 TypeScript 脚本 `@GlobalClass`,对齐 Godot C# 全局类命名。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 992e0f0..96900b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.4.4 + +- Added Inspector editing for exported `interface[]` properties, including nested fields and defaults. +- Added `Signal` type checking and runtime binding; restored the global `Signal` declaration. +- Fixed shared ESM singletons and script owner binding for explicit `super()` calls. +- Fixed `number`/`bigint` overload selection for builtin operators. + ## 2.4.3 - Added `@GlobalClass` for TypeScript scripts, matching Godot C# global class naming. diff --git a/CMakeLists.txt b/CMakeLists.txt index 61d1695..395bc9d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.22) cmake_policy(SET CMP0091 NEW) set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" CACHE STRING "Select the MSVC runtime library." FORCE) -project(gode VERSION 2.4.3 LANGUAGES C CXX) +project(gode VERSION 2.4.4 LANGUAGES C CXX) if(NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE) diff --git a/example/addons/gode/plugin.cfg b/example/addons/gode/plugin.cfg index b1c48c2..23d728b 100644 --- a/example/addons/gode/plugin.cfg +++ b/example/addons/gode/plugin.cfg @@ -4,5 +4,5 @@ name="gode" description="Godot with TypeScript and Node.js" website="https://godothub.com" author="GodotHub" -version="2.4.3" +version="2.4.4" script="gode.gd" diff --git a/example/scripts/tests/runtime_integration_test.ts b/example/scripts/tests/runtime_integration_test.ts index 9757144..b2d0c2a 100644 --- a/example/scripts/tests/runtime_integration_test.ts +++ b/example/scripts/tests/runtime_integration_test.ts @@ -274,6 +274,20 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase { const recompiledStaticModule = await compileEsm(staticRootSource, staticRootPath); nodeAssert.equal(recompiledStaticModule.recovered, 202); + const singletonPath = path.join(retryDir, "shared_singleton.mjs"); + const singletonConsumerPath = path.join(retryDir, "singleton_consumer.mjs"); + const singletonSource = "export default class SharedSingleton { static INSTANCE; }\n"; + const singletonConsumerSource = `import SharedSingleton from ${JSON.stringify(singletonPath)};\nexport { SharedSingleton };\n`; + fs.writeFileSync(singletonPath, singletonSource, "utf8"); + const singletonConsumerModule = await compileEsm(singletonConsumerSource, singletonConsumerPath); + const canonicalVariantPath = singletonPath.replace(/\\/g, "/"); + const directlyLoadedSingletonModule = await compileEsm(singletonSource, canonicalVariantPath); + const importedSingleton = singletonConsumerModule.SharedSingleton as unknown as { INSTANCE?: unknown }; + const directlyLoadedSingleton = directlyLoadedSingletonModule.default as unknown as { INSTANCE?: unknown }; + nodeAssert.equal(importedSingleton, directlyLoadedSingleton); + directlyLoadedSingleton.INSTANCE = "shared-instance"; + nodeAssert.equal(importedSingleton.INSTANCE, "shared-instance"); + const metaPath = path.join(retryDir, "meta_url.mjs"); const metaModule = await compileEsm("export const url = import.meta.url;\n", metaPath); nodeAssert.equal(fileURLToPath(String(metaModule.url)), metaPath); @@ -652,7 +666,12 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase { const vector2iFromBigInt = new Vector2i(1n, 2n); nodeAssert.equal(vector2iFromBigInt.x, 1); nodeAssert.equal(vector2iFromBigInt.y, 2); - const doubledVector2i = vector2i.multiply(2n); + const numberScaledVector2i: Vector2 = vector2i.multiply(2); + nodeAssert.ok(numberScaledVector2i instanceof Vector2); + nodeAssert.equal(numberScaledVector2i.x, 2); + nodeAssert.equal(numberScaledVector2i.y, 4); + const doubledVector2i: Vector2i = vector2i.multiply(2n); + nodeAssert.ok(doubledVector2i instanceof Vector2i); nodeAssert.equal(doubledVector2i.x, 2); nodeAssert.equal(doubledVector2i.y, 4); nodeAssert.throws(() => vector2i.multiply(9223372036854775808n), RangeError); diff --git a/example/scripts/tests/signal_test.ts b/example/scripts/tests/signal_test.ts index 3e5f747..1bc5336 100644 --- a/example/scripts/tests/signal_test.ts +++ b/example/scripts/tests/signal_test.ts @@ -1,4 +1,29 @@ -import { GDDictionary, Node, type VariantArgument, Vector3 } from "godot"; +import { GDDictionary, Node, Signal, type VariantArgument, Vector3 } from "godot"; + +function verifyTypedSignalDeclarations(signal: Signal<(message: string, count: number) => void>): void { + signal.connect((message, count) => void `${message}:${count}`); + signal.disconnect((message, count) => void `${message}:${count}`); + signal.is_connected((message, count) => void `${message}:${count}`); + signal.emit("ready", 1); + // @ts-expect-error Typed signals reject arguments in the wrong order. + signal.emit(1, "ready"); + // @ts-expect-error Typed signals reject callbacks with incompatible parameters. + signal.connect((message: number) => void message); +} + +void verifyTypedSignalDeclarations; + +function verifyGeneratedGodotSignalDeclarations(node: Node): void { + node.ready.connect(() => undefined); + node.ready.emit(); + node.child_entered_tree.connect(child => void child.get_name()); + // @ts-expect-error Node.ready has no signal arguments. + node.ready.emit("unexpected"); + // @ts-expect-error child_entered_tree provides a Node, not a string. + node.child_entered_tree.connect((child: string) => void child); +} + +void verifyGeneratedGodotSignalDeclarations; function assert(condition: boolean, message: string): void { if (!condition) { @@ -30,6 +55,10 @@ function dictionaryValue(container: VariantArgument, key: string): VariantArgume } export default class SignalTest extends Node { + static constructor_owner_id: number | bigint = 0; + + typed_completed!: Signal<(message: string, count: number) => void>; + static signals = { completed: [{ name: "payload", type: "Object" }], test_finished: [ @@ -50,13 +79,28 @@ export default class SignalTest extends Node { threshold = 3 as const; spawn_offset = new Vector3(1, 2, 3) as Vector3; + constructor() { + // Deliberately do not forward Gode's internal owner argument. ScriptInstance + // must still bind this wrapper to the Godot object that owns the script. + super(); + SignalTest.constructor_owner_id = this.get_instance_id(); + } + run_test() { void this.run(); } async run() { try { + assert(SignalTest.constructor_owner_id === this.get_instance_id(), "explicit super() created a second Godot object"); assert(this.has_signal("completed"), "static signal metadata was not registered"); + assert(this.has_signal("typed_completed"), "Signal field annotation was not registered"); + let typedSignalPayload = ""; + this.typed_completed.connect((message, count) => { + typedSignalPayload = `${message}:${count}`; + }); + this.typed_completed.emit("ready", 2); + assert(typedSignalPayload === "ready:2", "Signal field was not bound to the runtime Godot signal"); assert(this.threshold === 3, "exported scalar default was not applied"); assert(this.spawn_offset.x === 1 && this.spawn_offset.y === 2 && this.spawn_offset.z === 3, "exported Vector3 default was not applied"); const propertyList = this.get_property_list() as Array<{ name: VariantArgument; hint?: VariantArgument; hint_string?: VariantArgument }>; diff --git a/generator/builtin_classes_generator.py b/generator/builtin_classes_generator.py index f478c51..0fc7336 100644 --- a/generator/builtin_classes_generator.py +++ b/generator/builtin_classes_generator.py @@ -18,9 +18,11 @@ js_class_name as get_js_class_name, ) -def napi_match_expr(type_name, index): +def napi_match_expr(type_name, index, allow_number_for_int=True): value = f"info[{index}]" if type_name == 'int': + if not allow_number_for_int: + return f"{value}.IsBigInt()" return f"({value}.IsNumber() || {value}.IsBigInt())" if type_name == 'float': return f"{value}.IsNumber()" @@ -354,6 +356,17 @@ def run(self): # Each item has 'name' and 'overloads' list grouped_operators = [] for name, overloads in operator_groups.items(): + binary_types = { + overload['arguments'][0]['type'] + for overload in overloads + if not overload['is_unary'] and overload['arguments'] + } + if 'int' in binary_types and 'float' in binary_types: + for overload in overloads: + if not overload['is_unary'] and overload['arguments'][0]['type'] == 'int': + overload['arguments'][0]['match_expr'] = napi_match_expr( + 'int', 0, allow_number_for_int=False + ) grouped_operators.append({ 'name': name, 'overloads': overloads, diff --git a/generator/dts_generator.py b/generator/dts_generator.py index 6ebda9d..4922da7 100644 --- a/generator/dts_generator.py +++ b/generator/dts_generator.py @@ -235,6 +235,8 @@ def _builtin_type_parameters(self, ts_name: str) -> str: return '' if ts_name == 'GDDictionary': return '' + if ts_name == 'Signal': + return ' void = (...args: VariantArgument[]) => void>' return '' def _array_like_type(self) -> str: @@ -256,6 +258,12 @@ def _builtin_constructor_param_overrides(self, ts_name: str, arguments: list) -> if arg['type'] == 'Dictionary': overrides[arg['name']] = self._dictionary_like_type() return overrides + if ts_name == 'Signal': + return { + arg['name']: 'Signal' + for arg in arguments + if arg['type'] == 'Signal' + } return {} def _builtin_method_param_overrides(self, ts_name: str, method_name: str, arguments: list) -> dict: @@ -283,6 +291,12 @@ def _builtin_method_param_overrides(self, ts_name: str, method_name: str, argume elif arg['name'] == 'default': overrides[arg['name']] = 'V' return overrides + if ts_name == 'Signal' and method_name in {'connect', 'disconnect', 'is_connected'}: + return { + arg['name']: 'Callable | T' + for arg in arguments + if arg['name'] == 'callable' + } return {} def _builtin_method_return_override(self, ts_name: str, method_name: str) -> str: @@ -452,15 +466,27 @@ def _gen_builtin(self, cls_data: dict, ts_name: str, indent: int) -> list: params = self._format_params(args, self._builtin_method_param_overrides(ts_name, method['name'], args)) static = 'static ' if method.get('is_static') else '' if method.get('is_vararg'): - params = (params + ', ...args: VariantArgument[]') if params else '...args: VariantArgument[]' + vararg = '...args: Parameters' if ts_name == 'Signal' and method['name'] == 'emit' else '...args: VariantArgument[]' + params = (params + ', ' + vararg) if params else vararg self._append_unique_line(lines, body_seen, f'{ind2}{static}{name}({params}): {ret};') + operator_types = {} + for operator in cls_data.get('operators', []): + op_name = builtin_operator_method_name(operator['name']) + right_type = operator.get('right_type') + if op_name and right_type: + operator_types.setdefault(op_name, set()).add(right_type) + for operator in cls_data.get('operators', []): op_name = builtin_operator_method_name(operator['name']) if not op_name: continue right_type = operator.get('right_type') - params = f'right: {self._type_to_ts(right_type, is_input=True)}' if right_type else '' + if right_type == 'int' and 'float' in operator_types.get(op_name, set()): + right_ts_type = 'bigint' + else: + right_ts_type = self._type_to_ts(right_type, is_input=True) if right_type else '' + params = f'right: {right_ts_type}' if right_type else '' ret = self._type_to_ts_with_meta(operator.get('return_type', 'void'), meta=operator.get('return_meta', '')) self._append_unique_line(lines, body_seen, f'{ind2}{member_name(op_name)}({params}): {ret};') @@ -541,9 +567,14 @@ def _gen_class(self, cls_data: dict, indent: int, is_singleton: bool = False) -> if resolved_setter and resolved_setter not in declared_methods: self._append_unique_line(lines, body_seen, f'{body_ind}{sanitize_name(resolved_setter)}(value: {ts_type_input}): void;') - # Signals (as comments — no runtime type) + # Signals carry their extension_api argument list through Signal. for sig in cls_data.get('signals', []): - self._append_unique_line(lines, body_seen, f'{body_ind}{sig["name"]}: Signal;') + params = self._format_params(sig.get('arguments', [])) + self._append_unique_line( + lines, + body_seen, + f'{body_ind}{sig["name"]}: Signal<({params}) => void>;', + ) # Methods for method in cls_data.get('methods', []): diff --git a/generator/templates/class_binding.cpp.jinja2 b/generator/templates/class_binding.cpp.jinja2 index ce1592e..627aebb 100644 --- a/generator/templates/class_binding.cpp.jinja2 +++ b/generator/templates/class_binding.cpp.jinja2 @@ -182,7 +182,16 @@ Napi::Value {{ class_name }}Binding::init(Napi::Env env, Napi::Object exports) { } {{ class_name }}Binding::{{ class_name }}Binding(const Napi::CallbackInfo& info) : Napi::ObjectWrap<{{ class_name }}Binding>(info) { - if (info.Length() == 1 && info[0].IsExternal()) { + godot::Object *script_owner = gode::consume_script_instance_owner(); + if (script_owner) { + instance = godot::Object::cast_to(script_owner); + if (!instance) { + Napi::TypeError::New(info.Env(), "{{ js_class_name }} script owner is not compatible with {{ js_class_name }}").ThrowAsJavaScriptException(); + owns_instance = false; + return; + } + owns_instance = false; + } else if (info.Length() == 1 && info[0].IsExternal()) { instance = info[0].As>().Data(); owns_instance = false; } else if (info.Length() == 1 && info[0].IsObject()) { diff --git a/include/runtime/value_convert.h b/include/runtime/value_convert.h index b9b4da2..fcad125 100644 --- a/include/runtime/value_convert.h +++ b/include/runtime/value_convert.h @@ -55,7 +55,19 @@ struct ClassInfo { CreateFunc creator; }; +class ScriptInstanceOwnerScope { + godot::Object *previous_owner = nullptr; + +public: + explicit ScriptInstanceOwnerScope(godot::Object *p_owner); + ~ScriptInstanceOwnerScope(); + + ScriptInstanceOwnerScope(const ScriptInstanceOwnerScope &) = delete; + ScriptInstanceOwnerScope &operator=(const ScriptInstanceOwnerScope &) = delete; +}; + void register_class(const std::string &name, const std::string &godot_class_name, Napi::FunctionReference *ref, UnwrapFunc unwrapper, WrapFunc wrapper, CreateFunc creator); +godot::Object *consume_script_instance_owner(); godot::Object *unwrap_godot_object(const Napi::Object &value); void register_godot_instance(godot::Object *obj, Napi::Object js_obj); Napi::Value wrap_godot_object(Napi::Env env, godot::Object *obj, const std::string ®istered_class_name = ""); diff --git a/include/script/script_instance.h b/include/script/script_instance.h index 8085993..818bfde 100644 --- a/include/script/script_instance.h +++ b/include/script/script_instance.h @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace gode { @@ -34,6 +35,8 @@ class ScriptInstance { mutable std::vector method_return_gde_cache; private: + void register_script_signals(); + bool bind_script_signals_to_instance(const Napi::Object &p_instance, const std::string &p_context); void notification_bind(Napi::Object instance, int32_t p_what, bool p_reversed); void store_property_value_for_lifetime(const godot::StringName &p_name, const godot::Variant &p_value) const; diff --git a/src/runtime/node_bootstrap_scripts.cpp b/src/runtime/node_bootstrap_scripts.cpp index 7c4b991..5fff5bd 100644 --- a/src/runtime/node_bootstrap_scripts.cpp +++ b/src/runtime/node_bootstrap_scripts.cpp @@ -748,8 +748,16 @@ std::string esm_bootstrap_script() { "const _gode_builtin_modules = new Set(Module.builtinModules || []);" "const _gode_is_builtin_module = (specifier) => typeof specifier === 'string' && (specifier.startsWith('node:') || _gode_builtin_modules.has(specifier));" "const _gode_strip_module_generation = (p) => typeof p === 'string' ? p.replace(/\\?gode_gen=\\d+$/, '') : p;" - "const _gode_module_cache_key = (p) => String(global.__gode_esm_generation) + ':' + p;" - "const _gode_module_identifier = (p) => p + '?gode_gen=' + String(global.__gode_esm_generation);" + "const _gode_canonical_module_path = (p) => {" + " if (typeof p !== 'string') return p;" + " p = _gode_strip_module_generation(p);" + " if (p.startsWith('file://')) { try { p = require('url').fileURLToPath(p); } catch (_) {} }" + " if (_gode_is_virtual_path(p)) return p.replace(/\\\\/g, '/');" + " if (path.isAbsolute(p)) return path.normalize(p).replace(/\\\\/g, '/');" + " return p.replace(/\\\\/g, '/');" + "};" + "const _gode_module_cache_key = (p) => String(global.__gode_esm_generation) + ':' + _gode_canonical_module_path(p);" + "const _gode_module_identifier = (p) => _gode_canonical_module_path(p) + '?gode_gen=' + String(global.__gode_esm_generation);" "const _gode_source_fallback = (p) => {" " if (typeof p !== 'string') return p;" " const normalized = p.replace(/\\\\/g, '/');" @@ -829,7 +837,7 @@ std::string esm_bootstrap_script() { " }" "};" "global.__gode_forget_esm_module = function(filepath) {" - " filepath = _gode_strip_module_generation(filepath);" + " filepath = _gode_canonical_module_path(filepath);" " global.__gode_esm_cache.delete(filepath);" " global.__gode_esm_pending.delete(filepath);" " global.__gode_esm_pending_source.delete(filepath);" @@ -993,7 +1001,7 @@ std::string esm_bootstrap_script() { "}" "" "global.__gode_resolve_to_module = function(specifier, referrerPath) {" - " referrerPath = _gode_strip_module_generation(referrerPath);" + " referrerPath = _gode_canonical_module_path(referrerPath);" " let resolvedPath;" " if (specifier.startsWith('#')) {" " const pkgImport = __gode_resolve_pkg_import(specifier, referrerPath);" @@ -1043,7 +1051,7 @@ std::string esm_bootstrap_script() { " }" " }" " let source;" - " resolvedPath = _gode_existing_or_source(resolvedPath);" + " resolvedPath = _gode_canonical_module_path(_gode_existing_or_source(resolvedPath));" " try {" " source = fs.readFileSync(resolvedPath, 'utf8');" " } catch(e) {" @@ -1121,10 +1129,24 @@ std::string esm_bootstrap_script() { " if (!global.__gode_esm_supported) {" " throw new Error('vm.SourceTextModule is not available');" " }" + " filepath = _gode_canonical_module_path(filepath);" " if (global.__gode_esm_source_cache.has(filepath) && global.__gode_esm_source_cache.get(filepath) !== source) {" " global.__gode_forget_esm_module(filepath);" " }" " if (global.__gode_esm_cache.has(filepath)) { return global.__gode_esm_cache.get(filepath); }" + " const linkedModule = global.__gode_esm_mod_cache.get(_gode_module_cache_key(filepath));" + " if (linkedModule && global.__gode_esm_source_cache.get(filepath) === source && (linkedModule.status === 'linked' || linkedModule.status === 'evaluated')) {" + " try {" + " if (linkedModule.status === 'linked') await linkedModule.evaluate();" + " if (linkedModule.status === 'evaluated') {" + " const ns = linkedModule.namespace;" + " global.__gode_esm_cache.set(filepath, ns);" + " return ns;" + " }" + " } catch (_) {" + " global.__gode_forget_esm_module(filepath);" + " }" + " }" " if (global.__gode_esm_pending.has(filepath)) {" " if (global.__gode_esm_pending_source.get(filepath) === source) { return global.__gode_esm_pending.get(filepath); }" " global.__gode_forget_esm_module(filepath);" @@ -1177,7 +1199,10 @@ std::string esm_bootstrap_script() { "" "global.__gode_compile_esm = async function(code, filename) {" " try {" - " global.__gode_forget_esm_module(filename);" + " filename = _gode_canonical_module_path(filename);" + " const linkedModule = global.__gode_esm_mod_cache.get(_gode_module_cache_key(filename));" + " const canReuseLinkedModule = !global.__gode_esm_cache.has(filename) && global.__gode_esm_source_cache.get(filename) === code && linkedModule && (linkedModule.status === 'linked' || linkedModule.status === 'evaluated');" + " if (!canReuseLinkedModule) global.__gode_forget_esm_module(filename);" " const ns = await global.__gode_load_esm(filename, code);" " return ns;" " } catch (e) {" diff --git a/src/runtime/value_convert.cpp b/src/runtime/value_convert.cpp index 50290ad..5dbd2fb 100644 --- a/src/runtime/value_convert.cpp +++ b/src/runtime/value_convert.cpp @@ -66,6 +66,7 @@ namespace gode { static std::unordered_map class_registry; static std::vector class_order; static std::unordered_map object_cache; +static thread_local godot::Object *script_instance_owner = nullptr; constexpr const char *GODOT_OBJECT_ID_SYMBOL = "__gode.godot_object_id__"; constexpr const char *GODOT_OBJECT_PTR_SYMBOL = "__gode.godot_object_ptr__"; @@ -73,6 +74,21 @@ constexpr double JS_MAX_SAFE_INTEGER = 9007199254740991.0; constexpr int64_t JS_MAX_SAFE_INTEGER_INT64 = 9007199254740991LL; constexpr uint64_t JS_MAX_SAFE_INTEGER_UINT64 = 9007199254740991ULL; +ScriptInstanceOwnerScope::ScriptInstanceOwnerScope(godot::Object *p_owner) : + previous_owner(script_instance_owner) { + script_instance_owner = p_owner; + +} +ScriptInstanceOwnerScope::~ScriptInstanceOwnerScope() { + script_instance_owner = previous_owner; +} + +godot::Object *consume_script_instance_owner() { + godot::Object *owner = script_instance_owner; + script_instance_owner = nullptr; + return owner; +} + static bool is_safe_js_integer(double number) { return std::isfinite(number) && std::trunc(number) == number && std::fabs(number) <= JS_MAX_SAFE_INTEGER; } diff --git a/src/script/script_instance.cpp b/src/script/script_instance.cpp index 9b13206..3989204 100644 --- a/src/script/script_instance.cpp +++ b/src/script/script_instance.cpp @@ -11,6 +11,7 @@ #include #include #include +#include using namespace godot; @@ -86,6 +87,46 @@ void ScriptInstance::store_property_value_for_lifetime(const StringName &p_name, } } +void ScriptInstance::register_script_signals() { + if (!script.is_valid() || owner == nullptr) { + return; + } + + for (const KeyValue &E : script->signals) { + if (owner->has_user_signal(E.key)) { + continue; + } + Array args; + for (const PropertyInfo &arg : E.value.arguments) { + Dictionary d; + d["name"] = String(arg.name); + d["type"] = (int)arg.type; + args.push_back(d); + } + owner->add_user_signal(E.key, args); + } +} + +bool ScriptInstance::bind_script_signals_to_instance(const Napi::Object &p_instance, const std::string &p_context) { + if (!script.is_valid() || owner == nullptr) { + return false; + } + + Napi::Env env = p_instance.Env(); + for (const KeyValue &E : script->signals) { + const std::string signal_name = String(E.key).utf8().get_data(); + Napi::Value signal_value = godot_to_napi(env, Variant(Signal(owner, E.key))); + if (log_and_clear_pending_js_exception(env, p_context + " signal conversion " + signal_name)) { + return false; + } + p_instance.Set(signal_name, signal_value); + if (log_and_clear_pending_js_exception(env, p_context + " signal binding " + signal_name)) { + return false; + } + } + return true; +} + ScriptInstance::ScriptInstance(const Ref &p_script, Object *p_owner, bool p_placeholder) : script(p_script), owner(p_owner), @@ -99,21 +140,6 @@ ScriptInstance::ScriptInstance(const Ref &p_script, Object *p_ return; } - // Register signals before creating the runtime module instance. - for (const KeyValue &E : script->signals) { - if (owner->has_user_signal(E.key)) { - continue; - } - Array args; - for (const PropertyInfo &arg : E.value.arguments) { - Dictionary d; - d["name"] = String(arg.name); - d["type"] = (int)arg.type; - args.push_back(d); - } - owner->add_user_signal(E.key, args); - } - // This can compile TypeScript; keep it outside the instance V8 scope to avoid lock inversion. if (!script->ensure_default_class_loaded()) { return; @@ -135,10 +161,16 @@ ScriptInstance::ScriptInstance(const Ref &p_script, Object *p_ Napi::Value external_owner = Napi::External::New(env, owner); Napi::Object instance; try { - instance = default_class.New({ external_owner }); + { + ScriptInstanceOwnerScope owner_scope(owner); + instance = default_class.New({ external_owner }); + } if (log_and_clear_pending_js_exception(env, "JS script constructor")) { return; } + if (!bind_script_signals_to_instance(instance, "JS script constructor")) { + return; + } } catch (const Napi::Error &e) { log_js_error("JS script constructor", js_error_to_string(e)); return; @@ -150,6 +182,9 @@ ScriptInstance::ScriptInstance(const Ref &p_script, Object *p_ return; } + // Register signals before creating the runtime module instance. + register_script_signals(); + js_instance = Napi::Persistent(instance); } } @@ -201,19 +236,7 @@ void ScriptInstance::reload(bool p_keep_state) { script->compile(); // Register new signals during reload and skip existing ones to avoid duplicate registration errors. - for (const KeyValue &E : script->signals) { - if (owner->has_user_signal(E.key)) { - continue; - } - Array args; - for (const PropertyInfo &arg : E.value.arguments) { - Dictionary d; - d["name"] = String(arg.name); - d["type"] = (int)arg.type; - args.push_back(d); - } - owner->add_user_signal(E.key, args); - } + register_script_signals(); if (!NodeRuntime::is_running()) { NodeRuntime::init_once(); @@ -265,10 +288,16 @@ void ScriptInstance::reload(bool p_keep_state) { Napi::Value external_owner = Napi::External::New(env, owner); Napi::Object instance; try { - instance = default_class.New({ external_owner }); + { + ScriptInstanceOwnerScope owner_scope(owner); + instance = default_class.New({ external_owner }); + } if (log_and_clear_pending_js_exception(env, "JS script reload constructor")) { return; } + if (!bind_script_signals_to_instance(instance, "JS script reload constructor")) { + return; + } } catch (const Napi::Error &e) { log_js_error("JS script reload constructor", js_error_to_string(e)); return; diff --git a/src/script/typescript_script.cpp b/src/script/typescript_script.cpp index 80b099a..6c9cd18 100644 --- a/src/script/typescript_script.cpp +++ b/src/script/typescript_script.cpp @@ -2736,6 +2736,10 @@ static void parse_class_members(TSNode class_node, const std::string &source, co finalize_explicit_object_hint(pi); StringName iface_key(type_str.c_str()); + StringName interface_array_key; + if (type_str.size() > 2 && type_str.compare(type_str.size() - 2, 2, "[]") == 0) { + interface_array_key = StringName(type_str.substr(0, type_str.size() - 2).c_str()); + } if (!type_str.empty() && interfaces.has(iface_key)) { std::string prefix = String(field_name).utf8().get_data() + std::string("::"); HashSet visited; diff --git a/test/test_generator_output.py b/test/test_generator_output.py index 60386c2..7d00da5 100644 --- a/test/test_generator_output.py +++ b/test/test_generator_output.py @@ -124,6 +124,7 @@ def test_builtin_argument_matching_accepts_js_arrays_for_array_types(self): from generator.builtin_classes_generator import napi_match_expr self.assertEqual("(info[0].IsNumber() || info[0].IsBigInt())", napi_match_expr("int", 0)) + self.assertEqual("info[0].IsBigInt()", napi_match_expr("int", 0, allow_number_for_int=False)) self.assertEqual("info[0].IsNumber()", napi_match_expr("float", 0)) self.assertEqual( "info[0].IsArray() || (info[0].IsObject() && info[0].As().InstanceOf(ArrayBinding::constructor.Value()))", diff --git a/test/test_repository_integrity.py b/test/test_repository_integrity.py index c5279bb..ee93d85 100644 --- a/test/test_repository_integrity.py +++ b/test/test_repository_integrity.py @@ -588,6 +588,9 @@ def source_between(start_marker: str, end_marker: str) -> str: constructor_load = constructor_body.index("if (!script->ensure_default_class_loaded())") constructor_locker = constructor_body.index("v8::Locker locker(NodeRuntime::isolate);") self.assertLess(constructor_load, constructor_locker) + self.assertIn("ScriptInstanceOwnerScope owner_scope(owner);", constructor_body) + self.assertNotIn("register_godot_instance(owner, instance);", constructor_body) + self.assertIn('bind_script_signals_to_instance(instance, "JS script constructor")', constructor_body) reload_body = source_between("void ScriptInstance::reload", "bool ScriptInstance::set") reload_lockers = [match.start() for match in re.finditer(r"v8::Locker locker\(NodeRuntime::isolate\);", reload_body)] @@ -595,6 +598,9 @@ def source_between(start_marker: str, end_marker: str) -> str: reload_load = reload_body.index("if (!script->ensure_default_class_loaded())") self.assertLess(reload_lockers[0], reload_load) self.assertLess(reload_load, reload_lockers[1]) + self.assertIn("ScriptInstanceOwnerScope owner_scope(owner);", reload_body) + self.assertNotIn("register_godot_instance(owner, instance);", reload_body) + self.assertIn('bind_script_signals_to_instance(instance, "JS script reload constructor")', reload_body) def test_script_v8_scopes_do_not_call_compiling_metadata_apis(self): risky_calls = ( @@ -864,6 +870,8 @@ def test_value_convert_registry_and_cache_are_restart_safe(self): self.assertIn("NodeRuntime::is_running()", source) self.assertIn("object_cache[id] = Napi::Weak(js_obj);", source) self.assertNotIn("object_cache[id] = Napi::Persistent(js_obj);", source) + self.assertIn("static thread_local godot::Object *script_instance_owner", source) + self.assertIn("godot::Object *consume_script_instance_owner()", source) self.assertIn("ref.SuppressDestruct();", source) self.assertNotIn("entry.second.Reset();", source) @@ -1400,7 +1408,15 @@ def test_typescript_metadata_parser_resolves_project_imports_like_compiler(self) self.assertIn('import("./signal_test" + suffix)', dependency_scan_test) signal_test = (ROOT / "example/scripts/tests/signal_test.ts").read_text(encoding="utf-8") - self.assertIn('import { GDDictionary, Node, type VariantArgument, Vector3 } from "godot";', signal_test) + self.assertIn('import { GDDictionary, Node, Signal, type VariantArgument, Vector3 } from "godot";', signal_test) + self.assertIn('Signal<(message: string, count: number) => void>', signal_test) + self.assertIn('typed_completed!: Signal<(message: string, count: number) => void>;', signal_test) + self.assertIn("constructor() {", signal_test) + self.assertIn("super();", signal_test) + self.assertIn("SignalTest.constructor_owner_id = this.get_instance_id();", signal_test) + self.assertIn('SignalTest.constructor_owner_id === this.get_instance_id()', signal_test) + self.assertIn('this.typed_completed.connect((message, count) => {', signal_test) + self.assertIn('this.typed_completed.emit("ready", 2);', signal_test) self.assertIn("function dictionaryValue(container: VariantArgument, key: string): VariantArgument", signal_test) self.assertIn("static signals = {", signal_test) self.assertIn("} as const;", signal_test) @@ -2461,6 +2477,8 @@ def test_object_and_ref_conversions_reject_plain_javascript_objects(self): self.assertIn(token, value_convert) for token in ( + "godot::Object *script_owner = gode::consume_script_instance_owner();", + "script owner is not compatible with", "constructor expected a Godot object wrapper", "constructor expected an object compatible with", "constructor expected no arguments", @@ -3037,7 +3055,7 @@ def class_body(dts_name: str) -> str: mismatches.append(f"{class_name}.{signal_name} missing Godot Signal wrapper") if f"signal_{signal_name}(const Napi::CallbackInfo& info)" not in header: mismatches.append(f"{class_name}.{signal_name} missing header declaration") - if re.search(rf"^\s+{re.escape(signal_name)}: Signal;", body, re.MULTILINE) is None: + if re.search(rf"^\s+{re.escape(signal_name)}: Signal<\(.*\) => void>;", body, re.MULTILINE) is None: mismatches.append(f"{class_name}.{signal_name} missing dts declaration") self.assertEqual([], mismatches) @@ -3253,7 +3271,8 @@ def test_generated_js_api_renames_match_typescript_contract(self): self.assertIn("type_convert(variant: VariantArgument, type: VariantType): VariantArgument;", godot_dts) self.assertNotIn("typeof_gd(", godot_dts) self.assertIn("add(right: Vector2i): Vector2i;", godot_dts) - self.assertIn("multiply(right: number | bigint): Vector2i;", godot_dts) + self.assertIn("multiply(right: bigint): Vector2i;", godot_dts) + self.assertIn("multiply(right: number): Vector2;", godot_dts) self.assertNotIn("'NodePath': 'string'", dts_generator) self.assertIn("if type_str == 'NodePath':", dts_generator) self.assertIn("return 'NodePath | string' if is_input else 'NodePath'", dts_generator) @@ -3320,6 +3339,11 @@ def test_generated_dts_singletons_are_instances_not_constructors(self): self.assertIn("export class GDDictionary", godot_dts) self.assertIn("constructor(from_gd: GDDictionary | { [key: string]: V } | Map);", godot_dts) self.assertIn("export class GDArray", godot_dts) + self.assertIn("export class Signal void = (...args: VariantArgument[]) => void>", godot_dts) + self.assertIn("connect(callable: Callable | T, flags?: number | bigint): number | bigint;", godot_dts) + self.assertIn("emit(...args: Parameters): void;", godot_dts) + self.assertIn("ready: Signal<() => void>;", godot_dts) + self.assertIn("child_entered_tree: Signal<(node: Node) => void>;", godot_dts) self.assertIn("get(index: number | bigint): T;", godot_dts) self.assertIn("count: number | bigint", godot_dts) self.assertNotIn(" const Color: typeof GodotModule.Color;", globals_dts)