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
5 changes: 5 additions & 0 deletions CHANGELOG-ZH.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 2.4.7

- 修复自定义 TypeScript 资源的保存和信号问题。
- 修复资源泄漏及脚本重载崩溃。

## 2.4.6

- 内置 Node.js 升级至 `24.21.0`。
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 2.4.7

- Fixed custom TypeScript resource saving and signal handling.
- Fixed resource leaks and script reload crashes.

## 2.4.6

- Updated the embedded Node.js to `24.21.0`.
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.6 LANGUAGES C CXX)
project(gode VERSION 2.4.7 LANGUAGES C CXX)

if(NOT CMAKE_CONFIGURATION_TYPES)
if(NOT CMAKE_BUILD_TYPE)
Expand Down
5 changes: 5 additions & 0 deletions README-ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ Godot 引擎的 JavaScript/TypeScript 脚本支持,运行在所有原生平台
| 支持情况 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 最低版本 | 10 | 9 | 10.15 | 16 | Ubuntu 22 |

如果需要轻量级方案,请尝试基于 Gode 的 [Godot-JS](https://github.com/godothub/godot-js/releases):
1. 裁剪了大量 NodeJS 内容,保留 V8 引擎。
2. 与 Gode 完全一致的代码编写方式。
3. 支持更多平台,比如 Web。

## 快速入门

### 1. 安装插件
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ JavaScript/TypeScript scripting support for the Godot engine, running on all nat
| Supported | ✅ | ✅ | ✅ | ✅ | ✅ |
| Minimum Version | 10 | 9 | 10.15 | 16 | Ubuntu 22 |

For a lightweight alternative, try [Godot-JS](https://github.com/godothub/godot-js/releases), based on Gode:

1. Removes much of Node.js while retaining the V8 engine.
2. Lets you write scripts the same way as with Gode.
3. Supports more platforms, including Web.

## Quick Start

### 1. Install the Plugin
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.6"
version="2.4.7"
script="gode.gd"
103 changes: 100 additions & 3 deletions example/addons/gode/runtime/typescript_compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -820,11 +820,66 @@
return replacement === specifierText ? "" : replacement;
}

function createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config) {
function findDefaultClass(tsApi, sourceFile) {
let exportedName;
for (const statement of sourceFile.statements) {
if (tsApi.isClassDeclaration(statement) && statement.modifiers &&
statement.modifiers.some(modifier => modifier.kind === tsApi.SyntaxKind.DefaultKeyword)) {
return statement;
}
if (tsApi.isExportAssignment(statement) && !statement.isExportEquals &&
tsApi.isIdentifier(statement.expression)) {
exportedName = statement.expression.text;
} else if (tsApi.isExportDeclaration(statement) && !statement.moduleSpecifier &&
!statement.isTypeOnly && statement.exportClause && tsApi.isNamedExports(statement.exportClause)) {
for (const specifier of statement.exportClause.elements) {
if (!specifier.isTypeOnly && specifier.name.text === "default") {
exportedName = (specifier.propertyName || specifier.name).text;
}
}
}
}
return sourceFile.statements.find(statement => tsApi.isClassDeclaration(statement) &&
statement.name && statement.name.text === exportedName);
}

function isGodotScriptClass(tsApi, program, sourcePath) {
const sourceFile = program.getSourceFile(sourcePath);
const classNode = sourceFile && findDefaultClass(tsApi, sourceFile);
if (!classNode) {
return false;
}
const checker = program.getTypeChecker();
const visited = new Set();
function inheritsGodotObject(type) {
const symbol = type.getSymbol();
if (!symbol || visited.has(symbol)) {
return false;
}
visited.add(symbol);
return (symbol.declarations || []).some(declaration => {
if (!tsApi.isClassDeclaration(declaration) && !tsApi.isClassExpression(declaration)) {
return false;
}
if (declaration.name && (declaration.name.text === "GodotObject" || declaration.name.text === "_GodotObject") &&
normalizePath(declaration.getSourceFile().fileName) === "res://addons/gode/types/godot.d.ts") {
return true;
}
// Follow runtime extends clauses, not implemented or merged interfaces.
return (declaration.heritageClauses || []).some(clause =>
clause.token === tsApi.SyntaxKind.ExtendsKeyword && clause.types.some(base =>
inheritsGodotObject(checker.getTypeAtLocation(base.expression))));
});
}
return inheritsGodotObject(checker.getTypeAtLocation(classNode));
}

function createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config, preserveSignals) {
const baseHost = createBaseHost(context, tsApi);

return (transformContext) => {
const factory = transformContext.factory || tsApi.factory;
let defaultClass;

function rewriteModuleSpecifier(specifierNode) {
if (!isStringLiteralNode(tsApi, specifierNode)) {
Expand All @@ -834,7 +889,45 @@
return replacement ? factory.createStringLiteral(replacement) : specifierNode;
}

function preserveNativeSignal(member) {
if (!tsApi.isPropertyDeclaration(member) || member.initializer || !tsApi.isIdentifier(member.name) ||
(member.modifiers && member.modifiers.some(modifier => modifier.kind === tsApi.SyntaxKind.StaticKeyword)) ||
!member.type || !tsApi.isTypeReferenceNode(member.type) || !tsApi.isIdentifier(member.type.typeName) ||
member.type.typeName.text !== "Signal" || !member.type.typeArguments || !member.type.typeArguments.length) {
return member;
}
// Signal<T> declares an engine-owned signal. Its object is bound during
// super(), so an emitted class field would replace it with undefined.
const modifiers = member.modifiers || [];
if (modifiers.some(modifier => modifier.kind === tsApi.SyntaxKind.DeclareKeyword)) {
return member;
}
const declaration = factory.updatePropertyDeclaration(member,
[...modifiers, factory.createModifier(tsApi.SyntaxKind.DeclareKeyword)], member.name,
member.questionToken || member.exclamationToken, member.type, member.initializer);
declaration.flags |= tsApi.NodeFlags.Ambient;
return declaration;
}

function visit(node) {
// Record the source on the actual default class, including local exports
// and anonymous classes, before any user static initializer can construct it.
// The native Resource constructor uses this to attach the script to
// the existing JS object instead of invoking its constructor again.
if (node === defaultClass && node.heritageClauses &&
node.heritageClauses.some(clause => clause.token === tsApi.SyntaxKind.ExtendsKeyword)) {
const marker = factory.createPropertyDeclaration(
[factory.createModifier(tsApi.SyntaxKind.StaticKeyword)],
factory.createComputedPropertyName(factory.createCallExpression(
factory.createPropertyAccessExpression(factory.createIdentifier("Symbol"), "for"),
undefined, [factory.createStringLiteral("__gode.script_path__")])),
undefined, undefined, factory.createStringLiteral(sourcePath));
// Signal annotations are native declarations only on actual Godot
// subclasses. Ordinary TypeScript classes keep their field semantics.
const members = preserveSignals ? node.members.map(preserveNativeSignal) : node.members;
node = factory.updateClassDeclaration(node, node.modifiers, node.name,
node.typeParameters, node.heritageClauses, [marker, ...members]);
}
if (tsApi.isImportDeclaration(node) && node.moduleSpecifier) {
const moduleSpecifier = rewriteModuleSpecifier(node.moduleSpecifier);
if (moduleSpecifier !== node.moduleSpecifier) {
Expand Down Expand Up @@ -872,7 +965,10 @@
return tsApi.visitEachChild(node, visit, transformContext);
}

return (sourceFile) => tsApi.visitNode(sourceFile, visit);
return (sourceFile) => {
defaultClass = findDefaultClass(tsApi, sourceFile);
return tsApi.visitNode(sourceFile, visit);
};
};
}

Expand Down Expand Up @@ -946,7 +1042,8 @@
fileName: sourcePath,
reportDiagnostics: true,
transformers: {
before: [createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config)]
before: [createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config,
isGodotScriptClass(tsApi, program, sourcePath))]
}
});
outputs.push({
Expand Down
8 changes: 8 additions & 0 deletions example/scripts/tests/runtime_assignment_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Resource } from "godot";

class RuntimeAssignmentResource extends Resource {
@Export()
label: string = "assignment export";
}

export default RuntimeAssignmentResource;
23 changes: 23 additions & 0 deletions example/scripts/tests/runtime_constructed_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Resource, Signal, Vector2 } from "godot";

