补齐 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
@@ -9,7 +9,9 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([
'agc-browser-playtest',
'agc-client-projection',
'agc-game-production-workflow',
'agc-godot-editor',
'agc-project-structure',
'agc-unity-editor',
'agc-web-game-development',
'taonier-art-assets',
]);
@@ -0,0 +1,14 @@
---
name: agc-godot-editor
description: 在 AGC 中通过已连接的 Godot 编辑器读取、修改和保存场景、节点、资源与 UI,运行项目并诊断 GDScript 执行结果。
---
# Godot 编辑器操作
使用当前环境实际提供的 Godot 执行工具:DirectProject 为 `agc_godot_execute`Runtime 使用 `godot.editor.execute` 对应的已发现工具。执行载荷只含 GDScript **函数体** `code`Direct 传 `{code:...}`Runtime 按实际 schema 包装为 `{reason:"...",input:{code:...}}`。项目、编辑器和连接身份由 AGC 管理。
开始操作前读取 [Godot 编辑器常用操作](references/【操作指南】Godot编辑器常用操作-2026-09-20.md),按当前任务选取查询、节点、撤销、资源、UI、保存或运行示例。先查询真实编辑场景与目标节点,再做有限修改并回读结果。
DLL 随 AGC 分发,首次连接需要 Godot 扫描时重新聚焦编辑器即可;无需手动复制 DLL、配置端口或运行引导脚本。不要读取或返回连接凭据。
明确失败也可能已经修改场景;先检查日志和真实状态再修复。超时、断线或 `needs-reconciliation` 表示结果待核对,不自动重放,不通过重连绕过执行阻断。保存、运行和删除范围以用户任务为准;局部 `UndoRedo` 不等于编辑器撤销历史。
@@ -0,0 +1,257 @@
# Godot 编辑器常用操作
面向 AGC 内置 Godot 工具,仅支持 Windows x64 标准编辑器;不推断 .NET 或其他平台支持。缺少执行工具时报告不可用。目录:执行、查询、节点、撤销、资源、UI、保存、运行、诊断。
## 执行合同
- Direct 的 `agc_godot_execute``{code:...}`Runtime 先发现 `godot.editor.execute`,按实际 schema 传 `{reason:"操作原因",input:{code:...}}`。执行载荷只含 `code`,不增加项目路径等字段。以下是函数体,不增加 `extends``@tool``func run()`,保留内部缩进。
- 上下文是临时 `RefCounted.run()``self` 不是场景 Node,不能直接 `get_tree()`。用 `EditorInterface.get_edited_scene_root()` 取得编辑场景根;`EditorInterface.get_base_control().get_tree().root` 是编辑器根,不是用户场景。
- 各次调用不共享局部变量。返回 `null`、布尔、整数、有限浮点、字符串、数组、字符串键字典。Node、Resource、Vector2、Color 等需投影为路径、数值数组或字典;不要直接返回 Godot 对象。用 `return` 返回结果,`print` 只写有界日志。
-`await EditorInterface.get_base_control().get_tree().process_frame` 或短计时器;不要死循环、长阻塞,也不要派发未等待的后台修改。一次只执行一段有界操作。
- DLL 原件由 AGC 安装资源提供,私有缓存按编辑器实例隔离;首次发现扩展时重新聚焦 Godot 即可。不手改 `.gdextension`、DLL、端口、令牌或 `.godot/agc`
## 读取当前场景、选中节点和树
先核对 `scene`、类型和相对路径。无打开场景时返回空结果。遍历最多 256 节点,`truncated` 为 true 时按目标子树继续查。
<!-- example:query -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
if root == null:
return {"scene": null, "nodes": [], "selected": []}
var selected: Array = []
for node in EditorInterface.get_selection().get_selected_nodes():
if node == root or root.is_ancestor_of(node):
selected.append(str(root.get_path_to(node)))
var nodes: Array = []
var pending: Array[Node] = [root]
while not pending.is_empty() and nodes.size() < 256:
var node: Node = pending.pop_back()
nodes.append({"path": str(root.get_path_to(node)), "type": node.get_class()})
for child in node.get_children():
pending.append(child)
return {"scene": root.scene_file_path, "root": str(root.name), "nodes": nodes,
"selected": selected, "truncated": not pending.is_empty()}
```
`get_node_or_null("Player/Sprite2D")` 相对于场景根。选择用 `EditorInterface.get_selection().clear()` / `add_node(node)`;检查器用 `EditorInterface.edit_node(node)`,均不保存场景。
## 创建、改属性、删除节点
`AGCGuideMarker` 替换为任务指定且不冲突的名称。示例直接修改,不自动加入编辑器撤销历史。`add_child` 后设 `owner = root` 才随当前场景保存;新子树逐个设置 owner,不重写实例场景内部 owner。
<!-- example:create-node -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
assert(root != null and root.get_node_or_null("AGCGuideMarker") == null)
var marker := Node2D.new()
marker.name = "AGCGuideMarker"
root.add_child(marker)
marker.owner = root
marker.position = Vector2(12, 24)
EditorInterface.mark_scene_as_unsaved()
return {"path": str(root.get_path_to(marker)), "position": [marker.position.x, marker.position.y],
"owned": marker.owner == root}
```
<!-- example:update-node -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
assert(root != null)
var marker := root.get_node_or_null("AGCGuideMarker") as Node2D
assert(marker != null)
marker.position = Vector2(24, 48)
EditorInterface.mark_scene_as_unsaved()
return {"path": str(root.get_path_to(marker)), "position": [marker.position.x, marker.position.y]}
```
删除前核对目标及后代;`queue_free()` 连同后代删除,下一帧完成后对象失效。不要删除场景根。
<!-- example:delete-node -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
assert(root != null)
var marker := root.get_node_or_null("AGCGuideMarker")
assert(marker != null and marker != root)
root.remove_child(marker)
marker.queue_free()
EditorInterface.mark_scene_as_unsaved()
await EditorInterface.get_base_control().get_tree().process_frame
return {"removed": root.get_node_or_null("AGCGuideMarker") == null}
```
其它属性如 `Sprite2D.texture``Node3D.position``Label.text`,先确认实际类型。向量和颜色返回数值数组。
## 撤销:局部事务与编辑器历史
局部 `UndoRedo.new()` 不进入 Ctrl+Z 菜单,调用结束即失去历史。下例同一次调用改位置为 `(80, 90)`,随后撤销并回读。
<!-- example:local-undo -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
assert(root != null)
var marker := root.get_node_or_null("AGCGuideMarker") as Node2D
assert(marker != null)
var previous := marker.position
var undo := UndoRedo.new()
undo.create_action("验证位置撤销")
undo.add_do_property(marker, "position", Vector2(80, 90))
undo.add_undo_property(marker, "position", previous)
undo.commit_action()
var changed := marker.position
assert(undo.undo())
return {"changed": [changed.x, changed.y], "restored": [marker.position.x, marker.position.y],
"matches": marker.position == previous}
```
Ctrl+Z 需复用已有 `EditorPlugin.get_undo_redo()``EditorUndoRedoManager``create_action(..., UndoRedo.MERGE_DISABLE, root)` 指定场景历史。局部 UndoRedo 方法操作用 Callablemanager 用对象、方法名、参数。不要为取得 manager 擅自安装 addon。
创建历史需登记 `add_child``owner`、逆向 `remove_child``add_do_reference`;删除记录父节点、顺序、owner,用 `add_undo_reference` 保活,禁止 `free/queue_free` 后再承诺恢复。属性成对登记新旧值。无持久 EditorPlugin 时只能承诺直接修改,不能承诺 Ctrl+Z。
保存重开后旧 Node 引用和局部历史不能复用。需重新查询,确认无后续用户改动,再执行逆操作并重新保存;内存 undo 不会恢复磁盘文件。
## PackedScene 与资源
`res://agc_guide_piece.tscn` 改为任务指定新路径,确认不存在并检查 `pack``ResourceSaver.save` 返回值。`owner` 决定子节点能否打包;不照例覆盖已有资源。
<!-- example:pack-resource -->
```gdscript
var target := "res://agc_guide_piece.tscn"
assert(not FileAccess.file_exists(target))
var source := Node2D.new()
source.name = "GuidePiece"
var child := Marker2D.new()
child.name = "Anchor"
source.add_child(child)
child.owner = source
var packed := PackedScene.new()
var packed_error := packed.pack(source)
source.free()
assert(packed_error == OK)
var save_error := ResourceSaver.save(packed, target)
assert(save_error == OK)
EditorInterface.get_resource_filesystem().scan()
return {"path": target, "saved": FileAccess.file_exists(target)}
```
实例化时检查 PackedScene 类型,只把实例根归属于当前根,保留内部所有权。实例局部覆盖不会改写源 `.tscn`
<!-- example:instance-resource -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
assert(root != null and root.get_node_or_null("AGCGuidePiece") == null)
var packed := ResourceLoader.load("res://agc_guide_piece.tscn", "PackedScene", ResourceLoader.CACHE_MODE_IGNORE) as PackedScene
assert(packed != null)
var instance := packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
instance.name = "AGCGuidePiece"
root.add_child(instance)
instance.owner = root
EditorInterface.mark_scene_as_unsaved()
return {"path": str(root.get_path_to(instance)), "source": instance.scene_file_path,
"has_anchor": instance.get_node_or_null("Anchor") != null}
```
ResourceLoader 默认缓存可能返回旧对象;外部刚写文件可用 `CACHE_MODE_IGNORE`。共享 Resource 的修改影响所有引用;局部变化先 `duplicate()` 再赋回。图片/音频须等扫描和导入完成,文件存在不代表已导入。
## 基础 Control / Container UI
Container 管理直属子 Control 布局,使用 `custom_minimum_size`、size flags、theme 常量,避免手写子控件 position/size。新节点逐个设置 owner。
<!-- example:create-ui -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
assert(root != null and root.get_node_or_null("AGCGuideHUD") == null)
var layer := CanvasLayer.new()
layer.name = "AGCGuideHUD"
root.add_child(layer)
layer.owner = root
var center := CenterContainer.new()
center.name = "Center"
layer.add_child(center)
center.owner = root
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var column := VBoxContainer.new()
column.name = "Column"
center.add_child(column)
column.owner = root
column.custom_minimum_size = Vector2(240, 96)
column.add_theme_constant_override("separation", 8)
var label := Label.new()
label.name = "Title"
label.text = "关卡目标"
column.add_child(label)
label.owner = root
var button := Button.new()
button.name = "Start"
button.text = "开始"
button.custom_minimum_size = Vector2(200, 40)
column.add_child(button)
button.owner = root
EditorInterface.mark_scene_as_unsaved()
return {"path": str(root.get_path_to(layer)), "title": label.text, "button": button.text,
"anchors": [center.anchor_left, center.anchor_top, center.anchor_right, center.anchor_bottom],
"owned": [layer.owner == root, center.owner == root, column.owner == root, label.owner == root, button.owner == root]}
```
持久信号应连接游戏脚本的方法,不把临时执行器 Callable 当运行时回调。此例只建布局;尺寸、层级、输入仍需实际试玩验收。
## 保存、重新打开与新场景
`mark_scene_as_unsaved()` 不写盘。仅在获准保存全部当前改动时执行。`save_scene_as(path,false)` 跳过缩略图但返回 void;旧文件可加载不代表本次保存成功。下例依赖前文三个分支,先核验磁盘节点和位置再重开;实际任务须覆盖所有待保存变更,无法证明时只保存、不 reload。
<!-- example:save-reopen -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
assert(root != null and not root.scene_file_path.is_empty())
var scene_path := root.scene_file_path
var expected: Vector2 = root.get_node("AGCGuideMarker").position
EditorInterface.save_scene_as(scene_path, false)
var saved := ResourceLoader.load(scene_path, "PackedScene", ResourceLoader.CACHE_MODE_IGNORE) as PackedScene
assert(saved != null)
var probe := saved.instantiate()
var marker := probe.get_node_or_null("AGCGuideMarker") as Node2D
var matches := marker != null and marker.position == expected and probe.has_node("AGCGuidePiece/Anchor") and probe.has_node("AGCGuideHUD/Center/Column/Title")
probe.free()
if not matches:
return {"reloaded": false, "reason": "磁盘内容未验证,保留当前编辑场景"}
EditorInterface.reload_scene_from_path(scene_path)
await EditorInterface.get_base_control().get_tree().process_frame
var reopened := EditorInterface.get_edited_scene_root()
assert(reopened != null and reopened.scene_file_path == scene_path)
return {"scene": reopened.scene_file_path, "saved": true, "reloaded": true,
"has_piece": reopened.get_node_or_null("AGCGuidePiece/Anchor") != null,
"has_ui": reopened.get_node_or_null("AGCGuideHUD/Center/Column/Title") != null}
```
打开场景用 `open_scene_from_path("res://...")`,等一帧重新取根核对路径;`get_open_scenes()` 查已打开路径,均属 EditorInterface。未命名场景用 `save_scene_as(path)`;常规 GUI 用 `save_scene()` 检查 `OK`,headless 缩略图可能报错。不要覆盖未知未保存工作。
## 运行与停止
EditorInterface 的 `play_current_scene()` 运行当前场景,`play_main_scene()` 运行主场景,`play_custom_scene("res://...")` 运行指定场景。仅需试玩时调用,先核对路径、主场景与未保存改动。`is_playing_scene()` / `get_playing_scene()` 只报告启动状态,不证明玩法正确;编辑根不是游戏 Remote SceneTree。
<!-- example:stop-play -->
```gdscript
var was_playing := EditorInterface.is_playing_scene()
if was_playing:
EditorInterface.stop_playing_scene()
await EditorInterface.get_base_control().get_tree().process_frame
return {"was_playing": was_playing, "playing": EditorInterface.is_playing_scene()}
```
## 错误诊断与回执
- 读取执行回执的 `ok/status/result/error/logs`。编译错误先检查函数体包装、类型推断和真实 API;确定运行失败也可能已经执行前半段修改,先读回节点/资源,再修复剩余步骤。
- `godot_result_not_serializable` 可能只是返回了对象,不能据此认定修改未发生;改用只读查询返回路径和标量。`assert` 失败不会替你回滚此前副作用。
- 超时、断线、`needs-reconciliation` 或发送后的身份不明不能自动重放;先核对编辑器真实状态,按 AGC 现有恢复流程处理阻断。重新连接、启停插件或重启 Runner 都不是“确认没有执行”。
- 捕获日志只覆盖这次编辑器执行且有长度上限;成功启动游戏不等于运行时无错误。结合 Godot Output/Debugger、游戏日志与实际试玩核验,不将空日志当作无故障。
<!-- example:diagnostics -->
```gdscript
var root := EditorInterface.get_edited_scene_root()
return {"version": Engine.get_version_info().string,
"editor": Engine.is_editor_hint(), "scene": root.scene_file_path if root != null else null,
"open_scenes": Array(EditorInterface.get_open_scenes()), "playing": EditorInterface.is_playing_scene(),
"playing_scene": EditorInterface.get_playing_scene()}
```
示例已在 Godot 4.7.2 标准版 headless 验证;停止仅验证已停止状态。GUI 缩略图保存、Ctrl+Z 历史、运行中停止及 UI 视觉效果未在此指南测试中验收。
@@ -0,0 +1,10 @@
---
name: agc-unity-editor
description: 通过 AGC 的 Unity 编辑器执行工具读取和修改当前项目的场景、对象、组件、Prefab、Canvas 与资源,并保存、撤销和检查播放状态。
---
# Unity 编辑器操作
使用当前会话提供的 Unity 执行工具,提交 C# 方法正文。开始操作前读取[常用操作指南](references/【操作指南】Unity编辑器常用操作-2026-09-20.md),按任务选择其中的示例。指南包含调用格式、目标定位、返回值投影和可执行代码。
先查询目标与编辑状态,修改后回读;写操作显式登记 Undo,保存操作检查返回值。执行失败可能留下部分修改,结果未知时不得重放。插件不会自动把任意代码变成可撤销事务。
@@ -0,0 +1,225 @@
# Unity 编辑器常用操作
## 调用与结果
当前接入支持 Windows x64 的 Mono 编辑器。工具缺失时报告不可用,不推断 .NET/CoreCLR 或其他平台已支持。
连接当前项目的 Unity 后提交仅含 `code` 的执行载荷。DirectProject 工具 `agc_unity_execute``{"code":"return 42;"}`Runtime 的 `unity.editor.execute` 按实际 schema 传 `{"reason":"读取编辑器状态","input":{"code":"return 42;"}}`。以会话工具清单为准。
`code` 是主线程执行的方法正文,直接 `return`,不加 `using`、类或 `Main`。使用完整 API 名称。Unity 对象先投影为普通数据;返回集合最多保留 32 项,嵌套深度达到 4 会转字符串,采用浅层投影、每批 30 项及显式截断标记。跨调用保留路径/GUID,实例 ID 仅当前 Editor 生命周期内有效。
先确认场景、选择、编辑模式和待修改资源。遍历 `GetRootGameObjects()``GetComponentsInChildren(..., true)` 可包含未激活对象;`GameObject.Find` 会漏掉它们。结合场景路径、层级路径和实例 ID 回读目标,重名时不要任取首个。
`completed` 只证明代码返回,仍要回读。`failed` 可能已部分修改,检查 `dispatched` 与现场后修复;编译失败且 `dispatched=false` 表示未执行。`needs-reconciliation`、超时或断线后结果未知时不重放,保留执行 ID 并核对现场,重连不等于允许重试。工具不自动撤销/回滚,不能中断死循环;保持调用短小,不在主线程等待编译/播放切换。
## 当前场景、选择和层级
返回当前场景及最多 30 个节点。其他场景用 `SceneManager.sceneCount/GetSceneAt` 枚举。
<!-- example:inspect -->
```csharp
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
var rows = new System.Collections.Generic.List<object>();
var queue = new System.Collections.Generic.Queue<UnityEngine.Transform>();
foreach (var root in scene.GetRootGameObjects()) queue.Enqueue(root.transform);
while (queue.Count > 0 && rows.Count < 30) {
var t = queue.Dequeue();
var path = t.name;
for (var p = t.parent; p != null; p = p.parent) path = p.name + "/" + path;
rows.Add(new { id = t.gameObject.GetInstanceID(), path, active = t.gameObject.activeSelf,
x = t.localPosition.x, y = t.localPosition.y, z = t.localPosition.z });
for (int i = 0; i < t.childCount; i++) queue.Enqueue(t.GetChild(i));
}
var selected = UnityEditor.Selection.activeGameObject;
return new { scene = scene.path, dirty = scene.isDirty, nodes = rows.ToArray(), truncated = queue.Count > 0,
selectedId = selected == null ? 0 : selected.GetInstanceID(),
playing = UnityEditor.EditorApplication.isPlaying, compiling = UnityEditor.EditorApplication.isCompiling };
```
## 创建、修改、删除与 Undo
示例对象 `AGC_Guide_Object` 应替换成任务目标。编辑先退出播放模式。属性写入前 `Undo.RecordObject`;创建用 `RegisterCreatedObjectUndo`,加组件用 `Undo.AddComponent`,删除用 `Undo.DestroyObjectImmediate`,改父级用 `Undo.SetTransformParent`。磁盘写入、外部副作用及未登记修改不会自动撤销。
创建对象和组件并选中它;检查重复名是防误建措施,不是结果未知后重试的许可。
<!-- example:create -->
```csharp
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放");
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
foreach (var root in scene.GetRootGameObjects())
if (root.name == "AGC_Guide_Object") throw new System.Exception("目标已存在,请先核对");
UnityEditor.Undo.IncrementCurrentGroup();
int group = UnityEditor.Undo.GetCurrentGroup();
UnityEditor.Undo.SetCurrentGroupName("AGC 创建对象");
var go = new UnityEngine.GameObject("AGC_Guide_Object");
UnityEditor.Undo.RegisterCreatedObjectUndo(go, "AGC 创建对象");
UnityEditor.Undo.AddComponent<UnityEngine.BoxCollider>(go);
UnityEditor.Selection.activeGameObject = go;
UnityEditor.Undo.CollapseUndoOperations(group);
return new { id = go.GetInstanceID(), name = go.name, collider = go.GetComponent<UnityEngine.BoxCollider>() != null };
```
确认选择是目标后修改。Prefab 实例属性写入后记录 override。改 Prefab 资产用 `LoadPrefabContents/SaveAsPrefabAsset/UnloadPrefabContents` 并在 `finally` 释放,不能当场景对象保存。
<!-- example:modify -->
```csharp
var go = UnityEditor.Selection.activeGameObject;
if (go == null || !go.scene.IsValid() || UnityEditor.EditorUtility.IsPersistent(go)) throw new System.Exception("请选中场景对象");
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放");
UnityEditor.Undo.IncrementCurrentGroup();
int group = UnityEditor.Undo.GetCurrentGroup();
UnityEditor.Undo.SetCurrentGroupName("AGC 修改对象");
UnityEditor.Undo.RecordObject(go.transform, "AGC 移动对象");
go.transform.localPosition = new UnityEngine.Vector3(1, 2, 3);
var collider = go.GetComponent<UnityEngine.BoxCollider>();
if (collider == null) collider = UnityEditor.Undo.AddComponent<UnityEngine.BoxCollider>(go);
UnityEditor.Undo.RecordObject(collider, "AGC 修改碰撞体");
collider.size = new UnityEngine.Vector3(2, 3, 4);
if (UnityEditor.PrefabUtility.IsPartOfPrefabInstance(go)) {
UnityEditor.PrefabUtility.RecordPrefabInstancePropertyModifications(go.transform);
UnityEditor.PrefabUtility.RecordPrefabInstancePropertyModifications(collider);
}
UnityEditor.Undo.FlushUndoRecordObjects();
UnityEditor.Undo.CollapseUndoOperations(group);
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene);
return new { id = go.GetInstanceID(), x = go.transform.localPosition.x, colliderX = collider.size.x };
```
删除选择对象上的碰撞体;删除整个已核对对象时把 `collider` 替换为 `go`,并提前回读待删除子树。
<!-- example:remove_component -->
```csharp
var go = UnityEditor.Selection.activeGameObject;
if (go == null || !go.scene.IsValid() || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("需要编辑模式中的场景对象");
var collider = go.GetComponent<UnityEngine.BoxCollider>();
if (collider == null) throw new System.Exception("没有 BoxCollider");
UnityEditor.Undo.IncrementCurrentGroup();
UnityEditor.Undo.SetCurrentGroupName("AGC 删除碰撞体");
UnityEditor.Undo.DestroyObjectImmediate(collider);
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene);
return new { removed = go.GetComponent<UnityEngine.BoxCollider>() == null };
```
只在确认最后一条 Undo 就是本次操作时执行撤销,避免撤销用户插入的编辑。撤销后重新运行查询检查对象/属性。
<!-- example:undo -->
```csharp
UnityEditor.Undo.PerformUndo();
var go = UnityEditor.Selection.activeGameObject;
return new { selectedId = go == null ? 0 : go.GetInstanceID(), collider = go != null && go.GetComponent<UnityEngine.BoxCollider>() != null };
```
## 资源查找与 Prefab 实例化
按类型和目录查询,拿到 GUID/路径后加载。下例返回前 30 个 Prefab;过滤器可换成 `t:Material``t:Texture2D` 等。
<!-- example:assets -->
```csharp
var ids = UnityEditor.AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" });
var rows = new System.Collections.Generic.List<object>();
for (int i = 0; i < ids.Length && i < 30; i++)
rows.Add(new { guid = ids[i], path = UnityEditor.AssetDatabase.GUIDToAssetPath(ids[i]) });
return new { assets = rows.ToArray(), total = ids.Length, truncated = ids.Length > 30 };
```
路径替换为已查到的 Prefab`InstantiatePrefab` 保持 Prefab 联系,后续修改登记 override。
<!-- example:prefab -->
```csharp
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放");
var path = "Assets/AGCGuide/Guide.prefab";
var asset = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>(path);
if (asset == null || UnityEditor.PrefabUtility.GetPrefabAssetType(asset) == UnityEditor.PrefabAssetType.NotAPrefab) throw new System.Exception("未找到 Prefab");
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
UnityEditor.Undo.IncrementCurrentGroup();
var instance = (UnityEngine.GameObject)UnityEditor.PrefabUtility.InstantiatePrefab(asset, scene);
UnityEditor.Undo.RegisterCreatedObjectUndo(instance, "AGC 实例化 Prefab");
UnityEditor.Selection.activeGameObject = instance;
return new { id = instance.GetInstanceID(), source = UnityEditor.PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(instance) };
```
## 基础 Canvas 与布局
先查询并复用现有 UI。下例创建 Canvas 与居中布局容器,不依赖 uGUI/TMP,容器无可见图形。添加 `Image``Button`、文本或 `EventSystem` 前确认项目 UI 体系和包,避免重复事件系统。
<!-- example:canvas -->
```csharp
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放");
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
foreach (var root in scene.GetRootGameObjects())
if (root.name == "AGC_Guide_Canvas") throw new System.Exception("示例 Canvas 已存在");
UnityEditor.Undo.IncrementCurrentGroup();
int group = UnityEditor.Undo.GetCurrentGroup();
var canvasObject = new UnityEngine.GameObject("AGC_Guide_Canvas", typeof(UnityEngine.RectTransform), typeof(UnityEngine.Canvas));
UnityEditor.Undo.RegisterCreatedObjectUndo(canvasObject, "AGC 创建 Canvas");
canvasObject.GetComponent<UnityEngine.Canvas>().renderMode = UnityEngine.RenderMode.ScreenSpaceOverlay;
var panel = new UnityEngine.GameObject("Content", typeof(UnityEngine.RectTransform));
UnityEditor.Undo.RegisterCreatedObjectUndo(panel, "AGC 创建布局");
UnityEditor.Undo.SetTransformParent(panel.transform, canvasObject.transform, "AGC 设置 UI 父级");
var rect = (UnityEngine.RectTransform)panel.transform;
rect.anchorMin = rect.anchorMax = rect.pivot = new UnityEngine.Vector2(0.5f, 0.5f);
rect.anchoredPosition = UnityEngine.Vector2.zero;
rect.sizeDelta = new UnityEngine.Vector2(320, 180);
UnityEditor.Undo.CollapseUndoOperations(group);
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(scene);
return new { canvasId = canvasObject.GetInstanceID(), panelId = panel.GetInstanceID(), width = rect.sizeDelta.x, height = rect.sizeDelta.y };
```
## 保存与打开场景
确认目标路径及对象所属场景,多场景时用 `go.scene` 而非默认 active scene;已有场景通常沿用 `scene.path``MarkSceneDirty` 不是保存;独立资源用 `SetDirty``AssetDatabase.SaveAssetIfDirty` 保存。磁盘保存不由 Undo 回滚。
<!-- example:save -->
```csharp
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放");
var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
var path = "Assets/AGCGuide/Guide.unity";
if (!UnityEditor.AssetDatabase.IsValidFolder("Assets/AGCGuide")) UnityEditor.AssetDatabase.CreateFolder("Assets", "AGCGuide");
if (!UnityEditor.SceneManagement.EditorSceneManager.SaveScene(scene, path)) throw new System.Exception("场景保存失败");
return new { path = scene.path, dirty = scene.isDirty };
```
Single 会关闭当前场景;存在未保存修改时先停下处理,不默默丢弃。要保留场景则用 `OpenSceneMode.Additive`,并明确后续目标场景。
<!-- example:open -->
```csharp
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放");
for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++)
if (UnityEngine.SceneManagement.SceneManager.GetSceneAt(i).isDirty) throw new System.Exception("存在未保存场景,请先处理");
var path = "Assets/AGCGuide/Guide.unity";
if (UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path) == null) throw new System.Exception("场景文件不存在");
var scene = UnityEditor.SceneManagement.EditorSceneManager.OpenScene(path, UnityEditor.SceneManagement.OpenSceneMode.Single);
return new { path = scene.path, loaded = scene.isLoaded, roots = scene.rootCount };
```
## 播放、停止与编译诊断
播放/停止在下一次 Editor update 调度,`requested` 不代表已切换,稍后查询。播放和修改脚本可能触发编译/Domain Reload 使连接失效,稳定后重连核对,不重发操作。退出播放通常不保留运行期改动。
<!-- example:play -->
```csharp
if (UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating) throw new System.Exception("编辑器正在编译或导入");
UnityEditor.EditorApplication.delayCall += () => { UnityEditor.EditorApplication.isPlaying = true; };
return new { requested = "play" };
```
<!-- example:stop -->
```csharp
UnityEditor.EditorApplication.delayCall += () => { UnityEditor.EditorApplication.isPlaying = false; };
return new { requested = "stop" };
```
状态查询不能证明编译成功。代码编译错误由工具回执返回;项目编译详情查看 Console/Editor 日志,回执不含全量 Console。不要依赖未公开的 `LogEntries` API。
<!-- example:diagnostics -->
```csharp
return new { compiling = UnityEditor.EditorApplication.isCompiling,
importing = UnityEditor.EditorApplication.isUpdating,
playing = UnityEditor.EditorApplication.isPlaying,
changingPlayMode = UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode,
version = UnityEngine.Application.unityVersion };
```
## 验证范围
以上 13 个代码块已从本文提取,在 Windows x64 Unity 6000.3.7f1 Mono 的独立无包依赖项目中经 AGC Attach 实测,包含修改回读、Undo、Prefab override 保存重开及播放/停止。采用 batchmode/nographics;未验收 UI 视觉、第三方包或其他 Unity 版本。
@@ -1,7 +1,39 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.25",
"version": "2026-08-26.27",
"skills": [
{
"name": "agc-unity-editor",
"purpose": "通过 AGC 内置 Unity 插件查询和修改场景、对象、资源与 UI,正确处理撤销、保存和回执",
"triggers": [
"操作已打开的 Unity 工程",
"编写 Unity 编辑器执行代码"
],
"requiredTools": [
"agc_tools.agc_unity_execute"
],
"files": [
"SKILL.md",
"references/【操作指南】Unity编辑器常用操作-2026-09-20.md"
],
"sha256": "9599fa1884db9c4f3eeab20d18871d4dafc845f5ecfe0f9ac9ba7417e65062fc"
},
{
"name": "agc-godot-editor",
"purpose": "通过 AGC 内置 Godot 插件查询和修改场景、节点、资源与 UI,正确处理 owner、撤销和回执",
"triggers": [
"操作已打开的 Godot 工程",
"编写 Godot 编辑器执行代码"
],
"requiredTools": [
"agc_tools.agc_godot_execute"
],
"files": [
"SKILL.md",
"references/【操作指南】Godot编辑器常用操作-2026-09-20.md"
],
"sha256": "b5d76c8685c49e0cd1b0a243a2f46f00daa1a7c8a37c5137b6f31917df9a0af0"
},
{
"name": "agc-game-production-workflow",
"purpose": "把完整游戏从策划案按阶段推进到真实素材接入、构建、试玩和交付",
@@ -6092,7 +6092,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es
printf '%s\n' '{"id":2,"result":{}}'
IFS= read -r skills_list
case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac
printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}'
printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}'
while IFS= read -r line; do :; done
"#,
)
@@ -6833,7 +6833,7 @@ while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;;
*'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
*'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;;
*'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;;
*'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;;
*'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
*'"method":"turn/start"'*)
File diff suppressed because one or more lines are too long
@@ -3009,6 +3009,22 @@ mod tests {
assert_eq!(denied_windows_absolute["isError"], true);
}
#[test]
fn editor_guides_are_available_through_the_existing_skill_resource_tool() {
for (skill, engine) in [("agc-unity-editor", "Unity"), ("agc-godot-editor", "Godot")] {
let relative = format!("references/【操作指南】{engine}编辑器常用操作-2026-09-20.md");
let expected = read_agc_skill_resource(&format!("{skill}/{relative}")).unwrap();
let response =
call_agc_read_skill_resource(&json!({"skillName":skill,"relativePath":relative}));
assert_eq!(response["isError"], false);
assert_eq!(response["content"][0]["text"], expected);
let denied = call_agc_read_skill_resource(
&json!({"skillName":skill,"relativePath":"references/not-in-manifest.md"}),
);
assert_eq!(denied["isError"], true);
}
}
#[test]
fn external_codex_response_redacts_sensitive_lines_and_keeps_safe_text() {
let response = redact_external_mcp_response(
@@ -6,16 +6,34 @@ use std::path::{Component, Path};
const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json");
const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1";
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 6] = [
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 8] = [
"agc-browser-playtest",
"agc-client-projection",
"agc-game-production-workflow",
"agc-godot-editor",
"agc-project-structure",
"agc-unity-editor",
"agc-web-game-development",
"taonier-art-assets",
];
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 18] = [
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 22] = [
(
"agc-unity-editor/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-unity-editor/SKILL.md"),
),
(
"agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md",
include_bytes!("../../resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md"),
),
(
"agc-godot-editor/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-godot-editor/SKILL.md"),
),
(
"agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md",
include_bytes!("../../resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md"),
),
(
"agc-browser-playtest/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"),
@@ -306,10 +324,10 @@ mod tests {
use super::*;
#[test]
fn bundled_skill_pack_is_exactly_the_six_reviewed_skills() {
fn bundled_skill_pack_matches_the_reviewed_allowlist() {
let manifest = validated_skill_pack_manifest().expect("validated manifest");
assert_eq!(manifest.schema_version, "agc-skill-pack.v1");
assert_eq!(manifest.skills.len(), 6);
assert_eq!(manifest.skills.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64));
let serialized = serde_json::to_string(
&manifest
@@ -391,4 +409,29 @@ mod tests {
assert!(!is_safe_skill_relative_path(r"\\server\share\SKILL.md"));
assert!(!is_safe_skill_relative_path(r"references\contract.md"));
}
#[test]
fn editor_guides_are_complete_in_installed_and_readable_skill_resources() {
let home = tempfile::tempdir().unwrap();
install_agc_skill_pack(home.path()).unwrap();
for (skill, engine) in [("agc-unity-editor", "Unity"), ("agc-godot-editor", "Godot")] {
let resource =
format!("{skill}/references/【操作指南】{engine}编辑器常用操作-2026-09-20.md");
let guide = read_agc_skill_resource(&resource).unwrap();
assert!(!guide.is_empty());
assert!(
guide.len() <= 14 * 1024,
"{engine} reference exceeds UTF-8 budget"
);
let installed =
std::fs::read_to_string(home.path().join(".agents/skills").join(&resource))
.unwrap();
assert_eq!(installed, guide);
let entry = read_agc_skill_resource(&format!("{skill}/SKILL.md")).unwrap();
assert!(entry.contains(resource.split_once('/').unwrap().1));
assert!(
read_agc_skill_resource(&format!("{skill}/references/../../auth.json")).is_err()
);
}
}
}
@@ -294,10 +294,22 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent(
if !names.insert(name.clone()) {
return Err(format!("Runtime 原生函数名重复:{name}"));
}
let description = if let Some(reference) = editor_operation_reference(definition.id()) {
let reference = reference.replace("\r\n", "\n");
if reference.len() > 14 * 1024 {
return Err(format!(
"Runtime 编辑器操作参考超过随包预算:{}",
definition.id()
));
}
reference
} else {
definition.description().to_owned()
};
functions.push(
LlmFunctionTool::new(
name,
definition.description(),
description,
action_function_parameters(definition.input_schema().clone()),
)
.with_strict(true),
@@ -1004,6 +1016,14 @@ fn string_array_schema(max_items: usize) -> Value {
})
}
fn editor_operation_reference(tool: &str) -> Option<&'static str> {
match tool {
"unity.editor.execute" => Some(include_str!("../resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md")),
"godot.editor.execute" => Some(include_str!("../resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md")),
_ => None,
}
}
fn runtime_tool_description(tool: &str) -> &'static str {
match tool {
"user.input_request" => "向用户提出一至三个结构化问题,并暂停当前 run 等待回答。",
@@ -1056,8 +1076,8 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"cocos.editor.execute" => {
"在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。"
}
"unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
"godot.editor.execute" => "在当前 Godot 项目已打开的 Windows x64 标准编辑器中执行支持 return/await 的 GDScript 函数体。DLL 原件保留在安装目录,宿主在私有缓存准备每实例加载副本,受管描述文件引用该副本,重新聚焦后自动加载;仅提交 code,结果待核对时禁止自动重发。",
"unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。执行载荷仅有 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
"godot.editor.execute" => "在当前 Godot 项目已打开的 Windows x64 标准编辑器中执行支持 return/await 的 GDScript 函数体。执行载荷仅有 code,重新聚焦可触发首次加载;结果待核对时禁止自动重发。",
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
"agent.delegate" => {
@@ -1927,6 +1947,63 @@ mod tests {
assert_eq!(schema["properties"].as_object().unwrap().len(), 1);
}
#[test]
fn editor_guides_reach_native_tool_definitions_without_truncation() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
for id in [
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID,
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID,
] {
crate::builtin_plugins::set_enabled(id, true).unwrap();
}
let functions = build_agent_runtime_native_function_tools().unwrap();
for (engine, skill, tool) in [
("Unity", "agc-unity-editor", "unity.editor.execute"),
("Godot", "agc-godot-editor", "godot.editor.execute"),
] {
let reference = crate::agent::read_agc_skill_resource(&format!(
"{skill}/references/【操作指南】{engine}编辑器常用操作-2026-09-20.md"
))
.unwrap();
assert_eq!(
editor_operation_reference(tool)
.unwrap()
.replace("\r\n", "\n"),
reference
);
let emitted = functions
.iter()
.find(|function| function.name == native_runtime_function_name_for_tool(tool));
let expected = match engine {
"Unity" => cfg!(all(
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
)),
"Godot" => cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
)),
_ => false,
};
assert_eq!(emitted.is_some(), expected);
if let Some(function) = emitted {
let wire = serde_json::to_value(function).unwrap();
assert_eq!(
wire["description"].as_str().unwrap().replace("\r\n", "\n"),
reference
);
} else {
assert!(!functions
.iter()
.any(|function| function.description.contains(&reference)));
}
}
}
#[test]
fn strict_native_function_schemas_match_openai_subset() {
let functions =
@@ -1,5 +1,9 @@
# 决策记录
## Unity 与 Godot 常用操作指导
两种编辑器的操作指导复用客户端审核 Skill packDirectProject 通过原生 Skill 或既有审核资源读取入口按需取得,Agent Runtime 的对应执行工具说明嵌入同源参考。指南不改变插件可用性、执行授权或 Runner 回执;只读说明不能证明编辑器已连接。常用示例与执行失败/部分修改、保存、撤销边界在同一参考中维护,避免提示词和文档各存一份代码。
## 2026-09-20 Godot 编辑器执行接入
Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和不确定执行回执合同,编辑器实现留在 `plugins/agc-godot-editor`。用户选择 DLL 原件随 AGC 安装资源分发,并确认按编辑器实例在 AGC 私有缓存准备临时加载副本,以满足 Godot Windows 加载器的同目录 `~DLL` 写入要求;项目内不复制 DLL,只用受管 `.gdextension` 引导。Godot 自动 UID 伴生文件必须记录归属并在确认卸载后按内容匹配清理。工作区根不迁移到 Godot 子目录,原始项目配置与场景只通过明确编辑操作修改。完整合同及验证范围见 [Godot 编辑器插件接入](<../../technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。
@@ -7,6 +7,12 @@
## 目标与边界
常用操作指导随客户端审核 Skill pack 提供,入口为 `agc-godot-editor`。DirectProject 按需读取 [Skill 入口](../../apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/SKILL.md) 和其常用操作参考;Runtime 的 `godot.editor.execute` 工具说明包含同一参考正文。指导覆盖场景/节点、owner、PackedScene、资源、UI、保存与撤销,不新增专用操作工具,不改变执行授权。
原文示例在 Godot 4.7.2 标准版 headless 工程验证了节点回读、局部撤销、PackedScene 存读、居中 UI 结构、无缩略图保存重开及只读旧文件写失败时保留内存修改。`save_scene_as` 不返回错误码,指南在重开前核验磁盘包含本次预期变更,不能以旧文件可加载作为保存成功证据。复验时设置 `AGC_GODOT_TEST_EXECUTABLE`,运行 `node --test plugins/agc-godot-editor/native/gdextension/tests/guide-examples.test.mjs`。正式 Ctrl+Z 历史、运行中停止、GUI 缩略图保存及 UI 视觉仍未由该测试验收。
独立图形环境补验已确认该示例在 800×600、480×800、1280×720 三种实际渲染尺寸下中文和按钮正常显示、容器居中且无裁切;此结果只覆盖示例布局,不代表按钮已接入游戏逻辑或其他 UI 已验收。
将 Godot 编辑器操控接入现有 AGC PluginHost、EditorAdapter、Runner、内置插件开关、权限审计和 Agent 工具链。Windows x64 的 Godot 4.7 及以上标准编辑器是首个实现目标,实机验收使用 4.7.2;其他平台和 .NET 编辑器不得从该结果推断支持。
初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。链接/reparse point 继续按同一文件边界失败关闭。
@@ -3,10 +3,14 @@
> 文档状态:`current`
> 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范
更新时间:`2026-09-18`
更新时间:`2026-09-20`
## 目标与非目标
常用操作指导随客户端审核 Skill pack 提供,入口为 `agc-unity-editor`。DirectProject 按需读取 [Skill 入口](../../apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/SKILL.md) 和其常用操作参考;Runtime 的 `unity.editor.execute` 工具说明包含同一参考正文。指导覆盖查询、对象/组件、Prefab、资源、UI、保存与撤销,不新增专用操作工具,不改变执行授权。
指南示例在独立 Unity 6000.3.7f1 Mono 工程经真实 Attach 验证,覆盖查询、创建/修改、Undo、Prefab override 保存重开、Canvas 及播放切换;不据此推断 UI 视觉、第三方包或其他版本已验收。复验入口为 `native/unity-editor-bridge/tests/guide_examples.rs`:按文件头显式设置 fixture 的 helper、项目与 PID 环境变量后,运行 `cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml --test guide_examples -- --ignored --nocapture`;它直接提取随包指南代码,而非维护示例副本。
将 DotCraft.Unity 0.4.3 对应的 Attach 执行核心接入 AGC 现有插件系统,使当前 Unity 项目能够探测编辑器、建立连接、执行 C# 并获得真实结果。复用既有扩展列表、内置插件开关、权限、审计、EditorAdapter 和 Agent 工具通路。
首期只支持 Windows x64 的 Unity Mono Editor。连接不修改项目文件、不安装 UPM 包、不启动或关闭用户编辑器。不引入 DotCraft.Harness、另一套 Agent Runtime、MCP 服务或聊天界面;截图、热重载专用工具、macOS、Linux 和 Unity CoreCLR 不属于本次交付。
@@ -4,7 +4,7 @@
> 规范关系:AGC 插件与编辑器适配主规范
> 验收范围:插件 manifest、宿主生命周期、RPC、Capability/权限审计、UI 挂载和编辑器适配器边界
更新时间:`2026-09-18`
更新时间:`2026-09-20`
## 目标与边界
@@ -126,8 +126,27 @@ Windows x64 的 Attach helper 来源、构建工具链和执行回执合同见
Godot 使用同一 Runner 执行与回执确认层,按引擎分别保存 pending/uncertain 状态,不能相互确认或清除。`godot-editor` 的受控连接允许按用户已选方案维护项目内 `.gdextension` 引用及 Godot 自动生成的 UID;DLL 随安装资源分发,探测仍只读,原始项目配置和场景不改。具体文件归属、GDScript 错误/async、升级卸载与分发合同见 [Godot 插件接入](<./【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。
## 编辑器常用操作指导
Unity 与 Godot 的常用操作指导由客户端既有审核 Skill pack 随包提供;每个引擎有独立入口和常用操作参考,不新增执行工具或任意文件读取入口。DirectProject 在隔离 Skill 目录发现指导,也可通过已有 `agc_read_skill_resource` 按审核名称和相对路径读取。Agent Runtime 的对应执行工具说明包含同一份常用操作参考,避免只覆盖 Codex 原生 Skill 路径。正文只有一个源码来源,清单指纹与安装投影必须一致。
指导覆盖场景/层级查询、对象或节点的创建/修改/删除、组件或属性、Prefab/PackedScene、资源引用、基础 UI、打开/保存场景、运行/停止及诊断。示例是提交给现有 execute 的代码正文,说明前置状态、预期回读和保存/撤销语义,不写宿主路径、PID、令牌或桥接安装动作。读说明不连接编辑器、不授权修改;执行仍受原插件开关、平台、项目和权限门禁控制。
每个引擎的操作参考不超过 14 KiB UTF-8,并独立包含执行参数和失败边界;Direct 常驻提示只给读取路由,16K 字符预算内必须保留两个引擎的指南入口。Runtime 的最终工具定义须完整包含对应参考,不能因截断丢失末尾内容,也不能在该执行工具不可用时额外注入正文。
通用 CapabilityRegistry 保留原有短描述和 4000 字符约束;完整参考只在已注册能力转换为 Provider 函数工具时附加,按 UTF-8/LF 规范化并校验 14 KiB 上限。不扩大 core 的任务、能力或摘要长度合同。
执行载荷仅有 `code`Direct 工具的顶层参数为 `{code}`Runtime 原生函数沿用 `{reason,input:{code}}` 外层,指南必须按实际工具 schema 区分这两种调用格式。
确定失败也可能已经产生部分修改;未知结果继续禁止自动重放。Unity 使用实际场景与对象身份,区分 Undo、Prefab override、保存和 Domain Reload。Godot 使用真实编辑场景根、为需保存的新节点设置 owner,区分独立 UndoRedo 回滚与编辑器历史,避免承诺未验证的 Ctrl+Z。主线程同步死循环不可硬中止。
验收要求:两个入口均能取得审核正文,源码与安装后的字节一致;非法路径及未登记资源继续拒绝;系统提示能够发现指南但不塞入全部示例;从指南原文提取代码做真实临时工程验证,至少覆盖查询、修改与回读、局部撤销、场景持久化、资源实例化和 UI。未实测操作和平台须在交付记录中明确,不把 API 示例当作已有独立工具。
当前证据覆盖本地指南读取/安装、最终工具描述、真实编辑器执行示例及 Godot 示例图形渲染。Unity GUI 视觉和真实 Provider 读取指南后调用编辑器的端到端链路尚未验收,不能由定向测试或前置状态检查推断通过。
## Tauri 命令
`list_agc_extensions` 返回统一的 Plugin/Skill/MCP catalog`list_agc_plugins``refresh_agc_plugins``start_agc_plugin``stop_agc_plugin``reload_agc_plugin``call_agc_plugin``read_agc_plugin_panel` 提供 Runtime Plugin 管理入口;`set_agc_plugin_project_path` 设置当前项目的受控上下文。编辑器适配器通过宿主 registry 和 Plugin RPC 使用,不增加编辑器专属 Tauri 命令。
编辑器操作统一走 `host.rpc`:插件用 `extensions.world.genarrative.agc.adapter` 或显式 `adapter` 参数选择适配器,宿主校验 `editor.rpc` 权限后调用 `EditorAdapter::rpc`。项目上下文通过 `host.events.subscribe` 的响应和 `project.changed` 事件 payload 下发,插件不需要自己扫描目录。
@@ -1377,7 +1377,7 @@ game-project/
- 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。
- 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。
- `agc-skill-pack.v1` 包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。
- `agc-skill-pack.v1` 包含完整游戏交付流程、项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影,以及 Unity/Godot 编辑器常用操作八项审核 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。
- DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP2026-08-31 起还会在启动时接入客户端扩展仓库中用户已启用的独立第三方 STDIO/HTTP MCP 配置,但不读取用户全局 Codex MCP、不开启完整 Plugin Runtime。内置工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`。内置 MCP 进程只做协议;真实浏览器、付费 External v1 调用与受控搜索通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key、项目路径、revision、operation 或幂等键到模型上下文。内置与用户启用的第三方 MCP 工具都沿用 DirectProject 自动批准方式,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;通用 shell、Codex 原生 webSearch、任意原生命令网络、多 Agent 和完整插件能力继续关闭。`llm.webSearchEnabled` 只控制 DirectProject 的 AGC 受控搜索工具暴露与执行,Codex 原生 `web_search` 始终保持 disabledProvider、ToolHost、DirectHome 不纳入本次联网主链路。
- 陶泥儿生成继续复用持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记;普通客户端优先使用当前 AGC 登录会话及账号路由,只有受控的 ExternalDeveloper 发布模式才在客户端内部使用按服务器 origin 隔离的私有 Key。用户和模型都不需要提供或配置 API Key;凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。
- 自定义 LLM API Key 路由只在 DirectHome/DirectProject 经 loopback `/responses` 流式代理转发。代理不注入 Key,只要求请求自带 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,防止隔离 app-server 把 API Provider 误判为余额 0;旧 ToolHost 保持原 Provider 行为。
@@ -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 撤销、保存重开、播放/停止及状态诊断通过。");
}