Files
kdletters b96473836d 补齐 Unity 与 Godot 常用操作指导
新增两种编辑器的内置 Skill 和场景、资源、UI、保存撤销示例
接入 DirectProject 按需读取与 Runtime 同源操作参考
补齐指南原文实机测试、安装投影及工具说明完整性检查
记录 Godot 图形补验结果并保留尚未验收的边界
2026-09-20 17:26:34 +08:00

381 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const nativeRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const repoRoot = path.resolve(nativeRoot, '../../../..');
const guidePath = path.join(
repoRoot,
'apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md',
);
const guide = fs.readFileSync(guidePath, 'utf8');
const examples = new Map(
[
...guide.matchAll(
/<!-- example:([a-z-]+) -->\r?\n```gdscript\r?\n([\s\S]*?)\r?\n```/g,
),
].map((match) => [match[1], match[2]]),
);
const executable = process.env.AGC_GODOT_TEST_EXECUTABLE;
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(predicate, duration = 15000) {
const end = Date.now() + duration;
while (Date.now() < end) {
const value = predicate();
if (value) return value;
await pause(25);
}
throw Error('Godot guide fixture did not become ready');
}
function request(session, method, params = {}) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({
host: '127.0.0.1',
port: session.port,
});
let data = '';
socket.setTimeout(10000, () =>
socket.destroy(Error('Guide execution receipt timed out')),
);
socket.once('error', reject);
socket.once('connect', () =>
socket.write(
`${JSON.stringify({
protocol: session.protocol,
id: 1,
generation: session.generation,
token: session.token,
method,
params,
})}\n`,
),
);
socket.on('data', (chunk) => {
data += chunk;
const newline = data.indexOf('\n');
if (newline < 0) return;
try {
const reply = JSON.parse(data.slice(0, newline));
assert.equal(reply.protocol, session.protocol);
assert.equal(reply.generation, session.generation);
assert.equal(reply.pid, session.pid);
assert.equal(reply.buildId, session.buildId);
assert.equal(
path.resolve(reply.projectPath).toLowerCase(),
path.resolve(session.projectPath).toLowerCase(),
);
resolve(reply.result);
} catch (error) {
reject(error);
}
socket.end();
});
socket.once('end', () => {
if (!data.includes('\n')) reject(Error('Godot exited without a receipt'));
});
});
}
test('Godot guide examples are unique, extractable, and within the runtime read budget', () => {
assert.ok(Buffer.byteLength(guide, 'utf8') <= 14 * 1024);
assert.equal([...guide.matchAll(/<!-- example:/g)].length, examples.size);
assert.equal([...guide.matchAll(/```gdscript/g)].length, examples.size);
assert.deepEqual(
[...examples.keys()],
[
'query',
'create-node',
'update-node',
'delete-node',
'local-undo',
'pack-resource',
'instance-resource',
'create-ui',
'save-reopen',
'stop-play',
'diagnostics',
],
);
});
test(
'real headless Godot executes unchanged examples from the shipped guide',
{
skip: !executable,
timeout: 90000,
},
async (t) => {
assert.equal(process.platform, 'win32');
const fixture = path.join(nativeRoot, '.build', `guide-${Date.now()}`);
const project = path.join(fixture, 'project');
const cache = path.join(fixture, 'native-cache');
fs.mkdirSync(project, { recursive: true });
fs.mkdirSync(cache);
const projectText =
'config_version=5\n[application]\nconfig/name="AGC Godot Guide Fixture"\nrun/main_scene="res://main.tscn"\n[rendering]\nrenderer/rendering_method="gl_compatibility"\n';
fs.writeFileSync(path.join(project, 'project.godot'), projectText);
fs.writeFileSync(
path.join(project, 'main.tscn'),
'[gd_scene format=3]\n\n[node name="GuideRoot" type="Node2D"]\n\n[node name="Existing" type="Node2D" parent="."]\n',
);
const dll = path.join(cache, 'agc_godot_editor.dll');
fs.copyFileSync(
path.join(nativeRoot, 'bin/win-x64/agc_godot_editor.dll'),
dll,
);
const metadata = JSON.parse(
fs.readFileSync(
path.join(nativeRoot, 'bin/win-x64/metadata.json'),
'utf8',
),
);
fs.writeFileSync(
path.join(project, 'agc-editor-bridge.gdextension'),
`[configuration]\nentry_symbol="agc_godot_editor_init"\ncompatibility_minimum="4.7"\nreloadable=false\n[libraries]\nwindows.editor.x86_64="${dll.replaceAll('\\', '/')}"\n`,
);
const args = [
'--headless',
'--quiet',
'--editor',
'--path',
project,
'--log-file',
path.join(fixture, 'editor.log'),
'res://main.tscn',
];
const child = spawn(executable, args, {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const ownedPid = child.pid;
const exit = once(child, 'exit');
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.stderr.on('data', (chunk) => {
output += chunk;
});
fs.writeFileSync(
path.join(fixture, 'launch.json'),
JSON.stringify({ executable, args, pid: ownedPid }, null, 2),
);
const receipts = [];
let session;
try {
const sessionPath = path.join(
project,
'.godot/agc',
`editor-bridge-${ownedPid}.json`,
);
session = await until(() => {
if (child.exitCode !== null)
throw Error(`Owned Godot fixture exited: ${output}`);
try {
return JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
} catch {
return false;
}
});
assert.equal(session.pid, ownedPid);
assert.equal(session.buildId, metadata.buildId);
const execute = (code) =>
request(session, 'execute', { code, timeoutMs: 5000 });
const run = async (name) => {
assert.ok(examples.has(name), `Missing example ${name}`);
const receipt = await execute(examples.get(name));
receipts.push({ example: name, receipt });
assert.equal(
receipt.ok,
true,
JSON.stringify({ example: name, receipt }),
);
return receipt.result;
};
const selection = await execute(
'var root := EditorInterface.get_edited_scene_root()\nassert(root != null)\nEditorInterface.get_selection().clear()\nEditorInterface.get_selection().add_node(root.get_node("Existing"))\nreturn true',
);
assert.equal(selection.ok, true, JSON.stringify(selection));
await t.test(
'query returns the edited scene, real hierarchy and selected relative path',
async () => {
const result = await run('query');
assert.equal(result.scene, 'res://main.tscn');
assert.deepEqual(result.selected, ['Existing']);
assert.deepEqual(result.nodes, [
{ path: '.', type: 'Node2D' },
{ path: 'Existing', type: 'Node2D' },
]);
},
);
await t.test(
'create and property changes return actual node values',
async () => {
assert.deepEqual(await run('create-node'), {
path: 'AGCGuideMarker',
position: [12, 24],
owned: true,
});
assert.deepEqual(await run('update-node'), {
path: 'AGCGuideMarker',
position: [24, 48],
});
},
);
await t.test(
'local UndoRedo performs and reverts the property change',
async () => {
assert.deepEqual(await run('local-undo'), {
changed: [80, 90],
restored: [24, 48],
matches: true,
});
},
);
await t.test(
'PackedScene writes owned children and instantiates the resource',
async () => {
assert.deepEqual(await run('pack-resource'), {
path: 'res://agc_guide_piece.tscn',
saved: true,
});
assert.match(
fs.readFileSync(path.join(project, 'agc_guide_piece.tscn'), 'utf8'),
/name="Anchor"/,
);
assert.deepEqual(await run('instance-resource'), {
path: 'AGCGuidePiece',
source: 'res://agc_guide_piece.tscn',
has_anchor: true,
});
},
);
await t.test(
'Control and Container UI is owned by the saved scene',
async () => {
assert.deepEqual(await run('create-ui'), {
path: 'AGCGuideHUD',
title: '关卡目标',
button: '开始',
anchors: [0, 0, 1, 1],
owned: [true, true, true, true, true],
});
},
);
await t.test(
'read-only old scene cannot discard unsaved edits by reloading stale disk content',
async () => {
const sceneFile = path.join(project, 'main.tscn');
const oldDisk = fs.readFileSync(sceneFile, 'utf8');
const identityCode =
'var root := EditorInterface.get_edited_scene_root()\nvar marker := root.get_node("AGCGuideMarker") as Node2D\nreturn {"id": str(root.get_instance_id()), "position": [marker.position.x, marker.position.y], "has_ui": root.has_node("AGCGuideHUD/Center/Column/Title")}';
const before = await execute(identityCode);
assert.equal(before.ok, true);
fs.chmodSync(sceneFile, 0o444);
try {
const receipt = await execute(examples.get('save-reopen'));
receipts.push({ example: 'save-reopen-read-only', receipt });
if (receipt.ok) {
assert.equal(receipt.result.reloaded, false);
} else {
assert.equal(receipt.error.code, 'godot_runtime_error');
}
const after = await execute(identityCode);
receipts.push({
example: 'read-only-memory-verification',
receipt: after,
});
assert.equal(after.ok, true, JSON.stringify(after));
assert.deepEqual(after.result, before.result);
assert.equal(fs.readFileSync(sceneFile, 'utf8'), oldDisk);
} finally {
fs.chmodSync(sceneFile, 0o666);
}
},
);
await t.test(
'save and reload retain the instance and UI hierarchy on disk and in editor',
async () => {
assert.deepEqual(await run('save-reopen'), {
scene: 'res://main.tscn',
saved: true,
reloaded: true,
has_piece: true,
has_ui: true,
});
const disk = fs.readFileSync(path.join(project, 'main.tscn'), 'utf8');
assert.match(disk, /agc_guide_piece\.tscn/);
assert.match(disk, /name="Title"/);
assert.match(disk, /position = Vector2\(24, 48\)/);
},
);
await t.test(
'diagnostics and stop when already stopped have faithful results',
async () => {
const diagnostics = await run('diagnostics');
assert.equal(diagnostics.editor, true);
assert.equal(diagnostics.scene, 'res://main.tscn');
assert.equal(diagnostics.playing, false);
assert.ok(diagnostics.open_scenes.includes('res://main.tscn'));
assert.deepEqual(await run('stop-play'), {
was_playing: false,
playing: false,
});
},
);
await t.test('delete removes only the selected guide node', async () => {
assert.deepEqual(await run('delete-node'), { removed: true });
const after = await run('query');
assert.ok(!after.nodes.some((node) => node.path === 'AGCGuideMarker'));
assert.ok(after.nodes.some((node) => node.path === 'Existing'));
assert.ok(
after.nodes.some(
(node) => node.path === 'AGCGuideHUD/Center/Column/Title',
),
);
});
assert.equal(
fs.readFileSync(path.join(project, 'project.godot'), 'utf8'),
projectText,
);
assert.equal((await request(session, 'shutdown')).accepted, true);
await until(() => !fs.existsSync(sessionPath));
} finally {
// This retained ChildProcess is the editor launched above, never a discovered user process.
assert.equal(child.pid, ownedPid);
if (child.exitCode === null) child.kill();
await Promise.race([exit, pause(5000)]);
fs.writeFileSync(path.join(fixture, 'editor-output.log'), output);
fs.writeFileSync(
path.join(fixture, 'receipts.json'),
JSON.stringify(receipts, null, 2),
);
fs.writeFileSync(
path.join(fixture, 'cleanup.json'),
JSON.stringify(
{
pid: ownedPid,
exited: child.exitCode !== null || child.signalCode !== null,
},
null,
2,
),
);
assert.ok(
child.exitCode !== null || child.signalCode !== null,
'Owned fixture editor must exit',
);
}
},
);