export default class RuntimeConstructedResource extends Resource {
static constructions: number = 0;
typed_completed!: Signal<(label: string) => void>;
signal_ready_in_constructor: boolean = false;

@Export()
label: string = "default";

@Export()
position: Vector2 = new Vector2(1, 2);

@Export()
children: RuntimeConstructedResource[] = [];

constructor(label: string = "default") {
super();
RuntimeConstructedResource.constructions++;
this.label = label;
this.signal_ready_in_constructor = typeof this.typed_completed?.emit === "function";
}
}
8 changes: 8 additions & 0 deletions example/scripts/tests/runtime_export_clause_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Resource } from "godot";

class RuntimeExportClauseResource extends Resource {
@Export()
label: string = "export clause";
}

export { RuntimeExportClauseResource as default };
76 changes: 76 additions & 0 deletions example/scripts/tests/runtime_integration_test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import RuntimeConstructedResource from "./runtime_constructed_resource.js";
import RuntimeAssignmentResource from "./runtime_assignment_resource.js";
import RuntimeExportClauseResource from "./runtime_export_clause_resource.js";
import RuntimeStaticResource from "./runtime_static_resource.js";
import nodeAssert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
Expand Down Expand Up @@ -544,6 +548,78 @@ class RuntimeIntegrationTest extends RuntimeSameFileExportBase {
nodeAssert.equal(Number(staticRangeNamespaceProperty.hint), PROPERTY_HINT_RANGE);
nodeAssert.equal(String(staticRangeNamespaceProperty.hint_string), "0,4,1");

const beforeConstructions = RuntimeConstructedResource.constructions;
const directResource = new RuntimeConstructedResource("direct");
nodeAssert.equal(RuntimeConstructedResource.constructions, beforeConstructions + 1);
assert(directResource.get_script() !== null, "new Resource subclass has no script");
nodeAssert.equal(directResource.signal_ready_in_constructor, true, "typed signal was overwritten before the constructor body");
let directSignalLabel = "";
directResource.typed_completed.connect(label => { directSignalLabel = label; });
directResource.typed_completed.emit("direct signal");
nodeAssert.equal(directSignalLabel, "direct signal");
nodeAssert.equal(directResource.get("label"), "direct");
directResource.set("label", "from Godot");
nodeAssert.equal(directResource.label, "from Godot");
directResource.label = "saved direct";
directResource.position = new Vector2(6.5, 12.5);
directResource.children = [new RuntimeConstructedResource("nested child")];
const directPath = "user://gode_constructed_resource.tres";
nodeAssert.equal(ResourceSaver.save(directResource, directPath), GODOT_OK);
const restoredDirect = ResourceLoader.load(directPath, "", ResourceLoader.CACHE_MODE_IGNORE) as RuntimeConstructedResource;
assert(restoredDirect !== directResource, "round trip reused original Resource");
nodeAssert.equal(restoredDirect.get("label"), "saved direct");
const restoredPosition = restoredDirect.get("position") as Vector2;
nodeAssert.equal(restoredPosition.x, 6.5);
nodeAssert.equal(restoredPosition.y, 12.5);
const restoredChildren = restoredDirect.get("children") as RuntimeConstructedResource[];
nodeAssert.equal(restoredChildren.length, 1);
nodeAssert.equal(restoredChildren[0].get("label"), "nested child");
let restoredSignalLabel = "";
restoredDirect.typed_completed.connect(label => { restoredSignalLabel = label; });
restoredDirect.typed_completed.emit("restored signal");
nodeAssert.equal(restoredSignalLabel, "restored signal");

const constructedExports = [
{ resource: new RuntimeAssignmentResource(), source: "runtime_assignment_resource.ts", label: "assignment export" },
{ resource: new RuntimeExportClauseResource(), source: "runtime_export_clause_resource.ts", label: "export clause" },
{ resource: RuntimeStaticResource.singleton, source: "runtime_static_resource.ts", label: "assignment export" },
{ resource: RuntimeStaticResource.from_block, source: "runtime_static_resource.ts", label: "assignment export" },
];
for (const [index, fixture] of constructedExports.entries()) {
const script = fixture.resource.get_script() as Resource;
assert(script !== null, `constructed ${fixture.source} has no script`);
nodeAssert.equal(script.resource_path, `res://scripts/tests/${fixture.source}`);
nodeAssert.equal(fixture.resource.get("label"), fixture.label);
fixture.resource.label = `saved export ${index}`;
const fixturePath = `user://gode_constructed_export_${index}.tres`;
nodeAssert.equal(ResourceSaver.save(fixture.resource, fixturePath), GODOT_OK);
const restored = ResourceLoader.load(fixturePath, "", ResourceLoader.CACHE_MODE_IGNORE) as Resource;
nodeAssert.equal(restored.get("label"), `saved export ${index}`);
nodeAssert.equal((restored.get_script() as Resource).resource_path, script.resource_path);
if (fixture.resource instanceof RuntimeStaticResource) {
nodeAssert.equal(fixture.resource.get("static_label"), "static initialization");
nodeAssert.equal(restored.get("static_label"), "static initialization");
}
}

const nativeResourceOwner = new GDArray();
const adoptedId = (() => {
const child = new RuntimeConstructedResource("retained by Godot");
nativeResourceOwner.append(child);
return child.get_instance_id();
})();
await waitForEventLoopTurn();
forceGarbageCollection();
await waitForEventLoopTurn();
nodeAssert.equal((nativeResourceOwner.get(0) as Resource).get("label"), "retained by Godot");
nativeResourceOwner.clear();
await waitForEventLoopTurn();
for (let i = 0; i < 3; i++) {
forceGarbageCollection();
await waitForEventLoopTurn();
}
nodeAssert.equal(GD.instance_from_id(BigInt.asIntN(64, BigInt(adoptedId))), null, "constructed Resource retained a JS/native reference cycle");

const exportedResource = new Resource();
exportedResource.resource_name = "PersistentRuntimeResource";
this.resource_slot = exportedResource;
Expand Down
50 changes: 50 additions & 0 deletions example/scripts/tests/runtime_reload_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Resource } from "godot";

