补齐 Unity 与 Godot 常用操作指导

新增两种编辑器的内置 Skill 和场景、资源、UI、保存撤销示例
接入 DirectProject 按需读取与 Runtime 同源操作参考
补齐指南原文实机测试、安装投影及工具说明完整性检查
记录 Godot 图形补验结果并保留尚未验收的边界
This commit is contained in:
kdletters
2026-09-20 17:26:34 +08:00
parent 54d0fb75ea
commit b96473836d
18 changed files with 1266 additions and 13 deletions
@@ -0,0 +1,380 @@
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',
);
}
},
);
@@ -0,0 +1,142 @@
//! 从随包操作指南提取代码,在显式授权的独立 Unity fixture 中执行。
//! fixture 根目录必须包含 `.agc-guide-fixture`;设置 AGC_UNITY_SMOKE_HELPER、
//! AGC_UNITY_SMOKE_PROJECT、AGC_UNITY_SMOKE_PID,再运行本 ignored 测试。
#![cfg(all(windows, target_arch = "x86_64"))]
use editor_adapter_api::EditorAdapter;
use serde_json::{json, Value};
use std::path::PathBuf;
use unity_editor_bridge::{disconnect_unity_editor, UnityEditorAdapter};
const GUIDE: &str = include_str!(concat!(
"../../../../../apps/ai-game-creator-shell/src-tauri/resources/agc-skills/",
"agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md"
));
fn example(name: &str) -> String {
let guide = GUIDE.replace("\r\n", "\n");
let marker = format!("<!-- example:{name} -->\n```csharp\n");
let (_, after) = guide.split_once(&marker).expect("指南缺少示例");
after
.split_once("\n```")
.expect("示例代码未闭合")
.0
.to_string()
}
fn required(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("显式设置 {name} 后才能运行实机示例"))
}
struct Disconnect;
impl Drop for Disconnect {
fn drop(&mut self) {
disconnect_unity_editor();
}
}
#[test]
#[ignore = "需要指定独立临时 Unity fixture;修改演示场景、资源并验证 Undo 和保存"]
fn execute_documented_unity_examples_in_owned_fixture() {
let project = required("AGC_UNITY_SMOKE_PROJECT");
let project_path = PathBuf::from(&project);
assert!(project_path.join(".agc-guide-fixture").is_file());
let pid = required("AGC_UNITY_SMOKE_PID").parse::<u32>().unwrap();
let adapter = UnityEditorAdapter::new(vec![PathBuf::from(required("AGC_UNITY_SMOKE_HELPER"))]);
let _disconnect = Disconnect;
let connected = adapter
.rpc("connect", json!({"projectPath":project,"processId":pid}))
.unwrap();
assert_eq!(connected["connected"], true, "{connected}");
let execute = |label: &str, code: &str| -> Value {
let reply = adapter
.rpc(
"execute",
json!({"projectPath":project,"processId":pid,"code":code}),
)
.unwrap();
println!("{}", json!({"example":label,"reply":reply}));
assert_eq!(reply["status"], "completed", "{label}: {reply}");
reply["result"].clone()
};
let run = |name: &str| execute(name, &example(name));
execute("reset_owned_fixture_scene", "UnityEditor.SceneManagement.EditorSceneManager.NewScene(UnityEditor.SceneManagement.NewSceneSetup.EmptyScene, UnityEditor.SceneManagement.NewSceneMode.Single); return true;");
run("inspect");
assert_eq!(run("create")["collider"], true);
assert_eq!(run("modify")["colliderX"].as_f64(), Some(2.0));
let read = run("inspect");
assert!(read["nodes"]
.as_array()
.unwrap()
.iter()
.any(|node| node["path"] == "AGC_Guide_Object" && node["x"].as_f64() == Some(1.0)));
run("undo");
assert_eq!(
execute(
"read_undo",
"return UnityEditor.Selection.activeGameObject.transform.localPosition.x;"
)
.as_f64(),
Some(0.0)
);
assert_eq!(run("remove_component")["removed"], true);
assert_eq!(run("undo")["collider"], true);
assert_eq!(run("save")["dirty"], false);
assert_eq!(run("open")["loaded"], true);
assert_eq!(run("inspect")["scene"], "Assets/AGCGuide/Guide.unity");
execute("prepare_prefab_fixture", "var go = new UnityEngine.GameObject(\"GuidePrefab\"); try { var saved = UnityEditor.PrefabUtility.SaveAsPrefabAsset(go, \"Assets/AGCGuide/Guide.prefab\"); return saved != null; } finally { UnityEngine.Object.DestroyImmediate(go); }");
assert!(run("assets")["assets"]
.as_array()
.unwrap()
.iter()
.any(|asset| asset["path"] == "Assets/AGCGuide/Guide.prefab"));
assert_eq!(run("prefab")["source"], "Assets/AGCGuide/Guide.prefab");
run("modify");
assert_eq!(execute("read_prefab_override", "return UnityEditor.PrefabUtility.HasPrefabInstanceAnyOverrides(UnityEditor.Selection.activeGameObject, false);"), true);
let canvas = run("canvas");
assert_eq!(canvas["width"].as_f64(), Some(320.0));
assert_eq!(canvas["height"].as_f64(), Some(180.0));
let ui = execute("read_canvas", "var go = UnityEngine.GameObject.Find(\"AGC_Guide_Canvas/Content\"); var rect = go.GetComponent<UnityEngine.RectTransform>(); return new { width = rect.sizeDelta.x, height = rect.sizeDelta.y, parent = rect.parent.name }; ");
assert_eq!(ui["width"].as_f64(), Some(320.0));
assert_eq!(ui["height"].as_f64(), Some(180.0));
assert_eq!(ui["parent"], "AGC_Guide_Canvas");
run("undo");
assert_eq!(
execute(
"read_canvas_undo",
"return UnityEngine.GameObject.Find(\"AGC_Guide_Canvas\") == null;"
),
true
);
run("canvas");
run("save");
run("open");
let reopened = run("inspect");
assert!(reopened["nodes"]
.as_array()
.unwrap()
.iter()
.any(|node| node["path"] == "AGC_Guide_Canvas/Content"));
let persisted = execute("read_prefab_after_reopen", "foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) { if (UnityEditor.PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(go) == \"Assets/AGCGuide/Guide.prefab\") return new { overrideExists = UnityEditor.PrefabUtility.HasPrefabInstanceAnyOverrides(go, false), x = go.transform.localPosition.x, colliderX = go.GetComponent<UnityEngine.BoxCollider>().size.x }; } throw new System.Exception(\"Prefab instance missing\");");
assert_eq!(persisted["colliderX"].as_f64(), Some(2.0));
assert_eq!(persisted["x"].as_f64(), Some(1.0));
assert_eq!(persisted["overrideExists"], true);
assert_eq!(run("diagnostics")["playing"], false);
assert_eq!(run("play")["requested"], "play");
std::thread::sleep(std::time::Duration::from_secs(3));
let reconnected = adapter
.rpc("connect", json!({"projectPath":project,"processId":pid}))
.unwrap();
assert_eq!(reconnected["connected"], true, "{reconnected}");
assert_eq!(run("diagnostics")["playing"], true);
assert_eq!(run("stop")["requested"], "stop");
std::thread::sleep(std::time::Duration::from_secs(2));
let reconnected = adapter
.rpc("connect", json!({"projectPath":project,"processId":pid}))
.unwrap();
assert_eq!(reconnected["connected"], true, "{reconnected}");
assert_eq!(run("diagnostics")["playing"], false);
println!("Unity 指南 13 个原文示例:查询、创建、修改、组件删除与撤销、资源查找、Prefab override、Canvas 撤销、保存重开、播放/停止及状态诊断通过。");
}