补齐 Unity 与 Godot 常用操作指导
新增两种编辑器的内置 Skill 和场景、资源、UI、保存撤销示例 接入 DirectProject 按需读取与 Runtime 同源操作参考 补齐指南原文实机测试、安装投影及工具说明完整性检查 记录 Godot 图形补验结果并保留尚未验收的边界
This commit is contained in:
@@ -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` 不等于编辑器撤销历史。
|
||||
+257
@@ -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 方法操作用 Callable;manager 用对象、方法名、参数。不要为取得 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,保存操作检查返回值。执行失败可能留下部分修改,结果未知时不得重放。插件不会自动把任意代码变成可撤销事务。
|
||||
+225
@@ -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 pack:DirectProject 通过原生 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 MCP;2026-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` 始终保持 disabled;Provider、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 撤销、保存重开、播放/停止及状态诊断通过。");
|
||||
}
|
||||
Reference in New Issue
Block a user