补齐 Unity 与 Godot 常用操作指导
新增两种编辑器的内置 Skill 和场景、资源、UI、保存撤销示例 接入 DirectProject 按需读取与 Runtime 同源操作参考 补齐指南原文实机测试、安装投影及工具说明完整性检查 记录 Godot 图形补验结果并保留尚未验收的边界
This commit is contained in:
@@ -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 撤销、保存重开、播放/停止及状态诊断通过。");
|
||||
}
|
||||
Reference in New Issue
Block a user