Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG-ZH.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 2.4.4

- 新增导出 `interface[]` 属性的 Inspector 编辑,支持嵌套字段和默认值。
- 新增 `Signal<T>` 类型检查和运行时绑定,恢复全局 `Signal` 声明。
- 修复 ESM 单例共享及显式 `super()` 的脚本对象绑定。
- 修复内置运算符的 `number`/`bigint` 重载选择。

## 2.4.3

- 增加 TypeScript 脚本 `@GlobalClass`,对齐 Godot C# 全局类命名。
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 2.4.4

- Added Inspector editing for exported `interface[]` properties, including nested fields and defaults.
- Added `Signal<T>` 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.
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.22)
cmake_policy(SET CMP0091 NEW)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>: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)
Expand Down
2 changes: 1 addition & 1 deletion example/addons/gode/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 20 additions & 1 deletion example/scripts/tests/runtime_integration_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
46 changes: 45 additions & 1 deletion example/scripts/tests/signal_test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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: [
Expand All @@ -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<T> 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<T> 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 }>;
Expand Down
15 changes: 14 additions & 1 deletion generator/builtin_classes_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()"
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 35 additions & 4 deletions generator/dts_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ def _builtin_type_parameters(self, ts_name: str) -> str:
return '<T extends VariantArgument = VariantArgument>'
if ts_name == 'GDDictionary':
return '<K extends VariantArgument = VariantArgument, V extends VariantArgument = VariantArgument>'
if ts_name == 'Signal':
return '<T extends (...args: any[]) => void = (...args: VariantArgument[]) => void>'
return ''

def _array_like_type(self) -> str:
Expand All @@ -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<T>'
for arg in arguments
if arg['type'] == 'Signal'
}
return {}

def _builtin_method_param_overrides(self, ts_name: str, method_name: str, arguments: list) -> dict:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<T>' 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};')

Expand Down Expand Up @@ -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<T>.
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', []):
Expand Down
11 changes: 10 additions & 1 deletion generator/templates/class_binding.cpp.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -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<godot::{{ godot_class_name }}>(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<Napi::External<godot::{{ godot_class_name }}>>().Data();
owns_instance = false;
} else if (info.Length() == 1 && info[0].IsObject()) {
Expand Down
12 changes: 12 additions & 0 deletions include/runtime/value_convert.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 &registered_class_name = "");
Expand Down
3 changes: 3 additions & 0 deletions include/script/script_instance.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <godot_cpp/variant/string.hpp>
#include <godot_cpp/variant/string_name.hpp>
#include <godot_cpp/variant/variant.hpp>
#include <string>
#include <vector>

namespace gode {
Expand All @@ -34,6 +35,8 @@ class ScriptInstance {
mutable std::vector<GDExtensionPropertyInfo> 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;

Expand Down
Loading