export default class RuntimeReloadResource extends Resource {
static constructions: number = 0;
static spawnChildren: boolean = true;
static constructed: RuntimeReloadResource[] = [];
static pendingDetach: Resource[] = [];

@Export()
label: number = 0;

@Export()
child: RuntimeReloadResource | null = null;

constructor(createChild: boolean = true) {
super();
RuntimeReloadResource.constructions++;
RuntimeReloadResource.constructed.push(this);

const pending = RuntimeReloadResource.pendingDetach;
RuntimeReloadResource.pendingDetach = [];
for (const resource of pending) {
if (resource.get_instance_id() !== this.get_instance_id()) {
resource.set_script(null);
}
}

if (createChild !== false && RuntimeReloadResource.spawnChildren) {
this.child = new RuntimeReloadResource(false);
}
}

static constructionCount(): number {
return RuntimeReloadResource.constructions;
}

static takeConstructed(): RuntimeReloadResource[] {
const resources = RuntimeReloadResource.constructed;
RuntimeReloadResource.constructed = [];
return resources;
}

static disableChildren(): void {
RuntimeReloadResource.spawnChildren = false;
}

static detachOnNextConstruction(resources: Resource[]): void {
RuntimeReloadResource.pendingDetach = resources;
}
}
7 changes: 7 additions & 0 deletions example/scripts/tests/runtime_resource_lifetime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Resource } from "godot";
import RuntimeConstructedResource from "./runtime_constructed_resource.js";

export default class RuntimeResourceLifetime extends Resource {
@Export()
child: RuntimeConstructedResource = new RuntimeConstructedResource("field child");
}
13 changes: 13 additions & 0 deletions example/scripts/tests/runtime_static_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import RuntimeAssignmentResource from "./runtime_assignment_resource.js";

export default class RuntimeStaticResource extends RuntimeAssignmentResource {
static readonly singleton = new RuntimeStaticResource();
static from_block: RuntimeStaticResource;

static {
this.from_block = new this();
}

@Export()
static_label: string = "static initialization";
}
Loading
Loading