Initial public release: cc-3-8-x-mcp

Cocos Creator 3.8.x MCP bridge extension with a built-in offline CLI.

Components:
- Editor extension: in-process MCP server exposing scene / asset-db /
  preview / local / editor-process-control tools
- stdio router: aggregates multiple editor instances on one machine,
  with shortName dedup
- offline CLI (cocos-mcp-cli): headless prefab read/write + a wrapper
  around the Cocos CLI build

Pure Node.js, zero third-party dependencies. Licensed under Apache-2.0.
This commit is contained in:
furao
2026-06-06 11:33:19 +08:00
commit 14c5b00f14
96 changed files with 15855 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 依赖与构建产物
node_modules/
# 临时与本机产物
.dev/
.DS_Store
*.log
*.bak
*.bak.*
# 开发过程文档 / 实验(不对外开源)
REVIEW_*.md
CLI_ARRAY_REF_REPORT.md
poc/
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 付饶
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+6
View File
@@ -0,0 +1,6 @@
cc-3-8-x-mcp
Copyright 2026 付饶
This product is licensed under the Apache License, Version 2.0.
See the LICENSE file for the full license text, or visit
http://www.apache.org/licenses/LICENSE-2.0
+163
View File
@@ -0,0 +1,163 @@
# Quick Ref — Agent 速查表
> 一页搞定。复杂场景看 [`doc/cli.md`](./doc/cli.md)。
## 入口(在 `forest/` 项目根执行)
```bash
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js <command>
```
## 节点定位三种形式(`node` / `parent` / `target` / `source` / `refNode` 通用)
| 写法 | 用途 |
|---|---|
| `"itemList"` | 名字,首个匹配 |
| `{"id": 65}` | __id__**stub 节点 `_name` 为 null,必须用这个** |
| `{"path": "Canvas/Main/itemList"}` | DOM-like 路径;同名节点多时用 |
## 我要做什么 → 用什么
### 看 prefab
| 场景 | 命令 |
|---|---|
| 看节点树 | `query <prefab> --selector tree` |
| 看节点树 + 所有组件字段 | `query <prefab> --selector tree --with-comps` |
| 看单节点详情 | `query <prefab> --selector node --name X --with-comps` |
| 拿单组件单字段值(脚本管道) | `query <prefab> --selector field --name X --comp cc.UITransform --field _anchorPoint` |
| 列所有 cc.Label 的 id | `query <prefab> --selector find --type cc.Label` |
| 看 stub 节点已写入的所有 propertyOverrides | `query <prefab> --selector overrides --id 58` |
| 比较两个 prefab 字段差异 | `diff <a> <b>` |
### 改 prefab — 节点字段
| 场景 | op |
|---|---|
| 改 _active | `set-active` |
| 改 _name | `rename-node` |
| 改 _lpos(绝对值 x/y/z | `set-position` |
| 改 _lpos(相对偏移 dx/dy/dz | `adjust-position` |
| 改 _colorr/g/b/a0-255 | `set-node-color` |
| 调子节点顺序 | `reorder-children`order 必须含全部子节点) |
| 把节点搬到另一个父节点下 | `reparent`node + parent + index?;不复制;普通 inline 节点;自带循环检测)|
### 改 prefab — 组件字段
| 场景 | op |
|---|---|
| 改普通节点任意组件任意字段(含嵌套路径 `["_color","r"]` | `set-component-field` |
| 改 stub 内部组件字段 | `set-nested-component-field` |
| 清 stub 字段 override(回滚嵌套默认) | `reset-overrides`(单条 + property / `all:true` 清空) |
| 启用/禁用某组件 | `set-component-enabled` |
| cc.UITransform 锚点(含自动补偿 lposstub 也支持) | `set-anchor`(带 `compensatePosition: true` |
| cc.UITransform 尺寸(stub 也支持) | `set-size` |
| cc.Label 文字 | `set-label-text` |
| cc.Label 多字段(fontSize / overflow / bold 等) | `set-label` |
| cc.Sprite 换图 | `set-sprite-frame` |
| cc.Sprite 模式字段(sizeMode / type / grayscale | `set-sprite` |
| cc.Button 多字段(interactable / transition 等) | `set-button` |
| cc.EditBox 多字段(inputMode / maxLength / placeholder | `set-editbox` |
| cc.Layout 多字段(type / spacing / padding | `set-layout` |
| cc.RichText 多字段(text / maxWidth | `set-richtext` |
| 一次改一批(按组件类型 / 名前缀 / 正则筛选) | `bulk-set` |
### 改 prefab — 节点结构 / 引用
| 场景 | op |
|---|---|
| 加节点 | `add-node` |
| 加嵌套 prefab 实例(stub | `add-nested-prefab`parent + prefabUuid + name? + lpos? |
| 替换嵌套 prefab 的 asset uuid(保留 stub 结构) | `replace-nested-prefab`target + prefabUuid + clearOverrides? |
| 删节点 | `remove-node` |
| 清悬空嵌套实例根(删了一半的 prefab 残留:父引用没了但根 PrefabInfo 登记还在,残留 asset 仍被加载 → 404)| `sync-nested-roots`(无参,重建根 nestedPrefabInstanceRoots |
| 复制节点 | `clone-node` |
| 加组件 | `add-component` |
| 删组件(普通节点) | `remove-component`stub 不支持,用 `set-component-enabled` 禁用) |
| **新建 .ts / .json 后让 cli 当场可识别** | `ensure-meta`(path 相对项目根或绝对路径,建 v4 uuid meta)。**新建脚本必须用**——不然 add-component 会因 cli 查不到 .meta 抛错。同 batch 内放在 `add-component` 前 |
| 给脚本 @property 挂节点引用 | `set-component-ref`refType=`cc.Node` |
| 给脚本 @property 挂组件引用 | `set-component-ref`refType=`cc.Button` 等) |
| 给脚本 @property 挂 stub 内组件 | `set-component-ref`refNode 是 stub,自动走 TargetOverrideInfo |
| 给脚本 @property 挂多层嵌套 stub 内组件 | `set-component-ref`refSubNode 用字符串数组 `["A","B"]` |
| 给脚本 @property **数组字段** 按索引挂载(`_items[0]`/`_items[1]`…) | `set-component-ref`property 写 `"_items.0"``"_items[0]"`,多次调用各索引共存) |
| 合并同节点重复组件(cli 字符串版 + 编辑器压缩版) | `dedupe-component` |
### 跨文件 / 其他
| 场景 | 命令 |
|---|---|
| 跑 ops.json | `batch <prefab> <ops.json>` |
| 干跑预览(不写盘) | `batch <prefab> <ops.json> --dry-run` |
| 跨多个 prefab 跑同一组 ops | `batch <ops.json> --glob "<pattern>"`(先 `--dry-run` 确认匹配) |
| 操作 .anim 文件 | `anim query` / `anim batch <file> <ops.json>` |
| 单字段快捷写入(active / label.text / position.x\|y\|z | `set <prefab> <nodeName> <field> <value>` |
| 创建新 prefab(最小 root + UITransform | `create-prefab <out> [--name X] [--width W] [--height H]` |
| 创建 spine prefabroot + UITransform + sp.Skeleton | `create-prefab <out> --add-spine <skel-uuid>`,批量靠 shell `for` 循环喂 .skel.meta 的 uuid |
| **从 src 提取某子节点为独立 prefab**(含组件 + PrefabInfo + stub 嵌套等所有引用闭包) | `extract-prefab <src> <out> --node <selector> [--name X]`。selector 同 batch 三种(名/`{id:N}`/`{path:"A/B"}`)。新根 `_parent=null`PrefabInfo.root 指自己、asset 指 idx 0;适合"把 HomeBottom.btnTask 拆成 task BottomEntry.prefab" 这类场景 |
| **清 prefab data 数组 null 槽位 + 重映射 __id__**(早期手工生成的历史包袱) | `compact-prefab <prefab> [--dry-run]`。Cocos editor 反序列化容错跳过 null,但 build worker 严格 scan 撞 null 崩 `Cannot read properties of undefined (reading '__type__')`。算法同 extract-cmd line 105-132 紧凑 push + remap,但不剔除任何东西。dry-run 输出 dangling 引用警告(指向已删 null 的 `__id__`)。只清顶层 data 数组 null;子节点字段里的 null 引用要靠 GUI 重存或 query+set 单点修复 |
## 写 ops.json 速记
```json
[
{"op": "set-active", "node": "X", "active": false},
{"op": "rename-node", "node": "X", "name": "Y"},
{"op": "reparent", "node": "child", "parent": "newParent"},
{"op": "reparent", "node": {"id": 33}, "parent": "container", "index": 0},
{"op": "set-anchor", "node": "X", "y": 1, "compensatePosition": true},
{"op": "set-size", "node": "X", "width": 600, "height": 800},
{"op": "set-component-field", "node": "X", "componentType": "cc.UITransform", "property": "_anchorPoint", "value": {"__type__": "cc.Vec2", "x": 0.5, "y": 1}},
{"op": "set-component-field", "node": "X", "componentType": "cc.Label", "property": ["_color", "r"], "value": 255},
{"op": "set-nested-component-field", "node": {"id": 33}, "componentType": "cc.Label", "property": "_string", "value": "新文字"},
{"op": "set-component-ref", "node": "X", "componentType": "MyUI", "property": "_role", "refNode": {"id": 33}, "refType": "cc.Node"},
{"op": "set-component-ref", "node": "X", "componentType": "MyUI", "property": "_items.0", "refNode": {"id": 27}, "refType": "ItemComp"},
{"op": "set-component-ref", "node": "X", "componentType": "MyUI", "property": "_items[1]", "refNode": {"id": 40}, "refType": "ItemComp"},
{"op": "bulk-set", "selector": {"byComponent": "cc.Label"}, "target": "component:cc.Label", "property": "_isItalic", "value": true},
{"op": "reorder-children", "node": "list", "order": ["item3", "item1", "item2"]}
]
```
## 高频踩坑(详见 doc/cli.md §8
| 症状 | 原因 / 解法 |
|---|---|
| `找不到节点 "xxx"` 但 query 看见了 | xxx 是 stub`_name` 为 null。改用 `{id:N}` |
| 修改后 Cocos 编辑器打开发现字段没生效 | stub 节点要走 propertyOverrides。`set-component-field` 用错了,stub 改字段必须用 `set-nested-component-field` |
| 改 anchor 后视觉位置偏了 | 加 `compensatePosition: true` 自动补偿 lpos |
| 字段拼错 / 缺必填 / 未知 op / 类型错 | schema 校验跑前一次报齐(`comp``componentType` 拼写提示;`width:"100"` 类型错明示期望类型) |
| 改 cc.Vec2/Vec3/Size 字段后运行时取值不对 | `value` 必须带 `__type__: "cc.Vec2"``set-anchor` / `set-size` 已自动带 |
| `reorder-children` 抛错 "order 长度 ≠ _children 长度" | order 必须列全部子节点(不允许只列要前置的几个) |
| `{path:...}` 抛错"同名子节点 N 个" | path 不能消歧。用 `{id:N}` 精确定位 |
| `_name` 为 nullquery tree 输出里看到) | stub 节点正常现象,名字在 PrefabInstance.propertyOverrides,运行时填 |
| `set-component-ref` 报"未挂 XXX 组件",但 add-component 刚刚加上 | `componentType` 格式不一致:add-component 传了原始 UUIDset-component-ref 传了 @ccclass 名,两者被规范化成不同字符串。**修复已合入**:三种形式(@ccclass 名 / 原始 UUID / 压缩 classId)现在可混用,同 batch add+ref 也不再需要拆分。 |
| `set-component-ref` 多次挂同一数组字段(`_items`),后一条覆盖前一条 | 旧版幂等检查按 `propertyPath[0]` 去重,同字段名只保留一条。**修复已合入**:`property` 支持 `"_items.0"` / `"_items[0]"` 写法,幂等 key 改为完整路径数组,各索引独立共存。 |
| nested prefab 数组字段 override 写入后 Cocos inspector 显示空 / 运行时 `_items[N]` 全是 null | **数组索引 propertyPath 必须 int 不是 string**propertyPath 末段(数组索引)类型必须是 number,不是 string。Cocos 编辑器按 string key 匹配不到数组槽,override 静默失效。CLI `addRootTargetOverride` 已在写入前自动 normalize2026-05-18 修),调用 `set-component-ref``"_items.0"``"_items[0]"` 均安全,直接传 `["_items", "0"]` string 数组也会被自动转 int。 |
| stub 节点 `set-position` / `rename-node` 写入后,Cocos 加载该 prefab 时 _name / _lpos override 没生效(嵌套实例显示默认值) | **stub-node-field 类型 override.targetInfo.localID 必须是「嵌套 prefab 内根节点 PrefabInfo.fileId」**,不是「外层 stub 自己的 PrefabInfo.fileId」。早期 fgui→cc3 转出的 prefab 设计上两个 fileId 一致,所以巧合工作;手编/重设计的 prefab 一般不一致就暴露。CLI `setOverrideProperty` 已在写入前加载嵌套 prefab 拿真实根 fileId2026-05-20 修,**解析失败抛错**),并在命中旧版 stubFileId 写入的条目时**自动矫正** localID(一次性迁移历史脏数据)。`listOverrides` / `reset-overrides` 只识别真值 localID。 |
| `set-component-ref` 新加单字段引用(如 `_btnClose`)后,Cocos 加载时 `ui._xxx` 仍为 null,对应字段没赋值 | **Cocos 加载 rootTargetOverrides 数组时,单字段 override 必须排在数组字段 override`_items[N]`)之前**,否则被静默跳过。CLI `addRootTargetOverride` 已自动按 propertyPath 长度分插(2026-05-20 修,`cli/src/editor/nested.js`):`propertyPath.length === 1` 插到第一个数组字段 override 之前,`length > 1` 追加到末尾。原 `rootPrefabInfo.targetOverrides.push(...)` 改为 `splice(firstArrayIdx, 0, ...)`。 |
| `remove-node` 删嵌套 stub 后,Cocos 加载父 prefab 报错 / 外层脚本对该 stub 内部的引用(如 `_passScoreView`)悬空 | 删 stub 时只移除了 `_children`/`mountedChildren`/`nestedPrefabInstanceRoots`**根 PrefabInfo.targetOverrides 里 source/target 指向被删子树的条目没清**——外层脚本 @property 引用嵌套实例内部组件/节点走 targetOverride,残留为**可达**悬空引用(区别于软删保留的不可达孤儿,后者无害)。**修复已合入**(2026-06-01`cli/src/editor/ops/remove-node.js``collectSubtreeIds` + `cleanupRootTargetOverrides`):删子树前收集全部 __id__,从根 targetOverrides 过滤掉 source/target 落入子树的条目(被删 override 对象本身保留为孤儿,符合软删策略);`target=null`(走 targetInfo.localID)的无关条目保留。 |
## 标准工作流
```bash
# 1. 看节点树拿 id
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js query <prefab> --selector tree
# 2. 写 /tmp/ops.json
# 3. 干跑预览
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js batch <prefab> /tmp/ops.json --dry-run
# 4. 落盘
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js batch <prefab> /tmp/ops.json
# 5. 类型检查
npx tsc --noEmit
```
## 完整文档
- [`README.md`](./README.md) — MCP 扩展定位 + 架构 + tools 清单
- [`doc/cli.md`](./doc/cli.md) — **CLI 完整手册**(命令、配方、已知坑、源码导航)
- [`doc/prefab-schema.md`](./doc/prefab-schema.md) — prefab JSON 结构
- [`doc/nested-prefab-protocol.md`](./doc/nested-prefab-protocol.md) — `cc.TargetOverrideInfo` 协议
- [`doc/anim-schema.md`](./doc/anim-schema.md) — `.anim` 文件结构
+222
View File
@@ -0,0 +1,222 @@
# cc-3-8-x-mcp
Cocos Creator 3.8.x 的 MCP 桥接扩展 + 离线 prefab 读写 CLI。
把编辑器的 scene/asset/preview/local 能力以 MCP 协议暴露给 Claude Code;同时提供无需编辑器运行的 offline prefab 编辑能力,支持节点增删克隆与 stub 节点属性覆写。
---
## 文档导航
| 文档 | 内容 | 阅读时机 |
|---|---|---|
| [`QUICK-REF.md`](./QUICK-REF.md) | **一页速查表**:节点定位三式、场景 → op 对照、ops.json 速记、踩坑表 | agent 起手第一份,挑不到再翻 cli.md |
| [`doc/cli.md`](./doc/cli.md) | **CLI 完整手册**:命令、26 个 op 全表、配方、已知坑、源码导航 | 改 `.prefab` / `.anim` 文件前必读 |
| [`doc/prefab-schema.md`](./doc/prefab-schema.md) | CC3 prefab JSON 结构速查(节点 / 组件 / 引用字段格式) | 看不懂 prefab 字段时查 |
| [`doc/nested-prefab-protocol.md`](./doc/nested-prefab-protocol.md) | `cc.TargetOverrideInfo` 协议(跨 nested @property 挂载) | 调试 set-component-ref 跨 stub 失败时 |
| [`doc/prefab-direct-edit.md`](./doc/prefab-direct-edit.md) | offline CLI vs 编辑器路径决策表 | 不确定该用 CLI 还是 scene_set_property 时 |
| [`doc/anim-schema.md`](./doc/anim-schema.md) | `.anim` 文件结构 + Track 字段规范 | 改动画文件结构时 |
---
## 架构
```
Claude Code (MCP client)
│ stdio (JSON-RPC)
┌─────────────────────────────┐
│ router/bin.js │ ← 统一入口,聚合多个编辑器
│ - 扫 ~/.cocos-mcp/editors/ │
│ - offline prefab tools │
└───────────┬─────────────────┘
│ HTTP MCP (JSON-RPC)
┌───────┴────────┐
│ │
▼ ▼
编辑器实例 A 编辑器实例 B ← 每个项目一个编辑器进程
server/mcp-server.js ← 在 Cocos 编辑器进程内跑 HTTP MCP server
```
offline prefab tools`prefab_query` / `prefab_edit` / `prefab_batch`)在 router 进程内直接执行,调用 `cli/src/index.js`,不需要编辑器运行。
---
## 三个组件
### 1. 编辑器扩展(`main.js` + panel + server
在 Cocos Creator 编辑器进程内运行。启动时拉起 `server/mcp-server.js`HTTP MCP server),并把自身信息写入 `~/.cocos-mcp/editors/<pid>.json`(心跳注册)。
**暴露的 tool 域**
| 域 | 职责 |
|---|---|
| scene | 查询/修改节点树,打开/保存/重载场景,调用组件方法 |
| asset-db | 资源查询、导入、创建、保存、删除、移动 |
| preview | 预览地址查询、浏览器控制、截图、JS 注入 |
| local | 本地状态、worktree 列表、.dev 目录管理 |
### 2. stdio router`router/`
入口:`router/bin.js`
**职责**
- 扫描 `~/.cocos-mcp/editors/` 发现活跃编辑器(心跳超 120s 视为已死)
- 每隔 15s 自动发现新实例
- 给每个编辑器的 tool 加 `<shortName>__` 前缀,合并后暴露给 Claude Code
- 内置 offline prefab tools`prefab_query` / `prefab_edit` / `prefab_batch`),不带前缀,全局可用
**tool 路由示例**
```
forest__scene_query_node_tree → forest 编辑器的 HTTP MCP server
another__asset_query_assets → another 编辑器的 HTTP MCP server
prefab_query → router 本地执行(cli),无需编辑器
```
### 3. cocos-mcp-cli`cli/`
零依赖 Node CLI,直接读写 `.prefab` 文件,无需 Cocos 编辑器运行。详见 [`doc/cli.md`](./doc/cli.md)。
---
## 功能面板
通过菜单「扩展 → Cocos MCP → 功能面板」打开,或在编辑器扩展面板停靠。
| 区块 | 内容 |
|---|---|
| MCP Server | 运行状态指示灯 / 端点地址 / tool 数量 / 请求计数;复制端点、复制 CLI 命令、重启 |
| 编辑器状态 | 当前分支 / HEAD / 预览地址和端口 / 编辑器 PID / Watcher 状态 / 最后更新时间 |
| 快捷动作 | 一键刷新(资源+场景+预览)/ 软重载场景 / 打开预览浏览器 / 截图 / 打开 .dev 目录 / 清理临时文件 / 手动输入路径重新导入 |
| Debug 注入 | 在预览页面执行任意 JS(`eval_js`),结果直接展示;自定义快捷按钮(配置见下方) |
| 同机 Worktree | 列出同机其他 worktree 及其预览端口,方便多开切换 |
| 命令日志 | 最近 30 条操作记录(时间 / 来源 / 命令) |
**自定义 Debug 按钮**:在项目根目录新建 `.dev/cc-mcp-panel.json`
```json
{ "buttons": [{ "label": "解锁签到", "code": "app.userMod.setUserValue(0,1)" }] }
```
---
## Tools 清单
### scene 域(需编辑器运行)
| Tool | 说明 |
|---|---|
| `scene_query_node_tree` | 查询当前场景节点树;传 uuid 查子树 |
| `scene_query_node` | 查询单节点完整 dump(含所有组件属性) |
| `scene_set_property` | 设置节点/组件属性(path 为 dump path,如 `position``__comps__.0.string` |
| `scene_open_scene` | 打开场景(传场景资源 uuid) |
| `scene_save_scene` | 保存当前场景 |
| `scene_soft_reload` | 软重载场景,不清编辑器状态 |
| `scene_execute_component_method` | 调用指定节点的组件方法 |
> 修改 prefab 资源文件属性建议用 `prefab_edit`offline),`scene_set_property` 只适用于运行时节点或需要编辑器上下文的情况。
### asset-db 域(需编辑器运行)
| Tool | 说明 |
|---|---|
| `asset_query_assets` | 按 glob pattern 列资源(如 `db://assets/**/*.prefab` |
| `asset_query_info` | 查资源详情(传 uuid 或 url |
| `asset_query_url` | 由 uuid 查资源 url |
| `asset_query_uuid` | 由 url 查资源 uuid |
| `asset_refresh` | 刷新资源(全量或指定路径) |
| `asset_reimport` | 重新导入指定资源 |
| `asset_create` | 创建新资源 |
| `asset_save` | 保存资源内容 |
| `asset_delete` | 删除资源 |
| `asset_move` | 移动/重命名资源 |
### preview 域(需编辑器运行)
| Tool | 说明 |
|---|---|
| `preview_query_url` | 查询当前预览 URL |
| `preview_open_browser` | 在系统浏览器打开预览 |
| `preview_refresh_browser` | 刷新预览页面 |
| `preview_screenshot` | 截图,返回图片路径 |
| `preview_eval_js` | 在预览页面执行 JS,返回执行结果 |
| `preview_refresh_and_reload` | 重新导入资源后刷新预览(offline 改完必须调此工具才能看到效果) |
### local 域(需编辑器运行)
| Tool | 说明 |
|---|---|
| `local_get_status` | 获取编辑器本地状态(git branch、预览端口、PID 等) |
| `local_list_worktrees` | 列出同机所有 worktree |
| `local_open_dev_dir` | 在 Finder 中打开 .dev 目录 |
| `local_clean_dev_dir` | 清理 .dev 临时文件 |
### offline 域(router 级,无需编辑器运行)
| Tool | 说明 |
|---|---|
| `prefab_query` | 查询 prefab 节点树或单节点详情 |
| `prefab_edit` | 声明式批量编辑 prefab,所有 op 成功后一次性落盘 |
| `prefab_batch` | 从 JSON 文件读取 ops 后批量编辑 prefab |
完整 op 列表与配方见 [`doc/cli.md`](./doc/cli.md)。
> offline tools 的 `filePath` 和 `opsJsonPath` 必须为绝对路径;router 以 stdio 模式运行,cwd 不确定,相对路径有歧义。
---
## 多开支持
每个 Cocos 编辑器实例启动时向 `~/.cocos-mcp/editors/<pid>.json` 写入注册信息:
```json
{
"pid": 12345,
"url": "http://127.0.0.1:7788/mcp",
"shortName": "forest",
"projectPath": "/path/to/project"
}
```
router 定期扫此目录,心跳超过 120s 的记录视为死亡自动剔除。tool 名以 `<shortName>__` 为前缀隔离,同机多开不冲突。
---
## 接入 Claude Code
```bash
claude mcp add cocos -- node /path/to/forest/extensions/cc-3-8-x-mcp/router/bin.js
```
接入后 Claude Code 即可调用所有活跃编辑器的 tool,以及全局 offline prefab tools。
---
## `.dev/refresh` 信号协议
外部往 `<project>/.dev/refresh` 文件写一行命令,编辑器扩展的 watcher 读到后执行并清空文件。fire-and-forget,无返回值。
协议精简:**只支持 `restart-package`**——禁用→启用本扩展,让 `main.js` / `tools.js` / `server/*` 的代码改动生效。资源刷新 / 场景重载 / 预览刷新 / 截图等都走 MCP tool(`preview_refresh_and_reload` / `asset_reimport` / `preview_screenshot` 等)或面板按钮。
```bash
echo "restart-package" > .dev/refresh
```
面板上的「重启 MCP Server」按钮只重启 HTTP server 实例,Node require 缓存不动,**改不到 main.js 的代码改动**。`restart-package``Editor.Package.disable + enable`,整个扩展沙箱重建,所有 JS 重新 require。注意命令是 fire-and-forget,不返回结果;扩展重启过程中 MCP 连接会短暂中断(≈1-2s),重连由 router 自动发现完成。
---
## 已知限制
1. **stub 节点的组件字段**`set-label-text` / `set-sprite-frame` / `set-nested-component-field` 已通过 `uuid-resolver.js` 解析 `CompPrefabInfo.fileId` 写入 stub 节点的 override
2. **多层嵌套 stub**CLI 已通过 `refSubNode` 字符串数组路径支持。极端深嵌套场景仍建议走 tools pipeline`tools/step-3-script/bind-prefab-components.ts`
3. **offline 改完后需手动触发刷新**:直接写磁盘 Cocos 编辑器不会自动感知。改完后调用 `asset_reimport`(编辑器在线时)或 `preview_refresh_and_reload`,否则编辑器和预览页面看到的仍是旧内容
---
## 反馈
使用本扩展(MCP tool / offline CLI)时遇到 bug、行为异常或文档与实际不符,agent 必须把问题反馈给用户,由用户决定是否修复扩展本身,不要在调用方加 workaround 绕过。
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
'use strict';
require('../src/index.js'); // 确保模块可正常加载
const { main } = require('../src/cli/main.js');
main(process.argv.slice(2));
+9
View File
@@ -0,0 +1,9 @@
{
"name": "cocos-mcp-cli",
"version": "0.1.0",
"private": true,
"bin": { "cocos-mcp-cli": "./bin/cocos-mcp-cli.js" },
"main": "./src/index.js",
"license": "Apache-2.0",
"author": "付饶"
}
+378
View File
@@ -0,0 +1,378 @@
// ============================================================
// CC3 AnimationClip (.anim) 对象构建原语(纯 CJS,零三方依赖)
//
// .anim 文件和 .prefab 一样是 JSON 数组 + `__id__` 交叉引用,
// 复用 parsePrefab / writePrefab 解析写入。
//
// 但 .anim 内部对象类型(AnimationClip / Track / Curve / Channel
// 有自己的 schema 规范,最容易踩的坑:
//
// ✅ cc.animation.RealTrack → 单通道 → `_channel: {__id__: X}` (单数)
// ✅ cc.animation.ObjectTrack → 单通道 → `_channel: {__id__: X}` (单数)
// ✅ cc.animation.VectorTrack → 多通道 → `_channels: [x, y, z]` (复数数组)
// ✅ cc.animation.ColorTrack → 多通道 → `_channels: [r, g, b, a]` (复数数组)
//
// 写错字段名(比如给 RealTrack 写 `_channels: [...]`)会导致 CC3 编辑器
// 按 schema 验证时直接忽略整条轨道,表现为「anim 文件里有数据但编辑器
// 不显示关键帧、运行时也不播」——历史踩过的坑,见 anim-schema.md。
//
// 用本文件里的 make* 工厂函数,保证字段名一定写对。
// ============================================================
'use strict';
const { ref } = require('./primitives.js');
// ─────────────────────────────────────────────
// 插值模式常量
// 对应 cc.RealCurve.interpolationMode 枚举
// ─────────────────────────────────────────────
const InterpolationMode = Object.freeze({
LINEAR: 0, // 线性插值,两关键帧之间平滑过渡
CONSTANT: 1, // 常量插值,保持当前值直到下一帧瞬变
CUBIC: 2, // 三次插值,带缓动曲线
});
// ─────────────────────────────────────────────
// Extrapolation 模式常量
// 对应 cc.RealCurve.preExtrapolation / postExtrapolation
// ─────────────────────────────────────────────
const Extrapolation = Object.freeze({
LINEAR: 0,
CLAMP: 1, // 最常用:首帧之前保持首帧值,末帧之后保持末帧值
REPEAT: 2,
PINGPONG: 3,
});
// ─────────────────────────────────────────────
// RealKeyframeValue:单个浮点关键帧
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number} opts.value
* @param {number} [opts.interpolationMode=CONSTANT] 0=LINEAR 1=CONSTANT 2=CUBIC
* @param {number} [opts.easingMethod=0] 0=Linear,其余见 cc.EasingMethod
*/
function makeRealKeyframe(opts) {
const { value, interpolationMode = InterpolationMode.CONSTANT, easingMethod = 0 } = opts;
return {
__type__: 'cc.RealKeyframeValue',
interpolationMode,
tangentWeightMode: 0,
value,
rightTangent: 0,
rightTangentWeight: 1,
leftTangent: 0,
leftTangentWeight: 1,
easingMethod,
__editorExtras__: null,
};
}
// ─────────────────────────────────────────────
// cc.RealCurve:浮点曲线
// times/values 一一对应,长度必须相同
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number[]} opts.times - 递增时间点(秒)
* @param {object[]} opts.values - RealKeyframeValue 对象数组,与 times 同长
* @param {number} [opts.preExtrapolation=1] CLAMP
* @param {number} [opts.postExtrapolation=1] CLAMP
*/
function makeRealCurve(opts) {
const { times, values, preExtrapolation = Extrapolation.CLAMP, postExtrapolation = Extrapolation.CLAMP } = opts;
if (times.length !== values.length) {
throw new Error(`makeRealCurve: times.length (${times.length}) !== values.length (${values.length})`);
}
return {
__type__: 'cc.RealCurve',
_times: times,
_values: values,
preExtrapolation,
postExtrapolation,
};
}
// ─────────────────────────────────────────────
// cc.ObjectCurve:对象引用曲线(用于 spriteFrame 等资产序列)
// values 是资产引用对象数组(`{ __uuid__, __expectedType__ }`
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number[]} opts.times
* @param {object[]} opts.values - 资产引用对象
*/
function makeObjectCurve(opts) {
const { times, values, preExtrapolation = Extrapolation.CLAMP, postExtrapolation = Extrapolation.CLAMP } = opts;
if (times.length !== values.length) {
throw new Error(`makeObjectCurve: times.length (${times.length}) !== values.length (${values.length})`);
}
return {
__type__: 'cc.ObjectCurve',
_times: times,
_values: values,
preExtrapolation,
postExtrapolation,
};
}
// ─────────────────────────────────────────────
// cc.animation.ChannelTrack → Curve 的中间层
// ─────────────────────────────────────────────
/**
* @param {number} curveIdx - RealCurve/ObjectCurve 在 objects 数组里的下标
*/
function makeChannel(curveIdx) {
return {
__type__: 'cc.animation.Channel',
_curve: ref(curveIdx),
};
}
// ─────────────────────────────────────────────
// cc.animation.HierarchyPath / ComponentPath / TrackPath
// 用于定位轨道的目标节点与属性
// ─────────────────────────────────────────────
/**
* @param {string} path - 节点层级路径,如 "n4" 或 "content/titleBar";根节点自身写 ""
*/
function makeHierarchyPath(path) {
return { __type__: 'cc.animation.HierarchyPath', path };
}
/**
* @param {string} component - 组件类型,如 "cc.UIOpacity" / "cc.Sprite"
*/
function makeComponentPath(component) {
return { __type__: 'cc.animation.ComponentPath', component };
}
/**
* @param {unknown[]} parts - 混合 HierarchyPath/ComponentPath 引用与属性字符串
* 例:[ref(hierIdx), ref(compIdx), "opacity"]
* 或根节点属性:[ref(compIdx), "position"](无 hierarchy path
*/
function makeTrackPath(parts) {
return { __type__: 'cc.animation.TrackPath', _paths: parts };
}
// ─────────────────────────────────────────────
// cc.animation.RealTrack(单通道浮点轨道)
// ⚠️ 字段必须是 `_channel`(单数),不是 `_channels`
// ⚠️ 写错会被 CC3 编辑器 schema 验证忽略,轨道形同虚设
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx - TrackPath 在 objects 数组里的下标
* @param {number} channelIdx - Channel 在 objects 数组里的下标
*/
function makeRealTrack(trackPathIdx, channelIdx) {
return {
__type__: 'cc.animation.RealTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channel: ref(channelIdx),
};
}
// ─────────────────────────────────────────────
// cc.animation.ObjectTrack(单通道对象轨道,常用于 spriteFrame 序列帧)
// ⚠️ 字段必须是 `_channel`(单数)
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx
* @param {number} channelIdx
*/
function makeObjectTrack(trackPathIdx, channelIdx) {
return {
__type__: 'cc.animation.ObjectTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channel: ref(channelIdx),
};
}
// ─────────────────────────────────────────────
// cc.animation.VectorTrack(多通道,x/y/z 或 x/y/z/w
// ⚠️ 字段必须是 `_channels`(复数,数组),且必须提供 `_nComponents`
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx
* @param {number[]} channelIndices - 各轴 Channel 下标数组(长度 = nComponents
* @param {number} nComponents - 2(Vec2) / 3(Vec3) / 4(Vec4/Quat)
*/
function makeVectorTrack(trackPathIdx, channelIndices, nComponents) {
if (channelIndices.length !== nComponents) {
throw new Error(`makeVectorTrack: channelIndices.length (${channelIndices.length}) !== nComponents (${nComponents})`);
}
return {
__type__: 'cc.animation.VectorTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channels: channelIndices.map(ref),
_nComponents: nComponents,
};
}
// ─────────────────────────────────────────────
// cc.animation.ColorTrack4 通道 r/g/b/a
// ⚠️ 字段必须是 `_channels`(复数,数组),无 `_nComponents`
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx
* @param {number[]} channelIndices - [rIdx, gIdx, bIdx, aIdx]
*/
function makeColorTrack(trackPathIdx, channelIndices) {
if (channelIndices.length !== 4) {
throw new Error(`makeColorTrack: expected 4 channel indices, got ${channelIndices.length}`);
}
return {
__type__: 'cc.animation.ColorTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channels: channelIndices.map(ref),
};
}
// ─────────────────────────────────────────────
// cc.AnimationClipAdditiveSettings(每个 clip 尾巴一份)
// ─────────────────────────────────────────────
function makeAdditiveSettings() {
return {
__type__: 'cc.AnimationClipAdditiveSettings',
enabled: false,
refClip: null,
};
}
// ─────────────────────────────────────────────
// cc.AnimationClip 文件头(objects[0]
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {string} opts.name
* @param {number} opts.sample - 采样帧率
* @param {number} opts.duration - 秒
* @param {number} [opts.speed=1]
* @param {number} [opts.wrapMode=1] 1=Normal, 2=Loop, 22=PingPong, 36=PingPongLoop
* @param {number} opts.hash - 哈希值(通常 hashString(name)
* @param {number[]} opts.trackIndices - 所有 Track 在 objects 数组里的下标
* @param {number} opts.additiveIdx - AdditiveSettings 下标
* @param {number[]} [opts.embeddedPlayerIndices=[]] - EmbeddedPlayer 下标
*/
function makeAnimationClip(opts) {
const {
name,
sample,
duration,
speed = 1,
wrapMode = 1,
hash,
trackIndices,
additiveIdx,
embeddedPlayerIndices = [],
} = opts;
return {
__type__: 'cc.AnimationClip',
_name: name,
_objFlags: 0,
__editorExtras__: { embeddedPlayerGroups: [] },
_native: '',
sample,
speed,
wrapMode,
enableTrsBlending: false,
_duration: duration,
_hash: hash,
_tracks: trackIndices.map(ref),
_exoticAnimation: null,
_events: [],
_embeddedPlayers: embeddedPlayerIndices.map(ref),
_additiveSettings: ref(additiveIdx),
_auxiliaryCurveEntries: [],
};
}
// ─────────────────────────────────────────────
// cc.animation.EmbeddedPlayer / EmbeddedAnimationClipPlayable
// 用于 movieclip 这类「主 clip 触发节点自己 Animation 播子 clip」的结构
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number} opts.begin - 主 clip 时间线上子 clip 开始秒
* @param {number} opts.end - 主 clip 时间线上子 clip 结束秒
* @param {number} opts.playableIdx - EmbeddedAnimationClipPlayable 下标
*/
function makeEmbeddedPlayer(opts) {
const { begin, end, playableIdx } = opts;
return {
__type__: 'cc.animation.EmbeddedPlayer',
begin,
end,
reconciledSpeed: false,
playable: ref(playableIdx),
};
}
/**
* @param {object} opts
* @param {string} opts.path - 目标节点路径(相对 clip 所在节点)
* @param {string} opts.clipUuid - 子 clip 资产 UUID
*/
function makeEmbeddedAnimationClipPlayable(opts) {
const { path, clipUuid } = opts;
return {
__type__: 'cc.animation.EmbeddedAnimationClipPlayable',
path,
clip: {
__uuid__: clipUuid,
__expectedType__: 'cc.AnimationClip',
},
};
}
// ─────────────────────────────────────────────
// 哈希函数:生成 AnimationClip._hash
// 用简单字符串哈希,只需保证同名 clip 得到同一 hash 即可
// ─────────────────────────────────────────────
function hashString(s) {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = ((hash << 5) - hash) + s.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
module.exports = {
InterpolationMode,
Extrapolation,
makeRealKeyframe,
makeRealCurve,
makeObjectCurve,
makeChannel,
makeHierarchyPath,
makeComponentPath,
makeTrackPath,
makeRealTrack,
makeObjectTrack,
makeVectorTrack,
makeColorTrack,
makeAdditiveSettings,
makeAnimationClip,
makeEmbeddedPlayer,
makeEmbeddedAnimationClipPlayable,
hashString,
};
+138
View File
@@ -0,0 +1,138 @@
// ============================================================
// ClassIdResolver:扫描 assets/scripts/ 下所有 .ts,建立
// className → { uuid, classId, scriptPath } 映射。
//
// 来源:
// - @ccclass('SomeName') 装饰器提供 className
// - 同路径 <script>.ts.meta 的 uuid 字段
// - compressUuid(uuid) 得到 Cocos 序列化 prefab 用的 23 字符 classId
//
// 缓存粒度:projectRoot;多次调用复用。
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { compressUuid } = require('./id');
const _cache = new Map(); // projectRoot -> Map<className, entry>
/** 从路径向上找含 assets/+package.json 的目录。 */
function _findProjectRoot(startPath) {
const resolved = path.resolve(startPath);
let dir;
try {
dir = fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
} catch (_) {
dir = path.dirname(resolved);
}
for (let i = 0; i < 20; i++) {
if (fs.existsSync(path.join(dir, 'assets')) && fs.existsSync(path.join(dir, 'package.json'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`ClassIdResolver: 无法从 "${startPath}" 向上找到项目根`);
}
/** 扫 .ts 抽取 @ccclass('Name');一个文件可以多个 */
function _extractCcClassNames(src) {
const names = [];
const re = /@ccclass\(\s*['"]([^'"]+)['"]\s*\)/g;
let m;
while ((m = re.exec(src)) !== null) {
names.push(m[1]);
}
return names;
}
/** 在 assetsDir 下找所有 .ts(跳过 .d.ts),返回绝对路径数组。 */
function _listTsFiles(assetsDir) {
try {
const raw = execSync(
`find "${assetsDir}" -name "*.ts" -not -name "*.d.ts" -type f`,
{ encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }
);
return raw.trim().split('\n').filter(Boolean);
} catch (e) {
throw new Error(`ClassIdResolver: find 命令失败: ${e.message}`);
}
}
/** 建 projectRoot 下的 className -> entry 索引。 */
function _buildIndex(projectRoot) {
const scriptsDir = path.join(projectRoot, 'assets', 'scripts');
const scanDir = fs.existsSync(scriptsDir) ? scriptsDir : path.join(projectRoot, 'assets');
const tsFiles = _listTsFiles(scanDir);
const index = new Map();
for (const tsPath of tsFiles) {
const metaPath = tsPath + '.meta';
if (!fs.existsSync(metaPath)) continue;
let src, meta;
try {
src = fs.readFileSync(tsPath, 'utf8');
meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
} catch (_) {
continue;
}
if (typeof meta.uuid !== 'string') continue;
const names = _extractCcClassNames(src);
if (names.length === 0) continue;
let classId;
try {
classId = compressUuid(meta.uuid);
} catch (_) {
continue;
}
for (const name of names) {
if (!index.has(name)) {
index.set(name, { uuid: meta.uuid, classId, scriptPath: tsPath });
}
}
}
return index;
}
function _getIndex(startPath) {
const projectRoot = _findProjectRoot(startPath);
if (_cache.has(projectRoot)) return _cache.get(projectRoot);
const idx = _buildIndex(projectRoot);
_cache.set(projectRoot, idx);
return idx;
}
/**
* 根据 @ccclass 名字解析对应的压缩 classId。
* @param {string} className 例如 'GMUI'
* @param {string} startPath 项目内任一路径(用于定位 projectRoot
* @returns {string|null} 命中返回 classId;未命中返回 null(调用方决定是否报错)
*/
function resolveClassIdByName(className, startPath) {
const idx = _getIndex(startPath);
const entry = idx.get(className);
return entry ? entry.classId : null;
}
/** 测试/诊断:列出所有 className → classId 对。 */
function listAll(startPath) {
const idx = _getIndex(startPath);
const out = {};
for (const [k, v] of idx.entries()) out[k] = v.classId;
return out;
}
function clearCache() {
_cache.clear();
}
module.exports = { resolveClassIdByName, listAll, clearCache };
+113
View File
@@ -0,0 +1,113 @@
// cli/anim-cmd.js — anim 子命令
//
// .anim 文件与 prefab 同为 JSON 数组 + __id__ 引用格式,editPrefab 可直接复用。
// 本子命令是封装:让用户语义上明确"这是动画文件操作",并允许 query 节点结构。
//
// 用法:
// anim query <anim> [--selector tree|node|find|field] ...
// anim batch <anim> <ops.json> [--dry-run]
//
// 注意:op 库当前面向 cc.Node 树设计,对 .anim 内的 cc.AnimationClip /
// cc.Track / cc.Curve 等结构无专属 op,但通用的 set-component-field(用于
// AnimationClip 顶层)/ set-component-ref / dedupe-component 等仍可用。
// 真正的 anim 曲线编辑应由 src/anim-primitives.js 暴露的 helper 在脚本中处理。
'use strict';
const fs = require('fs');
const path = require('path');
const { editPrefab } = require('../editor/index.js');
const { queryPrefab } = require('../query/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdAnim(args) {
const sub = args[0];
const rest = args.slice(1);
if (!sub || sub === '--help' || sub === '-h') {
process.stdout.write(`anim <subcommand> <file> [args]
Subcommands:
query <anim> [--selector tree|node|find|field] ...
batch <anim> <ops.json> [--project-root <path>] [--dry-run]
注意:.anim 与 .prefab 同为 JSON 数组 + __id__ 引用格式,复用 editPrefab。
op 库当前主要面向 cc.Node 树;编辑动画曲线请用 src/anim-primitives.js
暴露的 helper 在脚本中处理。
`);
return;
}
if (sub === 'query') {
const { flags, positional } = parseFlags(rest);
const animArg = positional[0];
if (!animArg) die('anim query: 必须提供 <anim>');
const animPath = resolvePath(animArg);
if (!fs.existsSync(animPath)) die(`anim query: 文件不存在: ${animPath}`);
const selectorType = flags['selector'] || 'tree';
const withComps = flags['with-comps'] === true;
let selector;
if (selectorType === 'tree') selector = { type: 'tree', withComps };
else if (selectorType === 'node') selector = { type: 'node', name: flags['name'], withComps };
else if (selectorType === 'find') selector = { type: 'find', nodeType: flags['type'] };
else if (selectorType === 'field') selector = {
type: 'field', name: flags['name'], componentType: flags['comp'], field: flags['field'],
};
else die(`anim query: 不支持的 --selector "${selectorType}"`);
let result;
try {
result = queryPrefab(animPath, selector);
} catch (e) {
die('anim query 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return;
}
if (sub === 'batch') {
const { flags, positional } = parseFlags(rest);
const [animArg, opsArg] = positional;
if (!animArg) die('anim batch: 必须提供 <anim>');
if (!opsArg) die('anim batch: 必须提供 <ops.json>');
const animPath = resolvePath(animArg);
const opsPath = resolvePath(opsArg);
if (!fs.existsSync(animPath)) die(`anim batch: 文件不存在: ${animPath}`);
if (!fs.existsSync(opsPath)) die(`anim batch: ops 文件不存在: ${opsPath}`);
let ops;
try {
ops = JSON.parse(fs.readFileSync(opsPath, 'utf8'));
} catch (e) {
die('anim batch: ops.json 解析失败: ' + e.message);
}
if (!Array.isArray(ops)) die('anim batch: ops.json 必须是数组');
const editOptions = {};
if (flags['project-root']) editOptions.projectRoot = resolvePath(flags['project-root']);
if (flags['dry-run'] === true) editOptions.dryRun = true;
let result;
try {
result = editPrefab(animPath, ops, editOptions);
} catch (e) {
die('anim batch 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return;
}
die(`anim: 未知子命令 "${sub}",可用: query / batch`);
}
module.exports = { cmdAnim };
+178
View File
@@ -0,0 +1,178 @@
// cli/batch-cmd.js — batch 子命令
//
// 用法:
// batch <prefab> <ops.json> [--project-root P] [--dry-run]
// batch <ops.json> --glob <pattern> [--project-root P] [--dry-run]
//
// --glob:把第一个位置参数当 ops.json,对所有匹配 pattern 的 prefab 跑同一组 ops
'use strict';
const fs = require('fs');
const path = require('path');
const { editPrefab } = require('../editor/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
// glob 匹配(自实现,支持 ** / * / ?)
// ** 匹配任意层目录(包括 0 层)
// * 匹配一段非斜杠字符
// ? 匹配单字符
function _globToRegex(pattern) {
let re = '^';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*' && pattern[i + 1] === '*') {
// 处理 **/ 和 ** 末尾
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else if (c === '*') {
re += '[^/]*';
i++;
} else if (c === '?') {
re += '[^/]';
i++;
} else if ('.+()|[]{}^$\\'.includes(c)) {
re += '\\' + c;
i++;
} else {
re += c;
i++;
}
}
re += '$';
return new RegExp(re);
}
function _walk(dir, relRoot, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (_) {
return;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
const rel = path.relative(relRoot, full);
if (ent.isDirectory()) {
// 跳过 node_modules / .git 等
if (ent.name === 'node_modules' || ent.name === '.git') continue;
_walk(full, relRoot, out);
} else if (ent.isFile()) {
out.push(rel);
}
}
}
function _expandGlob(pattern) {
// 取 pattern 里第一个含通配符之前的目录段作为扫描根
const cwd = process.cwd();
const segments = pattern.split('/');
const baseSegs = [];
for (const s of segments) {
if (s.includes('*') || s.includes('?')) break;
baseSegs.push(s);
}
const baseRel = baseSegs.join('/');
const baseAbs = baseRel ? path.resolve(cwd, baseRel) : cwd;
if (!fs.existsSync(baseAbs)) return [];
const all = [];
if (fs.statSync(baseAbs).isFile()) {
return [path.resolve(cwd, pattern)];
}
_walk(baseAbs, cwd, all);
const re = _globToRegex(pattern);
return all.filter((rel) => re.test(rel)).map((rel) => path.resolve(cwd, rel));
}
function cmdBatch(args) {
const { flags, positional } = parseFlags(args);
const editOptions = {};
if (flags['project-root']) {
editOptions.projectRoot = resolvePath(flags['project-root']);
}
if (flags['dry-run'] === true) {
editOptions.dryRun = true;
}
// glob 模式:第一个位置参数当 ops.json
if (flags['glob']) {
const opsArg = positional[0];
if (!opsArg) die('batch --glob: 必须提供 <ops.json>');
const opsPath = resolvePath(opsArg);
if (!fs.existsSync(opsPath)) die(`batch --glob: ops 文件不存在: ${opsPath}`);
const ops = _readOps(opsPath);
const matched = _expandGlob(flags['glob']);
if (matched.length === 0) {
die(`batch --glob: pattern "${flags['glob']}" 未匹配任何文件`);
}
const summary = [];
for (const prefabPath of matched) {
try {
const result = editPrefab(prefabPath, ops, editOptions);
summary.push({ file: path.relative(process.cwd(), prefabPath), ...result });
} catch (e) {
summary.push({ file: path.relative(process.cwd(), prefabPath), error: e.message });
}
}
process.stdout.write(JSON.stringify({ matchedCount: matched.length, results: summary }, null, 2) + '\n');
return;
}
// 单文件模式
const [prefabArg, opsArg] = positional;
if (!prefabArg) die('batch: 必须提供 <prefab>');
if (!opsArg) die('batch: 必须提供 <ops.json>');
const prefabPath = resolvePath(prefabArg);
if (!fs.existsSync(prefabPath)) die(`batch: prefab 文件不存在: ${prefabPath}`);
const ops = opsArg === '-'
? _readOpsRaw(fs.readFileSync('/dev/stdin', 'utf8'))
: _readOps(resolvePath(opsArg));
let result;
try {
result = editPrefab(prefabPath, ops, editOptions);
} catch (e) {
die('batch 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
function _readOps(opsPath) {
if (!fs.existsSync(opsPath)) die(`batch: ops 文件不存在: ${opsPath}`);
return _readOpsRaw(fs.readFileSync(opsPath, 'utf8'));
}
function _readOpsRaw(raw) {
let ops;
try {
ops = JSON.parse(raw);
} catch (e) {
die('batch: ops.json 解析失败: ' + e.message);
}
if (!Array.isArray(ops)) die('batch: ops.json 必须是数组');
return ops;
}
module.exports = { cmdBatch };
+141
View File
@@ -0,0 +1,141 @@
// ============================================================
// cli/build-cmd.js — Cocos CLI 命令行打包(headless,不依赖编辑器/MCP
//
// 封装 Cocos Creator 的命令行构建:
// CocosCreator --project <path> --build "configPath=<json>"
// CocosCreator --project <path> --build "platform=<plat>;debug=<bool>"
//
// 退出码:Cocos 退出非 0 常见于 postBuild 阶段的资源警告等非致命问题(主构建其实成功)。
// 默认照产物存在性判定:产物齐了就退 0(成功),产物缺才透传 Cocos 退出码(失败)。
// --strict 关掉这个兜底,直接透传 Cocos 退出码。
//
// 用法:
// cocos-mcp-cli build --project <path> --version 3.8.8 --config <buildConfig.json>
// cocos-mcp-cli build --project <path> --cocos <CocosCreator可执行> --platform web-mobile
// cocos-mcp-cli build ... --dry-run # 只打印命令不真跑
// cocos-mcp-cli build ... --strict # 严格透传 Cocos 退出码(不做产物兜底)
// ============================================================
'use strict';
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
function die(msg) { process.stderr.write('Error: ' + msg + '\n'); process.exit(1); }
function parseArgs(rest) {
const a = { project: '', cocos: '', version: '', config: '', platform: '', debug: false, dryRun: false, strict: false };
for (let i = 0; i < rest.length; i++) {
const k = rest[i];
if (k === '--project' || k === '-p') a.project = rest[++i];
else if (k === '--cocos' || k === '-c') a.cocos = rest[++i];
else if (k === '--version' || k === '-v') a.version = rest[++i];
else if (k === '--config') a.config = rest[++i];
else if (k === '--platform') a.platform = rest[++i];
else if (k === '--debug') a.debug = true;
else if (k === '--dry-run') a.dryRun = true;
else if (k === '--strict') a.strict = true;
else die(`未知参数 "${k}"。用法见 cocos-mcp-cli build --help`);
}
return a;
}
// 解析 CocosCreator 可执行:--cocos 显式优先,否则 --version 拼标准安装路径(macOS
function resolveCocos(a) {
if (a.cocos) {
if (!fs.existsSync(a.cocos)) die(`--cocos 路径不存在: ${a.cocos}`);
return a.cocos;
}
if (a.version) {
const p = `/Applications/Cocos/Creator/${a.version}/CocosCreator.app/Contents/MacOS/CocosCreator`;
if (!fs.existsSync(p)) die(`版本 ${a.version} 不在标准路径: ${p}\n 用 --cocos <可执行绝对路径> 显式指定`);
return p;
}
die('需指定 CocosCreator 可执行:--cocos <path> 或 --version <如 3.8.8>');
}
// 退出非 0 时按产物存在性判定主构建是否成功。true=产物齐 / false=产物缺 / null=无法定位产物目录
function checkArtifact(a) {
let outDir;
if (a.platform) {
// outputName 默认等于 platform;非默认场景请用 --config
outDir = path.join(a.project, 'build', a.platform);
} else if (a.config) {
try {
const cfg = JSON.parse(fs.readFileSync(a.config, 'utf8'));
const bp = String(cfg.buildPath || 'project://build').replace(/^project:\/\//, a.project + path.sep);
const on = cfg.outputName || cfg.platform || '';
outDir = path.join(bp, on);
} catch (e) { return null; }
}
if (!outDir || !fs.existsSync(outDir)) return false;
// 关键产物标志:index.html(web) / game.json(小游戏) / application.js
return ['index.html', 'game.json', 'application.js'].some(function (m) { return fs.existsSync(path.join(outDir, m)); });
}
function cmdBuild(rest) {
if (rest[0] === '--help' || rest[0] === '-h') {
process.stdout.write(
'cocos-mcp-cli build — Cocos 命令行打包(headless\n\n' +
' --project, -p <path> 项目根目录(必填)\n' +
' --version, -v <ver> Cocos 版本(拼标准安装路径,如 3.8.8)\n' +
' --cocos, -c <path> 或直接给 CocosCreator 可执行绝对路径(优先于 --version\n' +
' --config <json> 构建配置文件(→ --build "configPath=<json>"\n' +
' --platform <plat> 或按平台构建(→ --build "platform=<plat>"),如 web-mobile / alipay-mini-game\n' +
' --debug 平台构建时 debug=true(默认 false\n' +
' --dry-run 只打印将执行的命令,不真跑\n' +
' --strict 严格透传 Cocos 退出码(默认会按产物存在性兜底,把 postBuild 非致命的非 0 当成功)\n\n' +
'示例:\n' +
' cocos-mcp-cli build -p /path/to/forest -v 3.8.8 --config /path/build.json\n' +
' cocos-mcp-cli build -p /path/to/forest -v 3.8.8 --platform web-mobile --dry-run\n');
return;
}
const a = parseArgs(rest);
if (!a.project) die('缺 --project <项目路径>');
if (!fs.existsSync(a.project)) die(`项目路径不存在: ${a.project}`);
if (!a.config && !a.platform) die('需指定构建配置:--config <buildConfig.json> 或 --platform <平台>');
const cocos = resolveCocos(a);
let buildArg;
if (a.config) {
if (!fs.existsSync(a.config)) die(`--config 文件不存在: ${a.config}`);
buildArg = `configPath=${a.config}`;
} else {
buildArg = `platform=${a.platform};debug=${a.debug ? 'true' : 'false'}`;
}
const argv = ['--project', a.project, '--build', buildArg];
if (a.dryRun) {
process.stdout.write('[dry-run] 将执行:\n ' + cocos + ' --project ' + a.project + ' --build "' + buildArg + '"\n');
return;
}
process.stdout.write('Cocos CLI build 启动(headless,可能耗时几分钟):\n --project ' + a.project + '\n --build "' + buildArg + '"\n\n');
const child = spawn(cocos, argv, { stdio: 'inherit' });
child.on('error', function (e) { die('启动 Cocos 失败: ' + e.message); });
child.on('exit', function (code) {
if (code === 0) {
process.stdout.write('\nCocos 退出码: 0 — 构建成功\n');
process.exit(0);
}
const arti = a.strict ? null : checkArtifact(a);
if (arti === true) {
process.stdout.write('\nCocos 退出码: ' + code + '(非 0),但产物已生成 → 判定主构建成功(非 0 多为 postBuild 资源警告等非致命问题)。\n');
process.exit(0);
}
if (arti === false) {
process.stdout.write('\nCocos 退出码: ' + code + ',产物未生成 → 构建失败。\n');
process.exit(code == null ? 1 : code);
}
// null--strict 或无法定位产物目录(如 --config 解析失败)→ 透传
process.stdout.write('\nCocos 退出码: ' + (code == null ? '(被信号终止)' : code) +
(a.strict ? '--strict,透传)' : '(无法定位产物目录,透传)') + '\n');
process.exit(code == null ? 1 : code);
});
}
module.exports = { cmdBuild };
+136
View File
@@ -0,0 +1,136 @@
// ============================================================
// compact-cmd.js — 清理 prefab data 数组里的 null 槽位 + 重映射 __id__
//
// 用途:早期某些 prefab(比如 extract-prefab 命令上线前手工生成的)含 null 槽位,
// Cocos editor 反序列化容错跳过,但 build worker 严格 scan 撞 null 崩
// 「Cannot read properties of undefined (reading '__type__')」。
//
// 算法(跟 extract-cmd line 105-132 同款,但不剔除任何东西):
// 1) 收集所有 null 索引
// 2) 构造 oldIdx → newIdx 映射(newIdx = oldIdx - 前面 null 数量)
// 3) newData = data.filter(el => el !== null)
// 4) 递归重映射所有 __id__ 引用
//
// 用法:
// cocos-mcp-cli compact-prefab <prefab> [--dry-run]
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function cmdCompactPrefab(argv) {
let prefabPath = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--dry-run') {
dryRun = true;
} else if (arg.startsWith('--')) {
die(`未知参数 "${arg}"`);
} else if (prefabPath === null) {
prefabPath = arg;
} else {
die(`多余位置参数 "${arg}"`);
}
}
if (!prefabPath) die('需要 <prefab> 路径参数');
compactOne(prefabPath, dryRun);
}
function compactOne(prefabPath, dryRun) {
const abs = path.resolve(process.cwd(), prefabPath);
if (!fs.existsSync(abs)) die(`prefab 不存在: ${prefabPath}`);
const raw = fs.readFileSync(abs, 'utf8');
let data;
try {
data = JSON.parse(raw);
} catch (e) {
die(`JSON 解析失败 (${prefabPath}): ${e.message}`);
}
if (!Array.isArray(data)) die(`不是 prefab 数据数组: ${prefabPath}`);
// 1) 收集 null 索引
const nullIdxs = [];
data.forEach((el, i) => { if (el === null) nullIdxs.push(i); });
if (nullIdxs.length === 0) {
process.stdout.write(`${prefabPath} → 无 null 槽位,无需 compact\n`);
return;
}
// 2) 构造 oldIdx → newIdx 映射
const oldToNew = new Map();
let newIdx = 0;
for (let i = 0; i < data.length; i++) {
if (data[i] !== null) {
oldToNew.set(i, newIdx);
newIdx++;
}
}
// 3) 新数组
const newData = data.filter((el) => el !== null);
// 4) 重映射 __id__;跟踪指向已删 null 的 dangling 引用
let danglingRefs = 0;
const danglingDetails = [];
for (let i = 0; i < newData.length; i++) {
_remapIds(newData[i], oldToNew, '[' + i + ']', danglingDetails);
}
danglingRefs = danglingDetails.length;
// 5) Dry-run / Apply
const summary = `${prefabPath}${data.length}${newData.length} (清掉 ${nullIdxs.length} 个 null)`;
process.stdout.write(summary + (dryRun ? ' [dry-run]' : '') + '\n');
if (nullIdxs.length <= 50) {
process.stdout.write(' 原 null 索引: ' + nullIdxs.join(',') + '\n');
} else {
process.stdout.write(' 原 null 索引(前 20: ' + nullIdxs.slice(0, 20).join(',') + ' ... (共 ' + nullIdxs.length + ')\n');
}
if (danglingRefs > 0) {
process.stderr.write(`${danglingRefs} 个 __id__ 引用原本指向 null 槽位(已置 null):\n`);
danglingDetails.slice(0, 5).forEach((d) => process.stderr.write(' ' + d + '\n'));
if (danglingDetails.length > 5) process.stderr.write(` ... 共 ${danglingDetails.length}\n`);
}
if (dryRun) return;
fs.writeFileSync(abs, JSON.stringify(newData, null, 2) + '\n', 'utf8');
process.stdout.write(` ✓ 写入 ${prefabPath}\n`);
}
function _remapIds(obj, map, location, dangling) {
if (obj === null || obj === undefined) return;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) _remapIds(obj[i], map, location + '[' + i + ']', dangling);
return;
}
if (typeof obj === 'object') {
if (typeof obj.__id__ === 'number') {
const oldId = obj.__id__;
const newId = map.get(oldId);
if (newId === undefined) {
// 引用指向 null 槽位 —— 把 __id__ 置 null
obj.__id__ = null;
dangling.push(location + ' → __id__:' + oldId);
} else {
obj.__id__ = newId;
}
return;
}
for (const k of Object.keys(obj)) _remapIds(obj[k], map, location + '.' + k, dangling);
}
}
module.exports = { cmdCompactPrefab };
+160
View File
@@ -0,0 +1,160 @@
// ============================================================
// cli/create-cmd.js — create-prefab 子命令
//
// 用法:
// cocos-mcp-cli create-prefab <output-path>
// [--name <name>] [--width <w>] [--height <h>]
// [--add-spine <skel-uuid>] [--dry-run]
//
// 生成最小 prefabroot 节点 + UITransform+ 配套 .prefab.meta。
//
// 加 --add-spine <uuid> 时,在 root 节点上多挂一个 sp.Skeleton 组件,
// _skeletonData.__uuid__ 指向给定的 .skel 资产 UUID。批量生成 spine prefab
// 推荐外层 shell 循环 + N 次调用:
//
// for meta in assets/res/<group>/<xxxN>/*.skel.meta; do
// name=$(basename "$meta" .skel.meta)
// uuid=$(node -e 'console.log(require("./"+process.argv[1]).uuid)' "$meta")
// node bin/cocos-mcp-cli.js create-prefab \
// "assets/packages/<group>/<xxxN>/prefab/${name}.prefab" --add-spine "$uuid"
// done
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { deterministicUUID, deterministicFileId } = require('../id.js');
const {
makePrefabRoot,
makeNode,
makeUITransform,
makePrefabInfo,
makeCompPrefabInfo,
makeSpSkeleton,
} = require('../primitives.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function cmdCreatePrefab(argv) {
let outputPath = null;
let name = null;
let width = null;
let height = null;
let dryRun = false;
let spineSkelUuid = null;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--name') {
name = argv[++i];
if (!name) die('--name 需要一个值');
} else if (arg === '--width') {
width = Number(argv[++i]);
if (isNaN(width)) die('--width 必须是数字');
} else if (arg === '--height') {
height = Number(argv[++i]);
if (isNaN(height)) die('--height 必须是数字');
} else if (arg === '--add-spine') {
spineSkelUuid = argv[++i];
if (!spineSkelUuid) die('--add-spine 需要一个 .skel 资产 UUID');
} else if (arg === '--dry-run') {
dryRun = true;
} else if (!arg.startsWith('--')) {
if (outputPath !== null) die('多余的位置参数: ' + arg);
outputPath = arg;
} else {
die(`未知参数 "${arg}"`);
}
}
if (!outputPath) {
die('用法: create-prefab <output-path> [--name <name>] [--width <w>] [--height <h>] [--add-spine <skel-uuid>] [--dry-run]');
}
// 确保 .prefab 后缀
if (!outputPath.endsWith('.prefab')) outputPath += '.prefab';
// 推断 prefab 名称(basename 去掉 .prefab
if (!name) name = path.basename(outputPath, '.prefab');
// 默认 UITransform 尺寸:spine prefab 走 100×100sp.Skeleton 自己控制渲染,
// contentSize 不影响运行时;与现有 sk_loading.prefab 等产物对齐),
// 普通 UI prefab 走 750×200
if (width === null) width = spineSkelUuid ? 100 : 750;
if (height === null) height = spineSkelUuid ? 100 : 200;
// 确定性 ID(以名称为种子,保证同名 prefab 每次生成相同 UUID
const seed = `create-prefab:${name}`;
const prefabUuid = deterministicUUID(`${seed}:uuid`);
const rootFileId = deterministicFileId(`${seed}:root:fid`);
const uitransformFileId = deterministicFileId(`${seed}:uitransform:fid`);
// 索引分配 — 不带 spine(5 条):
// 0 cc.Prefab
// 1 cc.Node (root)
// 2 cc.UITransform
// 3 cc.CompPrefabInfo (UITransform 的)
// 4 cc.PrefabInfo (root 的)
// 带 spine7 条):
// 0 cc.Prefab
// 1 cc.Node (root, _components: [2, 4])
// 2 cc.UITransform
// 3 cc.CompPrefabInfo (UITransform 的)
// 4 sp.Skeleton
// 5 cc.CompPrefabInfo (sp.Skeleton 的)
// 6 cc.PrefabInfo (root 的)
let data;
if (spineSkelUuid) {
const spineFileId = deterministicFileId(`${seed}:sp.Skeleton:fid`);
data = [
makePrefabRoot({ name, rootId: 1 }),
makeNode({ name, componentIds: [2, 4], prefabId: 6 }),
makeUITransform({ nodeId: 1, width, height, prefabInfoId: 3 }),
makeCompPrefabInfo(uitransformFileId),
makeSpSkeleton({ nodeId: 1, skeletonUuid: spineSkelUuid, prefabInfoId: 5 }),
makeCompPrefabInfo(spineFileId),
makePrefabInfo({ rootId: 1, fileId: rootFileId, assetId: 0 }),
];
} else {
data = [
makePrefabRoot({ name, rootId: 1 }),
makeNode({ name, componentIds: [2], prefabId: 4 }),
makeUITransform({ nodeId: 1, width, height, prefabInfoId: 3 }),
makeCompPrefabInfo(uitransformFileId),
makePrefabInfo({ rootId: 1, fileId: rootFileId, assetId: 0 }),
];
}
const meta = {
ver: '1.1.50',
importer: 'prefab',
imported: true,
uuid: prefabUuid,
files: ['.json'],
subMetas: {},
userData: { syncNodeName: name },
};
if (dryRun) {
process.stdout.write('=== PREFAB ===\n');
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
process.stdout.write('\n=== META ===\n');
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
return;
}
const dir = path.dirname(outputPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.writeFileSync(outputPath + '.meta', JSON.stringify(meta, null, 2) + '\n', 'utf8');
process.stdout.write(`created: ${outputPath}\n`);
process.stdout.write(`created: ${outputPath}.meta\n`);
}
module.exports = { cmdCreatePrefab };
+60
View File
@@ -0,0 +1,60 @@
// cli/diff-cmd.js — diff 子命令(比较两个 prefab 的字段级差异)
//
// 用法:
// diff <prefabA> <prefabB>
//
// 输出与 batch --dry-run 同格式:
// { diff: [{ id, type, name, changes: { 'a.b.c': [oldVal, newVal] } }] }
//
// 适用于:CI 验证转换工具产物 / 对照历史版本 / review 自动 diff
'use strict';
const fs = require('fs');
const path = require('path');
const { parsePrefab } = require('../parse.js');
const { computeDiff } = require('../editor/diff.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdDiff(args) {
const { positional } = parseFlags(args);
const [a, b] = positional;
if (!a) die('diff: 必须提供 <prefabA>');
if (!b) die('diff: 必须提供 <prefabB>');
const aPath = resolvePath(a);
const bPath = resolvePath(b);
if (!fs.existsSync(aPath)) die(`diff: 文件不存在: ${aPath}`);
if (!fs.existsSync(bPath)) die(`diff: 文件不存在: ${bPath}`);
let aData;
let bData;
try {
aData = parsePrefab(aPath);
bData = parsePrefab(bPath);
} catch (e) {
die('diff: 解析失败: ' + e.message);
}
const diff = computeDiff(aData.elements, bData.elements);
const result = {
a: path.relative(process.cwd(), aPath),
b: path.relative(process.cwd(), bPath),
elementsA: aData.elements.length,
elementsB: bData.elements.length,
changedCount: diff.length,
diff,
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
module.exports = { cmdDiff };
+99
View File
@@ -0,0 +1,99 @@
// ============================================================
// cli/ensure-meta-cmd.js — ensure-meta 子命令
//
// 用法:
// cocos-mcp-cli ensure-meta <file-path> [--dry-run]
//
// 若目标文件已有同名 .meta → 跳过(幂等)。
// 若没有 .meta → 按扩展名生成对应格式的 .meta 文件。
//
// 支持的文件类型:
// .ts / .js → typescript importerver 4.0.24
// .json → json importerver 2.0.1
//
// 典型场景:新建脚本后 Cocos 编辑器未就绪时,先手动补 .meta。
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { randomUUID } = require('crypto');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function makeMeta(ext, uuid) {
if (ext === '.ts' || ext === '.js') {
return {
ver: '4.0.24',
importer: 'typescript',
imported: true,
uuid,
files: [],
subMetas: {},
userData: {},
};
}
if (ext === '.json') {
return {
ver: '2.0.1',
importer: 'json',
imported: true,
uuid,
files: ['.json'],
subMetas: {},
userData: {},
};
}
return null;
}
function cmdEnsureMeta(argv) {
let filePath = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--dry-run') {
dryRun = true;
} else if (!arg.startsWith('--')) {
if (filePath !== null) die('多余的位置参数: ' + arg);
filePath = arg;
} else {
die(`未知参数 "${arg}"`);
}
}
if (!filePath) {
die('用法: ensure-meta <file-path> [--dry-run]\n支持类型: .ts .js .json');
}
if (!fs.existsSync(filePath)) {
die(`文件不存在: ${filePath}`);
}
const metaPath = filePath + '.meta';
if (fs.existsSync(metaPath)) {
process.stdout.write(`skip (already exists): ${metaPath}\n`);
return;
}
const ext = path.extname(filePath).toLowerCase();
const meta = makeMeta(ext, randomUUID());
if (!meta) {
die(`不支持的文件类型 "${ext}",支持: .ts .js .json`);
}
if (dryRun) {
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
return;
}
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf8');
process.stdout.write(`created: ${metaPath}\n`);
}
module.exports = { cmdEnsureMeta };
+238
View File
@@ -0,0 +1,238 @@
// ============================================================
// cli/extract-cmd.js — extract-prefab 子命令
//
// 用法:
// cocos-mcp-cli extract-prefab <src-prefab> <out-prefab>
// --node <selector> [--name <new-name>] [--dry-run]
//
// 把 src-prefab 中某个子节点连同其整棵子树(含组件 / PrefabInfo /
// 嵌套 PrefabInstance / propertyOverrides / TargetInfo / mountedComponents
// 等所有 __id__ 引用闭包)提取出来,构造一个独立的新 prefab + .meta。
//
// 跟 batch op clone-node 的区别:
// - clone-node 在同 prefab 内复制 + 挂到 parent
// - extract-prefab 写出到新 .prefab 文件(含 cc.Prefab 头),脱离源文件
//
// 典型场景:把 HomeBottom 上的 btnTask 子树提取成独立的 task BottomEntry.prefab。
//
// selector 接受 batch 同款三种形式:
// "btnTask"
// { "id": 13 }
// { "path": "btnTask" }
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { deterministicUUID } = require('../id.js');
const { parsePrefab } = require('../parse.js');
const { resolveNode } = require('../editor/helpers.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function parseSelector(raw) {
// 支持 --node btnTask 或 --node '{"id":13}'
const t = raw.trim();
if (t.startsWith('{')) {
try { return JSON.parse(t); } catch (e) {
die(`--node JSON 解析失败: ${t} (${e.message})`);
}
}
return t;
}
function cmdExtractPrefab(argv) {
let srcPath = null;
let outPath = null;
let nodeSelector = null;
let newName = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--node') {
nodeSelector = parseSelector(argv[++i] ?? '');
if (!nodeSelector) die('--node 需要一个值');
} else if (arg === '--name') {
newName = argv[++i];
if (!newName) die('--name 需要一个值');
} else if (arg === '--dry-run') {
dryRun = true;
} else if (!arg.startsWith('--')) {
if (srcPath === null) srcPath = arg;
else if (outPath === null) outPath = arg;
else die('多余的位置参数: ' + arg);
} else {
die(`未知参数 "${arg}"`);
}
}
if (!srcPath || !outPath) {
die('用法: extract-prefab <src-prefab> <out-prefab> --node <selector> [--name <new-name>] [--dry-run]');
}
if (nodeSelector === null) die('--node 是必需参数');
const srcAbs = path.resolve(process.cwd(), srcPath);
if (!fs.existsSync(srcAbs)) die(`源 prefab 不存在: ${srcPath}`);
// 确保 .prefab 后缀
if (!outPath.endsWith('.prefab')) outPath += '.prefab';
if (!newName) newName = path.basename(outPath, '.prefab');
// 1) 解析源 prefab
const prefabData = parsePrefab(srcAbs);
const { elements } = prefabData;
const { nodeId: srcNodeId } = resolveNode(prefabData, nodeSelector, 'extract-prefab');
// 2) BFS 闭包收集:从 srcNodeId 出发,把所有递归引用的 __id__ 拉进来
const collected = new Set();
const queue = [srcNodeId];
while (queue.length > 0) {
const idx = queue.shift();
if (collected.has(idx)) continue;
collected.add(idx);
const refs = [];
_walkCollect(elements[idx], refs);
for (const r of refs) {
if (!collected.has(r)) queue.push(r);
}
}
// 3) 重新编号:new[0] = 新 cc.Prefab 头部,new[1] = srcNoderoot),其余按原 idx 升序
const oldToNew = new Map();
const sortedOld = [srcNodeId, ...[...collected].filter((i) => i !== srcNodeId).sort((a, b) => a - b)];
const newData = [];
// new[0]: 复制源 prefab 头部模板(只用 __type__ / data / optimizationPolicy / persistent 等基础字段)
const srcHead = elements[0] && elements[0].__type__ === 'cc.Prefab' ? elements[0] : null;
const newHead = {
__type__: 'cc.Prefab',
_name: '',
_objFlags: 0,
__editorExtras__: {},
_native: '',
data: { __id__: 1 },
optimizationPolicy: srcHead && srcHead.optimizationPolicy !== undefined ? srcHead.optimizationPolicy : 0,
persistent: srcHead && srcHead.persistent !== undefined ? srcHead.persistent : false,
};
newData.push(newHead);
for (let i = 0; i < sortedOld.length; i++) {
oldToNew.set(sortedOld[i], i + 1);
newData.push(_deepClone(elements[sortedOld[i]]));
}
// 4) Remap __id__ 引用
for (let i = 1; i < newData.length; i++) {
_remapIds(newData[i], oldToNew);
}
// 5) 修正根节点:_parent=null, _name=newName
const newRoot = newData[1];
newRoot._parent = null;
newRoot._name = newName;
// 6) 修正根节点 _prefabPrefabInfo):root 指向新根 idx 1asset 指向 idx 0
if (newRoot._prefab && typeof newRoot._prefab.__id__ === 'number') {
const rootPInfo = newData[newRoot._prefab.__id__];
if (rootPInfo && rootPInfo.__type__ === 'cc.PrefabInfo') {
rootPInfo.root = { __id__: 1 };
rootPInfo.asset = { __id__: 0 };
// 这些字段在源 prefab 是相对宿主 prefab 的,新 prefab 是独立的,清掉
if ('instance' in rootPInfo) rootPInfo.instance = null;
if ('targetOverrides' in rootPInfo) rootPInfo.targetOverrides = null;
if ('nestedPrefabInstanceRoots' in rootPInfo) rootPInfo.nestedPrefabInstanceRoots = null;
}
}
// 7) 生成 meta
const seed = `extract-prefab:${outPath}:${newName}`;
const newUuid = deterministicUUID(`${seed}:uuid`);
const meta = {
ver: '1.1.50',
importer: 'prefab',
imported: true,
uuid: newUuid,
files: ['.json'],
subMetas: {},
userData: { syncNodeName: newName },
};
if (dryRun) {
process.stdout.write('=== PREFAB ===\n');
process.stdout.write(JSON.stringify(newData, null, 2) + '\n');
process.stdout.write('\n=== META ===\n');
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
process.stdout.write(`\n=== STATS ===\ncollected ${collected.size} objects from source idx ${srcNodeId}\n`);
return;
}
const outAbs = path.resolve(process.cwd(), outPath);
const dir = path.dirname(outAbs);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(outAbs, JSON.stringify(newData, null, 2) + '\n', 'utf8');
fs.writeFileSync(outAbs + '.meta', JSON.stringify(meta, null, 2) + '\n', 'utf8');
process.stdout.write(`created: ${outPath} (${collected.size} objects)\n`);
process.stdout.write(`created: ${outPath}.meta\n`);
}
// ── internals ────────────────────────────────────────────
// 跳过的字段:_parent 反向引用会把父链/兄弟子树拖进闭包,破坏"只提取子树"的语义
const SKIP_KEYS = new Set(['_parent']);
function _walkCollect(obj, out) {
if (obj === null || obj === undefined) return;
if (Array.isArray(obj)) {
for (const v of obj) _walkCollect(v, out);
return;
}
if (typeof obj === 'object') {
if (typeof obj.__id__ === 'number') {
out.push(obj.__id__);
return;
}
for (const k of Object.keys(obj)) {
if (SKIP_KEYS.has(k)) continue;
_walkCollect(obj[k], out);
}
}
}
function _deepClone(obj) {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(_deepClone);
const out = {};
for (const k of Object.keys(obj)) out[k] = _deepClone(obj[k]);
return out;
}
function _remapIds(obj, map) {
if (obj === null || obj === undefined) return;
if (Array.isArray(obj)) {
for (const v of obj) _remapIds(v, map);
return;
}
if (typeof obj === 'object') {
if (typeof obj.__id__ === 'number') {
const newId = map.get(obj.__id__);
if (newId !== undefined) {
obj.__id__ = newId;
} else {
// 引用集合外的 idx —— 闭包应该完整,理论不会发生
obj.__id__ = null;
}
return;
}
for (const k of Object.keys(obj)) _remapIds(obj[k], map);
}
}
module.exports = { cmdExtractPrefab };
+29
View File
@@ -0,0 +1,29 @@
// cli/flags.js — argv 中提取 --key value 形式的选项
'use strict';
function parseFlags(argv) {
const flags = {};
const positional = [];
let i = 0;
while (i < argv.length) {
const arg = argv[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[key] = next;
i += 2;
} else {
flags[key] = true;
i += 1;
}
} else {
positional.push(arg);
i += 1;
}
}
return { flags, positional };
}
module.exports = { parseFlags };
+87
View File
@@ -0,0 +1,87 @@
// cli/help.js — 帮助信息
'use strict';
function printHelp() {
process.stdout.write(`cocos-mcp-cli — CC3 prefab 离线编辑工具
Usage:
cocos-mcp-cli query <prefab> [--selector tree|node|find|field] [--name X] [--type cc.Label]
[--comp cc.UITransform] [--field _anchorPoint] [--with-comps]
cocos-mcp-cli set <prefab> <nodeName> <field> <value>
cocos-mcp-cli batch <prefab> <ops.json> [--project-root <projectRoot>] [--dry-run]
cocos-mcp-cli batch <ops.json> --glob <pattern> [--project-root <path>] [--dry-run]
cocos-mcp-cli anim <subcommand> <file> [args] # subcommand: query | batch
cocos-mcp-cli diff <prefabA> <prefabB> # 字段级 diff
cocos-mcp-cli create-prefab <out> [--name X] [--width W] [--height H] [--add-spine <uuid>]
cocos-mcp-cli extract-prefab <src> <out> --node <selector> [--name X] [--dry-run]
Commands:
query 只读查询,输出 JSON
set 单条属性写入(field: active / label.text / position.x|y|z
batch 批量写入,ops.json 是 editPrefab ops 数组;--glob 跨多个 prefab
anim 操作 .anim 文件(与 prefab 同格式)
diff 比较两个 prefab 的字段级差异
create-prefab 创建空白 prefabroot + UITransform,可选 sp.Skeleton
extract-prefab 把 src 中的某个子节点闭包提取为独立 prefab(含组件 / PrefabInfo /
嵌套 PrefabInstance / overrides 的全部 __id__ 引用)
Query options:
--selector tree 节点树(默认)
--selector node 单节点详情,需要 --name <节点名>
--selector find 按 __type__ 列 id,需要 --type <类型>
--selector field 单组件单字段值,需要 --name --comp --field
--with-comps tree/node 下展开组件字段(输出 components: [{type,id,fields}]
Batch options:
--project-root <path> 指定含 assets/+package.json 的项目根;
当 prefab 放在 /tmp/ 等非项目目录时必须显式传入,
否则 className → classId 自动规范化会抛错(避免写入 className 字符串导致 cocos MissingScript)。
--dry-run 不写盘,输出会改的字段 diff{ path: [old, new] } 形式)。
Supported ops:
set-position / set-label-text / set-sprite-frame / set-active / rename-node
set-component-field # 普通节点改任意组件任意字段,property 接字符串或嵌套路径数组
set-component-enabled # 改 _enabled
set-anchor / set-size # cc.UITransform 锚点 / 尺寸便捷写法
# set-anchor 支持 compensatePosition 自动补偿 lpos
adjust-position # lpos 相对偏移
reorder-children # 调子节点顺序(影响渲染层级)
bulk-set # 按 selectorbyComponent/byNamePrefix/byNameRegex)一次改一批
add-node / remove-node / clone-node
add-component / set-component-ref # componentType 支持 @ccclass 名或压缩 classId
# set-component-ref 的 refSubNode 可用字符串数组走多层嵌套 stub
set-nested-component-field # 仅 stub 节点(嵌套 prefab)改组件字段
dedupe-component # 合并同节点重复组件
ensure-meta # 给新建 .ts/.json 创建 .metav4 uuid),
# 避免等 cocos 编辑器生成;放在 add-component 前即可
# 联动(同 batch 内 cache invalidate 重扫)
Component shortcuts(组件快捷 op,多字段一次设置):
set-editbox node + inputMode?/maxLength?/placeholder?/string?/inputFlag?/fontSize?
inputMode: 0=ANY 1=EMAIL 2=NUMERIC 3=PHONE 4=URL 5=DECIMAL 6=SINGLE_LINE
set-label node + text?/fontSize?/lineHeight?/overflow?/horizontalAlign?/verticalAlign?/bold?/italic?/underline?/enableWrapText?
overflow: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE
set-button node + interactable?/transition?/zoomScale?/duration?
transition: 0=NONE 1=COLOR 2=SPRITE 3=SCALE
set-layout node + type?/resizeMode?/paddingLeft?/paddingRight?/paddingTop?/paddingBottom?/spacingX?/spacingY?/startAxis?/constraint?/constraintNum?/affectedByScale?
type: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID
set-richtext node + text?/maxWidth?/fontSize?/lineHeight?
set-sprite node + sizeMode?/type?/grayscale?/trim?(换图用 set-sprite-frame
type: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH
set-node-color node + r?/g?/b?/a?0-255
节点定位三种形式(适用所有 op 的 node/parent/target/source/refNode):
"name" / { id: N } / { path: "Canvas/Main/itemList" }
Examples:
cocos-mcp-cli query HomeUI.prefab --selector tree --with-comps
cocos-mcp-cli query HomeUI.prefab --selector field --name itemList \\
--comp cc.UITransform --field _anchorPoint
cocos-mcp-cli set HomeUI.prefab btnClose label.text "关闭"
cocos-mcp-cli batch HomeUI.prefab ops.json
cocos-mcp-cli batch HomeUI.prefab ops.json --dry-run
`);
}
module.exports = { printHelp };
+63
View File
@@ -0,0 +1,63 @@
// ============================================================
// cli/main.js — 子命令分发入口
//
// 用法:
// cocos-mcp-cli query <prefab> [--selector ...] [--with-comps] ...
// cocos-mcp-cli set <prefab> <nodeName> <field> <value>
// cocos-mcp-cli batch <prefab> <ops.json> [--project-root ...] [--dry-run]
// ============================================================
'use strict';
const { printHelp } = require('./help.js');
const { cmdQuery } = require('./query-cmd.js');
const { cmdSet } = require('./set-cmd.js');
const { cmdBatch } = require('./batch-cmd.js');
const { cmdAnim } = require('./anim-cmd.js');
const { cmdDiff } = require('./diff-cmd.js');
const { cmdCreatePrefab } = require('./create-cmd.js');
const { cmdExtractPrefab } = require('./extract-cmd.js');
const { cmdCompactPrefab } = require('./compact-cmd.js');
const { cmdEnsureMeta } = require('./ensure-meta-cmd.js');
const { cmdBuild } = require('./build-cmd.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function main(argv) {
const cmd = argv[0];
const rest = argv.slice(1);
if (!cmd || cmd === '--help' || cmd === '-h') {
printHelp();
return;
}
if (cmd === 'query') {
cmdQuery(rest);
} else if (cmd === 'set') {
cmdSet(rest);
} else if (cmd === 'batch') {
cmdBatch(rest);
} else if (cmd === 'anim') {
cmdAnim(rest);
} else if (cmd === 'diff') {
cmdDiff(rest);
} else if (cmd === 'create-prefab') {
cmdCreatePrefab(rest);
} else if (cmd === 'extract-prefab') {
cmdExtractPrefab(rest);
} else if (cmd === 'compact-prefab') {
cmdCompactPrefab(rest);
} else if (cmd === 'ensure-meta') {
cmdEnsureMeta(rest);
} else if (cmd === 'build') {
cmdBuild(rest);
} else {
die(`未知子命令 "${cmd}",可用: query / set / batch / anim / diff / create-prefab / extract-prefab / compact-prefab / ensure-meta / build`);
}
}
module.exports = { main };
+81
View File
@@ -0,0 +1,81 @@
// cli/query-cmd.js — query 子命令
'use strict';
const fs = require('fs');
const path = require('path');
const { queryPrefab } = require('../query/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdQuery(args) {
const { flags, positional } = parseFlags(args);
const prefabArg = positional[0];
if (!prefabArg) die('query: 必须提供 <prefab> 路径');
const prefabPath = resolvePath(prefabArg);
if (!fs.existsSync(prefabPath)) die(`query: 文件不存在: ${prefabPath}`);
const selectorType = flags['selector'] || 'tree';
const withComps = flags['with-comps'] === true;
let selector;
if (selectorType === 'tree') {
selector = { type: 'tree', withComps };
} else if (selectorType === 'node') {
const name = flags['name'];
if (!name) die('query --selector node: 必须提供 --name <节点名>');
selector = { type: 'node', name, withComps };
} else if (selectorType === 'find') {
const nodeType = flags['type'];
if (!nodeType) die('query --selector find: 必须提供 --type <组件类型>');
selector = { type: 'find', nodeType };
} else if (selectorType === 'field') {
const name = flags['name'];
const compType = flags['comp'];
const field = flags['field'];
if (!name) die('query --selector field: 必须提供 --name <节点名>');
if (!compType) die('query --selector field: 必须提供 --comp <组件类型>');
if (!field) die('query --selector field: 必须提供 --field <字段名>');
selector = { type: 'field', name, componentType: compType, field };
} else if (selectorType === 'overrides') {
// --node 支持 name / --id N / --path A/B/C 三种
let nodeSel;
if (flags['id'] !== undefined) {
const idNum = Number(flags['id']);
if (!Number.isInteger(idNum) || idNum < 0) die('query --selector overrides: --id 必须是非负整数');
nodeSel = { id: idNum };
} else if (typeof flags['path'] === 'string' && flags['path'].length > 0) {
nodeSel = { path: flags['path'] };
} else if (typeof flags['node'] === 'string' && flags['node'].length > 0) {
nodeSel = flags['node'];
} else if (typeof flags['name'] === 'string' && flags['name'].length > 0) {
nodeSel = flags['name'];
} else {
die('query --selector overrides: 必须提供 --id N 或 --path A/B/C 或 --name <name>');
}
selector = { type: 'overrides', node: nodeSel };
} else {
die(`query: 不支持的 --selector 值 "${selectorType}",可选: tree / node / find / field / overrides`);
}
let result;
try {
result = queryPrefab(prefabPath, selector);
} catch (e) {
die('query 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
module.exports = { cmdQuery };
+79
View File
@@ -0,0 +1,79 @@
// cli/set-cmd.js — set 子命令(单字段快捷写入)
//
// field 支持:
// active → set-active (true/false)
// label.text → set-label-text
// position.x|y|z → set-position(只改一轴,其余保留)
'use strict';
const fs = require('fs');
const path = require('path');
const { parsePrefab } = require('../parse.js');
const { editPrefab } = require('../editor/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdSet(args) {
const { positional } = parseFlags(args);
const [prefabArg, nodeName, field, rawValue] = positional;
if (!prefabArg) die('set: 必须提供 <prefab>');
if (!nodeName) die('set: 必须提供 <nodeName>');
if (!field) die('set: 必须提供 <field>');
if (rawValue === undefined) die('set: 必须提供 <value>');
const prefabPath = resolvePath(prefabArg);
if (!fs.existsSync(prefabPath)) die(`set: 文件不存在: ${prefabPath}`);
let op;
if (field === 'active') {
if (rawValue !== 'true' && rawValue !== 'false') {
die('set active: value 必须是 true 或 false');
}
op = { op: 'set-active', node: nodeName, active: rawValue === 'true' };
} else if (field === 'label.text') {
op = { op: 'set-label-text', node: nodeName, text: rawValue };
} else if (field === 'position.x' || field === 'position.y' || field === 'position.z') {
const axis = field.split('.')[1];
const num = parseFloat(rawValue);
if (isNaN(num)) die(`set ${field}: value 必须是数字`);
const prefabData = parsePrefab(prefabPath);
const node = prefabData.findNodeByName(nodeName);
if (!node) die(`set: 找不到节点 "${nodeName}"`);
const lpos = node._lpos || { x: 0, y: 0, z: 0 };
const newPos = { x: lpos.x || 0, y: lpos.y || 0, z: lpos.z || 0 };
newPos[axis] = num;
op = { op: 'set-position', node: nodeName, x: newPos.x, y: newPos.y, z: newPos.z };
} else {
die(
`set: 不支持的 field "${field}",支持:\n` +
' active\n' +
' label.text\n' +
' position.x / position.y / position.z'
);
}
let result;
try {
result = editPrefab(prefabPath, [op]);
} catch (e) {
die('set 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
module.exports = { cmdSet };
+59
View File
@@ -0,0 +1,59 @@
// ============================================================
// editor/diff.js — dry-run 用的 elements 字段级 diff
// 输出:[{ id, type, name, changes: { 'a.b.c': [old, new] } }]
// ============================================================
'use strict';
function computeDiff(before, after) {
const out = [];
const maxLen = Math.max(before.length, after.length);
for (let i = 0; i < maxLen; i++) {
const a = before[i];
const b = after[i];
if (a === undefined && b !== undefined) {
out.push({ id: i, type: 'added', after: b });
continue;
}
if (a !== undefined && b === undefined) {
out.push({ id: i, type: 'removed', before: a });
continue;
}
const changes = {};
diffObject(a, b, '', changes);
if (Object.keys(changes).length > 0) {
out.push({
id: i,
type: b && b.__type__ ? b.__type__ : null,
name: b && b._name !== undefined ? b._name : undefined,
changes,
});
}
}
return out;
}
function diffObject(a, b, prefix, out) {
if (a === b) return;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
out[prefix || '<root>'] = [a, b];
return;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
const av = a[k];
const bv = b[k];
if (av === bv) continue;
const path = prefix ? `${prefix}.${k}` : k;
if (
typeof av === 'object' && av !== null &&
typeof bv === 'object' && bv !== null
) {
diffObject(av, bv, path, out);
} else {
out[path] = [av, bv];
}
}
}
module.exports = { computeDiff };
+206
View File
@@ -0,0 +1,206 @@
// ============================================================
// editor/helpers.js — 节点定位 / 组件查找 / 类型规范化
// 所有 op handler 共用的低层工具
// ============================================================
'use strict';
const { isCompressedClassId, compressUuid } = require('../id.js');
const { resolveClassIdByName } = require('../classid-resolver.js');
// ─── componentType 规范化 ────────────────────────────────────
//
// cli 允许 op 里用以下三种形式传 componentType
// 1. @ccclass 名(如 'MyUI'
// 2. 原始 UUID(如 '5a154a84-89a1-509a-8949-96edd6fb74a2'
// 3. 压缩 classId23 字符,已规范化格式,如 '5a154qEiaFQmolJlu3W+3Si'
//
// 但 Cocos 编辑器序列化 prefab 时会把 __type__ 规范化为压缩 classId。
// 为避免「写入字符串名/原始 UUID → 编辑器 reimport 后规范化 + 清空 refs」的坑,
// 在每个 op 的 handler 开头把 componentType 统一转成压缩 classId。
//
// 规则:
// - 空/非字符串:原样返回(让 handler 各自报参数错)
// - 以 'cc.' / 'sp.' / 'dragonBones.' 开头:引擎类,不可能是 className,原样
// - 已经是 23 字符压缩格式:原样
// - 原始 UUID 格式(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx):压缩为 classId
// - 其他(视作 @ccclass 名):扫 assets/scripts 反查 → 压缩 classId
// 找不到时**直接抛错**cocos 反序列化看到 className 字符串会报 MissingScript
// 与其降级写入留个坑不如让 cli 当场失败,告诉调用方真实原因(meta 未生成 / class 名拼错 / 没加 @ccclass)。
//
// 这确保 add-component / set-component-ref / remove-component 无论传哪种形式
// 都能 lookup 到同一 __type__ 字符串,避免同 batch 内 add+ref 类型不一致。
const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function normalizeComponentType(componentType, resolverStartPath) {
if (typeof componentType !== 'string' || componentType.length === 0) {
return componentType;
}
if (/^(cc|sp|dragonBones)\./.test(componentType)) return componentType;
if (isCompressedClassId(componentType)) return componentType;
// 原始 UUID 格式 → 压缩为 classId,与 @ccclass 名查表结果统一
if (_UUID_RE.test(componentType)) {
try { return compressUuid(componentType); } catch (_) {}
}
// 视作 @ccclass 名:必须查表得到压缩 classId,否则写入会造成 cocos MissingScript
if (!resolverStartPath) {
throw new Error(
`normalizeComponentType: className "${componentType}" 无法解析——缺少 prefab 路径用于定位项目根。` +
`\n 通常因 prefab 在项目目录外(如 /tmp/)时未传 --project-root。`
);
}
const classId = resolveClassIdByName(componentType, resolverStartPath);
if (!classId) {
throw new Error(
`normalizeComponentType: className "${componentType}" 在 assets/scripts 下找不到对应 .ts.meta。` +
`\n 常见原因:` +
`\n 1) .ts 文件刚新建,cocos 编辑器尚未生成 .ts.meta(等编辑器自动 import 后重跑);` +
`\n 2) class 未加 @ccclass('${componentType}') 装饰器;` +
`\n 3) @ccclass 参数与 className 拼写不一致。`
);
}
return classId;
}
// ─── 判断节点是否是 stub(嵌套 prefab 根节点)────────────────
function isStub(elements, node) {
if (!node || node.__type__ !== 'cc.Node') return false;
const prefabRef = node._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return false;
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return false;
const instanceRef = prefabInfo.instance;
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return false;
const instance = elements[instanceRef.__id__];
return !!(instance && instance.__type__ === 'cc.PrefabInstance');
}
// ─── 引用相等查 __id__ ───────────────────────────────────────
function indexOfNode(elements, node) {
for (let i = 0; i < elements.length; i++) {
if (elements[i] === node) return i;
}
return -1;
}
// ─── 节点定位(按名字串 或 {id:N})──────────────────────────
function resolveNode(prefabData, nodeSelector, opDesc) {
const { elements } = prefabData;
if (typeof nodeSelector === 'string') {
const node = prefabData.findNodeByName(nodeSelector);
if (!node) {
throw new Error(`editPrefab [${opDesc}]: 找不到节点 "${nodeSelector}"`);
}
const nodeId = indexOfNode(elements, node);
if (nodeId < 0) {
throw new Error(`editPrefab [${opDesc}]: 节点 "${nodeSelector}" 找到但索引失败(内部错误)`);
}
return { node, nodeId };
}
if (nodeSelector && typeof nodeSelector === 'object') {
if (typeof nodeSelector.id === 'number') {
const nodeId = nodeSelector.id;
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node') {
throw new Error(`editPrefab [${opDesc}]: __id__ ${nodeId} 不是有效 cc.Node`);
}
return { node, nodeId };
}
if (typeof nodeSelector.path === 'string' && nodeSelector.path.length > 0) {
return resolveNodeByPath(prefabData, nodeSelector.path, opDesc);
}
}
throw new Error(
`editPrefab [${opDesc}]: node 参数必须是字符串名称、{ id: N } 或 { path: 'A/B/C' },收到: ${JSON.stringify(nodeSelector)}`
);
}
// 按路径定位节点(DOM-like
// path 形如 "Canvas/Main/itemList",从根节点开始按 _name 逐级下钻
// 每段必须命中 _children 中某个节点的 _name
// 遇到 stub 节点时不下钻(stub _name 在 propertyOverrides 里,超出 cli 范围)
function resolveNodeByPath(prefabData, pathStr, opDesc) {
const { elements, rootId, getRoot } = prefabData;
const segments = pathStr.split('/').filter((s) => s.length > 0);
if (segments.length === 0) {
throw new Error(`editPrefab [${opDesc}]: path 段为空`);
}
let curId = rootId;
let cur = getRoot();
// 第一段对齐根节点名(如 "Canvas"),允许省略
if (cur._name === segments[0]) {
segments.shift();
}
for (const seg of segments) {
if (!Array.isArray(cur._children)) {
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在节点 "${cur._name}" 下没有子节点,无法继续下钻到 "${seg}"`);
}
const matches = [];
for (const cref of cur._children) {
if (typeof cref.__id__ !== 'number') continue;
const child = elements[cref.__id__];
if (child && child._name === seg) {
matches.push(cref.__id__);
}
}
if (matches.length === 0) {
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下找不到子节点 "${seg}"`);
}
if (matches.length > 1) {
// 同名子节点 path 无法消歧,强制报错而非静默取首个
throw new Error(
`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下有 ${matches.length} 个同名子节点 "${seg}"__id__: ${matches.join(', ')}),` +
`path 选择器无法消歧。请改用 {id: N} 精确定位,或对父节点用 path、对该层用 id 组合`
);
}
curId = matches[0];
cur = elements[curId];
}
return { node: cur, nodeId: curId };
}
// ─── 找节点上指定类型的组件 ──────────────────────────────────
function findComponent(elements, node, compType) {
if (!Array.isArray(node._components)) return null;
for (const compRef of node._components) {
if (typeof compRef.__id__ !== 'number') continue;
const comp = elements[compRef.__id__];
if (comp && comp.__type__ === compType) return comp;
}
return null;
}
// ─── 找根节点的 PrefabInfo(持有 nestedPrefabInstanceRoots / targetOverrides)──
function findRootPrefabInfo(elements, rootNodeId) {
// 根节点直接持有其 PrefabInfo 的引用——沿 rootNode._prefab.__id__ 跳一步即可。
// 不遍历:prefab 内每个节点都有自己的 PrefabInforoot/__id__ 均指向根节点),
// 迭代会优先命中遇到的第一个非根节点 PrefabInfo,导致 targetOverrides 写错位置。
const rootNode = elements[rootNodeId];
if (!rootNode || rootNode.__type__ !== 'cc.Node') return null;
const prefabRef = rootNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return null;
const pi = elements[prefabRef.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') return null;
if (pi.instance !== null && pi.instance !== undefined) return null;
return pi;
}
module.exports = {
normalizeComponentType,
isStub,
indexOfNode,
resolveNode,
findComponent,
findRootPrefabInfo,
};
+292
View File
@@ -0,0 +1,292 @@
// ============================================================
// editor/id-utils.js — fileId 分配 / 子树断开 / __id__ 重映射
//
// 用于 add-node / clone-node / remove-node / dedupe-component 共用:
// - fileId 唯一性(deterministic + 冲突检测)
// - 删节点时递归断开 _parent
// - 删 elements 后所有 __id__ 引用收缩
// ============================================================
'use strict';
const { createFileIdGenerator } = require('../id.js');
// ─── 收集 elements 中所有现有 fileId ─────────────────────────
/**
* 遍历 elements,收集所有 cc.PrefabInfo / cc.CompPrefabInfo / cc.PrefabInstance 的 fileId。
* @param {object[]} elements
* @returns {Set<string>}
*/
function collectExistingFileIds(elements) {
const ids = new Set();
for (const el of elements) {
if (!el) continue;
if (
(el.__type__ === 'cc.PrefabInfo' ||
el.__type__ === 'cc.CompPrefabInfo' ||
el.__type__ === 'cc.PrefabInstance') &&
typeof el.fileId === 'string' &&
el.fileId.length > 0
) {
ids.add(el.fileId);
}
}
return ids;
}
/**
* 生成不与 existingIds 冲突的 fileId。
* 先用 baseSeed 生成,若冲突则追加 #1、#2 … 直到不冲突。
* deterministic:相同 baseSeed + 相同现有集合 → 相同结果。
*/
function uniqueFileId(baseSeed, existingIds) {
let candidate = createFileIdGenerator(baseSeed)();
if (!existingIds.has(candidate)) {
existingIds.add(candidate);
return candidate;
}
let counter = 1;
while (true) {
candidate = createFileIdGenerator(`${baseSeed}#${counter}`)();
if (!existingIds.has(candidate)) {
existingIds.add(candidate);
return candidate;
}
counter++;
}
}
// ─── 断开子树(remove-node 用)────────────────────────────────
/**
* 递归断开子树中所有节点及其关联对象的 _parent 引用(置 null)。
* 元素本身保留在数组,只让它们成为真正的孤儿。
*/
function disconnectSubtree(elements, nodeId) {
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node') return;
if (Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ === 'number') {
disconnectSubtree(elements, childRef.__id__);
}
}
}
node._parent = null;
if (node._prefab && typeof node._prefab.__id__ === 'number') {
const pi = elements[node._prefab.__id__];
if (pi && pi.__type__ === 'cc.PrefabInfo') {
pi._parent = null;
if (pi.instance && typeof pi.instance.__id__ === 'number') {
const prefabInst = elements[pi.instance.__id__];
if (prefabInst && prefabInst.__type__ === 'cc.PrefabInstance') {
if (Array.isArray(prefabInst.mountedChildren)) {
for (const mcRef of prefabInst.mountedChildren) {
if (typeof mcRef.__id__ === 'number') {
disconnectSubtree(elements, mcRef.__id__);
}
}
prefabInst.mountedChildren = [];
}
prefabInst.propertyOverrides = [];
if (Array.isArray(prefabInst.mountedComponents)) {
prefabInst.mountedComponents = [];
}
pi.instance = null;
}
}
}
}
if (Array.isArray(node._components)) {
for (const compRef of node._components) {
if (typeof compRef.__id__ !== 'number') continue;
const comp = elements[compRef.__id__];
if (!comp) continue;
comp._parent = null;
if (comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
const cpi = elements[comp.__prefab.__id__];
if (cpi && cpi.__type__ === 'cc.CompPrefabInfo') {
cpi._parent = null;
}
}
}
}
}
// ─── elements 重排:__id__ 引用映射 / 收缩 ───────────────────
//
// 用于 dedupe-component:合并删除组件后,所有 __id__ 指向被删/被合并对象的
// 引用要重定向到 keeper 或按缩减后的下标 shift。
/** @property 字段非 null 计数(粗略打分,挑 keeper */
function countPropertyRefs(comp) {
let n = 0;
for (const [k, v] of Object.entries(comp)) {
if (isReservedCompField(k)) continue;
if (v === null || v === undefined) continue;
if (typeof v === 'object' || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
n++;
}
}
return n;
}
/** 合并时不触碰的核心字段 */
function isReservedCompField(key) {
return (
key === '__type__' ||
key === '_name' ||
key === '_objFlags' ||
key === '__editorExtras__' ||
key === 'node' ||
key === '_enabled' ||
key === '__prefab' ||
key === '_id'
);
}
/** 所有节点的 _components / mountedComponents 去掉指向 deleteSet 的 ref */
function filterCompRefsInElements(elements, deleteSet) {
for (const el of elements) {
if (!el || typeof el !== 'object') continue;
if (Array.isArray(el._components)) {
el._components = el._components.filter(
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
);
}
if (Array.isArray(el.mountedComponents)) {
el.mountedComponents = el.mountedComponents.filter(
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
);
}
}
}
/** 把所有 __id__ 指向 redirect.keys 的 ref 改成指向 redirect.get(...) */
function redirectIdsAcrossElements(elements, redirect) {
if (redirect.size === 0) return;
const visit = (obj) => {
if (obj === null || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
for (const v of obj) visit(v);
return;
}
if (typeof obj.__id__ === 'number' && redirect.has(obj.__id__)) {
obj.__id__ = redirect.get(obj.__id__);
}
for (const k of Object.keys(obj)) visit(obj[k]);
};
visit(elements);
}
function buildShiftMap(total, deleteSet) {
const map = new Array(total);
let removed = 0;
for (let i = 0; i < total; i++) {
if (deleteSet.has(i)) {
map[i] = null;
removed++;
} else {
map[i] = i - removed;
}
}
return map;
}
function shiftIdsAcrossElements(elements, shiftMap) {
const visit = (obj) => {
if (obj === null || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
for (const v of obj) visit(v);
return;
}
if (typeof obj.__id__ === 'number') {
const nv = shiftMap[obj.__id__];
if (nv != null) obj.__id__ = nv;
}
for (const k of Object.keys(obj)) visit(obj[k]);
};
visit(elements);
}
// ─── 清理根 PrefabInfo.targetOverrides 中悬空条目 ─────────────
//
// 从根 PrefabInfo.targetOverrides 移除 source/target 落入 removedIds 的条目。
// 被移除的 cc.TargetOverrideInfo / cc.TargetInfo 对象本身保留为孤儿
// (软删策略,保持其他 __id__ 稳定)。
//
// 调用方:
// - remove-node:删 stub 后清「外层脚本 → stub 内部组件/节点」的悬空 override
// - remove-component:删组件后清「该组件 → 嵌套 stub 内部组件/节点」的悬空 override
//
// 不传 removedIds 则不做任何事;rootId 必须传(指向根 cc.Node 在 elements 数组中的 __id__)。
function cleanupRootTargetOverrides(elements, rootId, removedIds) {
if (!removedIds || removedIds.size === 0) return;
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab || typeof rootNode._prefab.__id__ !== 'number') return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
if (!Array.isArray(rootPrefabInfo.targetOverrides)) return;
rootPrefabInfo.targetOverrides = rootPrefabInfo.targetOverrides.filter((ref) => {
if (!ref || typeof ref.__id__ !== 'number') return false;
const ov = elements[ref.__id__];
if (!ov) return false;
const t = ov.target;
const s = ov.source;
if (t && typeof t.__id__ === 'number' && removedIds.has(t.__id__)) return false;
if (s && typeof s.__id__ === 'number' && removedIds.has(s.__id__)) return false;
return true;
});
}
// ─── 同步根 PrefabInfo.nestedPrefabInstanceRoots ─────────────
//
// 重建根节点 PrefabInfo.nestedPrefabInstanceRoots = 当前所有「有父 + 有 PrefabInfo +
// PrefabInfo.instance 指向 cc.PrefabInstance」的嵌套 stub 节点 __id__。
//
// 调用方:
// - remove-node:软删 stub 后,被删节点 _parent 已置 null,扫描时自动出局,
// 其登记从 nestedPrefabInstanceRoots 剔除。
// - sync-nested-roots op:单独修「删了一半」残留的悬空嵌套实例根(父引用已被移除
// 但根 PrefabInfo 登记残留 → 残留 asset 仍被当依赖加载)。
function syncNestedRoots(elements, rootId) {
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab) return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance) continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
}
module.exports = {
collectExistingFileIds,
uniqueFileId,
disconnectSubtree,
countPropertyRefs,
isReservedCompField,
filterCompRefsInElements,
redirectIdsAcrossElements,
buildShiftMap,
shiftIdsAcrossElements,
cleanupRootTargetOverrides,
syncNestedRoots,
};
+174
View File
@@ -0,0 +1,174 @@
// ============================================================
// editor/index.js — 声明式批量编辑 prefab 主入口
//
// editPrefab(filePath, ops[], options?)
// - 内存内依次执行所有 op
// - 自动判别 stub vs 普通节点
// - 任一 op 失败抛错、不落盘
// - dryRun: 跑完不写盘,返回字段级 diff
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { writePrefab } = require('../write.js');
const { computeDiff } = require('./diff.js');
// 各 op handler
const { execSetPosition } = require('./ops/set-position.js');
const { execSetLabelText } = require('./ops/set-label-text.js');
const { execSetSpriteFrame } = require('./ops/set-sprite-frame.js');
const { execSetActive } = require('./ops/set-active.js');
const { execSetComponentField } = require('./ops/set-component-field.js');
const { execSetComponentEnabled } = require('./ops/set-component-enabled.js');
const { execSetAnchor } = require('./ops/set-anchor.js');
const { execSetSize } = require('./ops/set-size.js');
const { execAdjustPosition } = require('./ops/adjust-position.js');
const { execRenameNode } = require('./ops/rename-node.js');
const { execReparent } = require('./ops/reparent.js');
const { execReorderChildren } = require('./ops/reorder-children.js');
const { execAddNode } = require('./ops/add-node.js');
const { execRemoveNode } = require('./ops/remove-node.js');
const { execCloneNode } = require('./ops/clone-node.js');
const { execAddComponent } = require('./ops/add-component.js');
const { execRemoveComponent } = require('./ops/remove-component.js');
const { execSetComponentRef } = require('./ops/set-component-ref.js');
const { execSetNestedComponentField } = require('./ops/set-nested-component-field.js');
const { execBulkSet } = require('./ops/bulk-set.js');
const { execDedupeComponent } = require('./ops/dedupe-component.js');
const { execSetEditBox } = require('./ops/set-editbox.js');
const { execSetLabel } = require('./ops/set-label.js');
const { execSetButton } = require('./ops/set-button.js');
const { execSetLayout } = require('./ops/set-layout.js');
const { execSetRichText } = require('./ops/set-richtext.js');
const { execSetSprite } = require('./ops/set-sprite.js');
const { execSetNodeColor } = require('./ops/set-node-color.js');
const { execReplaceNestedPrefab } = require('./ops/replace-nested-prefab.js');
const { execAddNestedPrefab } = require('./ops/add-nested-prefab.js');
const { execResetOverrides } = require('./ops/reset-overrides.js');
const { execEnsureMeta } = require('./ops/ensure-meta.js');
const { execSyncNestedRoots } = require('./ops/sync-nested-roots.js');
const { validateOps } = require('./op-schema.js');
const OP_HANDLERS = {
'set-position': execSetPosition,
'set-label-text': execSetLabelText,
'set-sprite-frame': execSetSpriteFrame,
'set-active': execSetActive,
'set-component-field': execSetComponentField,
'set-component-enabled': execSetComponentEnabled,
'set-anchor': execSetAnchor,
'set-size': execSetSize,
'adjust-position': execAdjustPosition,
'rename-node': execRenameNode,
'reparent': execReparent,
'reorder-children': execReorderChildren,
'add-node': execAddNode,
'remove-node': execRemoveNode,
'clone-node': execCloneNode,
'add-component': execAddComponent,
'remove-component': execRemoveComponent,
'set-component-ref': execSetComponentRef,
'set-nested-component-field': execSetNestedComponentField,
'bulk-set': execBulkSet,
'dedupe-component': execDedupeComponent,
'set-editbox': execSetEditBox,
'set-label': execSetLabel,
'set-button': execSetButton,
'set-layout': execSetLayout,
'set-richtext': execSetRichText,
'set-sprite': execSetSprite,
'set-node-color': execSetNodeColor,
'replace-nested-prefab': execReplaceNestedPrefab,
'add-nested-prefab': execAddNestedPrefab,
'reset-overrides': execResetOverrides,
'ensure-meta': execEnsureMeta,
'sync-nested-roots': execSyncNestedRoots,
};
/**
* 声明式批量编辑 prefab
*
* @param {string} filePath prefab 文件路径(读取 + 写回同一路径)
* @param {object[]} ops op 描述数组
* @param {object} [options]
* @param {string} [options.projectRoot] 项目根目录(含 assets/),默认从 filePath 向上推断。
* @param {boolean} [options.dryRun] true 时不写盘,仅返回模拟结果(含 diff)。
* @returns {{ changed: boolean, opsApplied: number, nodesAffected: (string|number)[], dryRun?: boolean, diff?: object[] }}
*
* @throws 任一 op 失败时抛错,不落盘
*/
function editPrefab(filePath, ops, options) {
if (typeof filePath !== 'string') {
throw new Error('editPrefab: filePath 必须是字符串');
}
if (!Array.isArray(ops) || ops.length === 0) {
throw new Error('editPrefab: ops 必须是非空数组');
}
// schema 预校验:跑前发现拼错的字段(comp / ref / 拼漏 op 等),不到 handler 才报错
validateOps(ops, Object.keys(OP_HANDLERS));
const opts = options || {};
const prefabData = parsePrefab(filePath);
prefabData.resolverStartPath = opts.projectRoot || filePath;
const dryRun = !!opts.dryRun;
prefabData.dryRun = dryRun;
const beforeSnapshot = dryRun
? JSON.parse(JSON.stringify(prefabData.elements))
: null;
const affectedIds = new Set();
const affectedNames = [];
let opsApplied = 0;
for (const op of ops) {
if (!op || typeof op.op !== 'string') {
throw new Error(`editPrefab: op 格式错误(缺少 op 字段): ${JSON.stringify(op)}`);
}
const handler = OP_HANDLERS[op.op];
if (!handler) {
throw new Error(
`editPrefab: 不支持的 op 类型 "${op.op}",支持: ${Object.keys(OP_HANDLERS).join(', ')}`
);
}
const nodeId = handler(prefabData, op);
if (typeof nodeId === 'number' && nodeId >= 0) {
affectedIds.add(nodeId);
}
// bulk-set 0 匹配时返回 -1,跳过 affectedIds
opsApplied++;
}
if (!dryRun) {
writePrefab(filePath, prefabData.elements, prefabData.raw);
}
for (const id of affectedIds) {
const node = prefabData.elements[id];
if (node && node._name) {
affectedNames.push(node._name);
} else {
affectedNames.push(id);
}
}
const result = {
changed: !dryRun,
opsApplied,
nodesAffected: affectedNames,
};
if (dryRun) {
result.dryRun = true;
result.diff = computeDiff(beforeSnapshot, prefabData.elements);
}
return result;
}
module.exports = { editPrefab, OP_HANDLERS };
+440
View File
@@ -0,0 +1,440 @@
// ============================================================
// editor/nested.js — stub 节点(嵌套 prefab)相关协议
//
// 涵盖:
// - 从嵌套 prefab 反查 CompPrefabInfo.fileId / Node PrefabInfo.fileId
// - 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 override
// - cc.TargetOverrideInfo 跨 nested @property 挂载
// 协议背景见 prefab-schema.md §4 与 set-component-ref op 上方注释。
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { resolveUuidToPath } = require('../uuid-resolver.js');
const { findRootPrefabInfo } = require('./helpers.js');
// ─── 嵌套 prefab:找指定组件的 CompPrefabInfo.fileId ─────────
/**
* @param {string} hostPrefabPath 宿主 prefab 文件路径(用于 UuidResolver 推断项目根)
* @param {object[]} elements 宿主 prefab elements 数组
* @param {number} stubNodeId stub 节点的 __id__
* @param {string} compType 组件类型,如 'cc.Label' / 'cc.Sprite'
* @param {string|null} nodeName 可选:指定嵌套 prefab 内的节点名(null = 第一个匹配)
* @returns {string} CompPrefabInfo.fileId
*/
function getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, nodeName) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`getNestedCompFileId: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(
`getNestedCompFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
);
}
const nestedUuid = assetRef.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
let nestedData;
try {
nestedData = parsePrefab(nestedPath);
} catch (e) {
throw new Error(
`getNestedCompFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}: ${e.message}`
);
}
const nEls = nestedData.elements;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== compType) continue;
if (nodeName !== null && nodeName !== undefined) {
if (!el.node || typeof el.node.__id__ !== 'number') continue;
const ownerNode = nEls[el.node.__id__];
if (!ownerNode || ownerNode._name !== nodeName) continue;
}
if (!el.__prefab || typeof el.__prefab.__id__ !== 'number') continue;
const cpi = nEls[el.__prefab.__id__];
if (!cpi || cpi.__type__ !== 'cc.CompPrefabInfo') continue;
if (typeof cpi.fileId !== 'string' || cpi.fileId.length === 0) continue;
return cpi.fileId;
}
const nodeHint = nodeName ? `(节点名: "${nodeName}"` : '';
throw new Error(
`getNestedCompFileId: 在嵌套 prefab "${nestedPath}" 中找不到 ${compType} 组件${nodeHint}` +
`或该组件没有 cc.CompPrefabInfo.fileId。`
);
}
// ─── 嵌套 prefab:找目标节点的 PrefabInfo.fileId ──────────────
/**
* @param {string} hostPrefabPath 宿主 prefab 路径
* @param {object[]} elements 宿主 prefab elements
* @param {number} stubNodeId stub 节点 __id__
* @param {string|null} nodeName 目标节点名(null = 嵌套 prefab 根节点)
* @returns {string} 目标节点 cc.PrefabInfo.fileId
*/
function getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, nodeName) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`getNestedNodeFileId: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(
`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
);
}
const nestedUuid = assetRef.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
let nestedData;
try {
nestedData = parsePrefab(nestedPath);
} catch (e) {
throw new Error(
`getNestedNodeFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}: ${e.message}`
);
}
const nEls = nestedData.elements;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = nEls[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (typeof pi.fileId !== 'string' || pi.fileId.length === 0) continue;
if (nodeName === null || nodeName === undefined) {
// 根节点:_parent 为 null
if (el._parent === null || el._parent === undefined) {
return pi.fileId;
}
} else {
if (el._name === nodeName) {
return pi.fileId;
}
}
}
const nodeHint = nodeName ? `(节点名: "${nodeName}"` : '(根节点)';
throw new Error(
`getNestedNodeFileId: 在嵌套 prefab "${nestedPath}" 中找不到目标节点${nodeHint}` +
`或该节点没有 cc.PrefabInfo.fileId。`
);
}
// ─── 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 ─
/**
* 在 stub 节点的 PrefabInstance.propertyOverrides 中写入一条组件属性 override。
* TargetInfo.localID 使用 compFileId(嵌套 prefab 内该组件的 CompPrefabInfo.fileId)。
*
* @param {object} prefabData parsePrefab 返回值
* @param {number} stubNodeId stub 节点 __id__
* @param {string} compFileId 嵌套 prefab 内目标组件的 CompPrefabInfo.fileId
* @param {string[]} propertyPath 属性路径,如 ['_string']
* @param {*} value 要写入的值
*/
function setStubCompOverride(prefabData, stubNodeId, compFileId, propertyPath, value) {
const { elements } = prefabData;
const stubNode = elements[stubNodeId];
const prefabInfo = elements[stubNode._prefab.__id__];
const prefabInstance = elements[prefabInfo.instance.__id__];
if (!prefabInstance || prefabInstance.__type__ !== 'cc.PrefabInstance') {
throw new Error(`setStubCompOverride: stub ${stubNodeId} 没有有效 PrefabInstance`);
}
if (Array.isArray(prefabInstance.propertyOverrides)) {
for (const overrideRef of prefabInstance.propertyOverrides) {
if (typeof overrideRef.__id__ !== 'number') continue;
const info = elements[overrideRef.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
const tiRef = info.targetInfo;
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
const ti = elements[tiRef.__id__];
if (!ti || ti.__type__ !== 'cc.TargetInfo') continue;
if (!Array.isArray(ti.localID) || ti.localID[0] !== compFileId) continue;
if (
Array.isArray(info.propertyPath) &&
info.propertyPath.length === propertyPath.length &&
info.propertyPath.every((p, i) => p === propertyPath[i])
) {
info.value = value;
return;
}
}
}
const targetInfo = {
__type__: 'cc.TargetInfo',
localID: [compFileId],
};
const targetInfoId = elements.length;
elements.push(targetInfo);
const overrideInfo = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: targetInfoId },
propertyPath: [...propertyPath],
value,
};
const overrideInfoId = elements.length;
elements.push(overrideInfo);
if (!Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
prefabInstance.propertyOverrides.push({ __id__: overrideInfoId });
}
// ─── 跨 nested @property 挂载(cc.TargetOverrideInfo)─────────
//
// 背景:主 prefab 里 BottomView.prefab 的某个脚本组件(如 BottomView)有
// @property _btnStore: cc.ButtonbtnStore 节点在主 prefab 里是 stub 代理
// PrefabInstance),真正的 cc.Button 组件在子 prefab StoreBtn.prefab 里。
// 正确协议:在主 prefab root PrefabInfo.targetOverrides 里写一条
// cc.TargetOverrideInfotarget 指向 stub 节点,targetInfo.localID 是子
// prefab 里目标组件的 __prefab.fileId。
//
// localID 为数组支持多层 nested:每过一层 PrefabInstance 边界新开子 map
// 每个元素是该层某节点/组件的 fileId。当前 cli 实现只支持 1 层;多层场景由
// 上游 tools/step-3-script/bind-prefab-components 兜底。
/**
* 在子 prefab 里按 compType + subNode 找目标组件 / 节点 fileId
* 返回 localID 数组。支持多层嵌套:
*
* subNode = null | string → 单层(在子 prefab 根上找 compType
* subNode = ['name1', 'name2'] → 多层(每段是嵌套 stub 节点名,
* 最后一段 + compType 决定终点)
*
* 多层链:path=['A','B'], compType='cc.Label'
* = 主 prefab stub → A.prefab 内的 stub 'A' → B.prefab 内的 cc.Label
* 返回 [stub-A 在 A.prefab 内的 fileId, B.prefab 内 cc.Label 的 fileId]
* 注意每跨一层 PrefabInstance 边界,链 push 一个 fileId。
*/
function resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode) {
// 单层:subNode 为 null 或字符串
if (subNode === null || subNode === undefined || typeof subNode === 'string') {
if (compType === 'cc.Node') {
const nodeFileId = getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, subNode);
return [nodeFileId];
}
const compFileId = getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, subNode);
return [compFileId];
}
// 多层:subNode 是字符串数组(路径)
if (!Array.isArray(subNode) || !subNode.every((s) => typeof s === 'string' && s.length > 0)) {
throw new Error(`resolveLocalIdChain: subNode 必须是 null / 字符串 / 字符串数组,收到 ${JSON.stringify(subNode)}`);
}
if (subNode.length === 0) {
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, null);
}
if (subNode.length === 1) {
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode[0]);
}
// 多层:从当前 stub 进入第一层嵌套,找名字 = subNode[0] 的内嵌 stub
// 拿到它在嵌套 prefab 内的 fileId,递归走剩下的路径
const [firstSeg, ...restPath] = subNode;
const { nestedPath, nestedData } = _loadNestedPrefab(hostPrefabPath, elements, stubNodeId);
const nEls = nestedData.elements;
let innerStubId = -1;
let innerStubFileId = null;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (el._name !== firstSeg) continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const innerPi = nEls[el._prefab.__id__];
if (!innerPi || innerPi.__type__ !== 'cc.PrefabInfo') continue;
if (!innerPi.instance) continue; // 不是 stub
if (typeof innerPi.fileId !== 'string' || innerPi.fileId.length === 0) continue;
innerStubId = i;
innerStubFileId = innerPi.fileId;
break;
}
if (innerStubId < 0) {
throw new Error(
`resolveLocalIdChain: 嵌套 prefab "${nestedPath}" 中找不到名为 "${firstSeg}" 的 stub 节点`
);
}
// 递归到下一层(用嵌套 prefab 自身作为 hostPrefabPath
const innerChain = resolveLocalIdChain(nestedPath, nEls, innerStubId, compType, restPath);
return [innerStubFileId, ...innerChain];
}
/** 加载 stub 指向的嵌套 prefab,返回路径 + parsed data */
function _loadNestedPrefab(hostPrefabPath, elements, stubNodeId) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`_loadNestedPrefab: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(`_loadNestedPrefab: stub ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`);
}
const nestedPath = resolveUuidToPath(assetRef.__uuid__, hostPrefabPath);
const nestedData = parsePrefab(nestedPath);
return { nestedPath, nestedData };
}
// ─── propertyPath 数组索引 normalize ─────────────────────────────────────────
//
// Cocos 编辑器加载 prefab 时按 JSON 类型区分属性名(string)与数组索引(number)。
// 若数组索引以 string 形式写入(如 "0" 代替 0),编辑器无法匹配对应数组槽,
// TargetOverrideInfo 静默失效(inspector 显示空)。
//
// 使用方法:
// addRootTargetOverride 在写入前调用 normalizePropertyPath
// 保证任何经由字符串解析("_items.0"、"_items[0]")或直接传入的数字 string
// 都被转换为 number 类型的数组索引。
//
// 例:["_items", "0"] → ["_items", 0]
// ["_items", 0 ] → ["_items", 0] (已是 number,不变)
// ["_role" ] → ["_role" ] (无下标,不变)
function normalizePropertyPath(path) {
return path.map(function(seg) {
if (typeof seg === 'string' && /^\d+$/.test(seg)) {
return parseInt(seg, 10);
}
return seg;
});
}
/**
* 给主 prefab root PrefabInfo.targetOverrides 追加一条 cc.TargetOverrideInfo
* + cc.TargetInfo,实现跨 stub @property 挂载。
*
* @param {(string|number)[]} propertyPath 属性路径数组,普通字段如 ["_role"]
* 数组字段元素如 ["_items", 0](索引用数字而非字符串)。
* 传入字符串形式的数字索引(如 "0")会被内部自动转为 number,调用方无需预处理。
*/
function addRootTargetOverride(prefabData, rootId, sourceCompId, propertyPath, targetStubId, localIdChain) {
const { elements } = prefabData;
const rootPrefabInfo = findRootPrefabInfo(elements, rootId);
if (!rootPrefabInfo) {
throw new Error(`addRootTargetOverride: 找不到主 prefab root PrefabInforootId=${rootId}`);
}
// 确保数组索引为 number 类型(Cocos 编辑器按类型匹配,string "0" ≠ number 0
const normalizedPath = normalizePropertyPath(propertyPath);
// 幂等:已存在同 source/propertyPath/target/localID 的 override 直接返回
// 注意:dedupe key 使用完整 propertyPath 数组比对,
// 允许同一字段名但不同索引(如 ["_items",0] vs ["_items",1])共存。
const existingRefs = Array.isArray(rootPrefabInfo.targetOverrides) ? rootPrefabInfo.targetOverrides : [];
for (const r of existingRefs) {
if (typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (!ov.source || ov.source.__id__ !== sourceCompId) continue;
if (!Array.isArray(ov.propertyPath) || ov.propertyPath.length !== normalizedPath.length) continue;
if (!ov.propertyPath.every((p, i) => p === normalizedPath[i])) continue;
if (!ov.target || ov.target.__id__ !== targetStubId) continue;
const tiRef = ov.targetInfo;
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
const ti = elements[tiRef.__id__];
if (!ti || !Array.isArray(ti.localID)) continue;
if (ti.localID.length !== localIdChain.length) continue;
if (ti.localID.every((v, i) => v === localIdChain[i])) return;
}
const targetInfoId = elements.length;
elements.push({
__type__: 'cc.TargetInfo',
localID: localIdChain.slice(),
});
const overrideId = elements.length;
elements.push({
__type__: 'cc.TargetOverrideInfo',
source: { __id__: sourceCompId },
sourceInfo: null,
propertyPath: normalizedPath.slice(),
target: { __id__: targetStubId },
targetInfo: { __id__: targetInfoId },
});
if (!Array.isArray(rootPrefabInfo.targetOverrides)) {
rootPrefabInfo.targetOverrides = [];
}
// 插入策略:
// - 单字段 overridepropertyPath.length === 1,如 ["_btnClose"]):插到所有
// 数组字段 override 之前。
// - 数组字段 overridepropertyPath.length > 1,如 ["_items", 0]):追加到末尾。
//
// 为什么:Cocos 加载 prefab 时,若 rootTargetOverrides 数组里前面有数组字段
// override,后面位置的单字段 override 会被静默跳过(实测 cocos 3.8.x 行为,
// 见 forest/extensions/cc-3-8-x-mcp/doc/cli.md 坑 14)。单字段插前面规避此 bug。
const newRef = { __id__: overrideId };
const isSingleField = normalizedPath.length === 1;
if (isSingleField) {
const arr = rootPrefabInfo.targetOverrides;
let firstArrayIdx = arr.length;
for (let i = 0; i < arr.length; i++) {
const r = arr[i];
if (!r || typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (Array.isArray(ov.propertyPath) && ov.propertyPath.length > 1) {
firstArrayIdx = i;
break;
}
}
arr.splice(firstArrayIdx, 0, newRef);
} else {
rootPrefabInfo.targetOverrides.push(newRef);
}
}
module.exports = {
getNestedCompFileId,
getNestedNodeFileId,
setStubCompOverride,
resolveLocalIdChain,
addRootTargetOverride,
normalizePropertyPath,
};
+239
View File
@@ -0,0 +1,239 @@
// ============================================================
// editor/op-schema.js — ops 跑前 schema 校验
//
// 价值:
// - 字段拼错(comp / ref / propery)一次性报齐,不用一条条 op 跑到才发现
// - 未知 op 类型 / 必填字段缺失,跑前就报,避免部分写入后回滚浪费时间
// - 字段类型错(`width: "100"`)跑前就报,避免运行时崩
//
// 校验粒度:必填字段名 + 已知字段拼写白名单 + 字段类型;
// 业务约束(值域、互斥)留给 handler(更易给出场景化错误信息)
// ============================================================
'use strict';
// 类型令牌:
// 'number' | 'string' | 'boolean' | 'object' | 'array' | 'any'
// 'node-selector' — 字符串 / 数字 / { id, path } 三选一
// 'string|array' — property 类支持嵌套路径数组
// 'string|object' — refSubNode 支持字符串或字符串数组(这里只做粗校验)
//
// 'any' 不做类型断言(覆盖 value / props 一类 raw JSON)。
const T = {
node: 'node-selector',
parent: 'node-selector',
target: 'node-selector',
source: 'node-selector',
refNode: 'node-selector',
componentType: 'string',
property: 'string|array',
refType: 'string',
refSubNode: 'any', // string | string[]
value: 'any',
props: 'object',
selector: 'object',
order: 'array',
text: 'string',
name: 'string',
uuid: 'string',
prefabUuid: 'string',
active: 'boolean',
enabled: 'boolean',
compensatePosition: 'boolean',
clearOverrides: 'boolean',
bold: 'boolean',
italic: 'boolean',
underline: 'boolean',
enableWrapText: 'boolean',
grayscale: 'boolean',
trim: 'boolean',
affectedByScale: 'boolean',
interactable: 'boolean',
all: 'boolean',
x: 'number',
y: 'number',
z: 'number',
dx: 'number',
dy: 'number',
dz: 'number',
width: 'number',
height: 'number',
r: 'number',
g: 'number',
b: 'number',
a: 'number',
fontSize: 'number',
lineHeight: 'number',
maxWidth: 'number',
maxLength: 'number',
inputMode: 'number',
inputFlag: 'number',
zoomScale: 'number',
duration: 'number',
type: 'number',
sizeMode: 'number',
overflow: 'number',
horizontalAlign: 'number',
verticalAlign: 'number',
transition: 'number',
resizeMode: 'number',
paddingLeft: 'number',
paddingRight: 'number',
paddingTop: 'number',
paddingBottom: 'number',
spacingX: 'number',
spacingY: 'number',
startAxis: 'number',
constraint: 'number',
constraintNum: 'number',
placeholder: 'string',
string: 'string',
labelNode: 'string',
spriteNode: 'string',
subNode: 'any', // string | string[]
};
// 每个 op 的字段白名单(含必填 + 可选;'op' 隐含必填)
// typeOverrides:对全局 T 表的字段类型做局部覆盖(同名字段在不同 op 里语义不同时用)
const SCHEMAS = {
'set-position': { required: ['node', 'x', 'y'], optional: ['z'] },
'set-label-text': { required: ['node', 'text'], optional: ['labelNode'] },
'set-sprite-frame': { required: ['node', 'uuid'], optional: ['spriteNode'] },
'set-active': { required: ['node', 'active'], optional: [] },
'set-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: [] },
'set-component-enabled': { required: ['node', 'componentType', 'enabled'], optional: ['subNode'] },
'set-anchor': { required: ['node'], optional: ['x', 'y', 'compensatePosition'] },
'set-size': { required: ['node'], optional: ['width', 'height'] },
'adjust-position': { required: ['node'], optional: ['dx', 'dy', 'dz'] },
'rename-node': { required: ['node', 'name'], optional: [] },
// reparent: 把节点搬到另一个父节点下(不复制;普通 inline 节点;自带循环检测)
'reparent': { required: ['node', 'parent'], optional: ['index'] },
'reorder-children': { required: ['node', 'order'], optional: [] },
// add-node 的 node 是「新节点描述对象」而非 selector
'add-node': { required: ['parent', 'node'], optional: [], typeOverrides: { node: 'object' } },
'remove-node': { required: ['target'], optional: [] },
'clone-node': { required: ['source', 'parent', 'name'], optional: [] },
'add-component': { required: ['node', 'componentType'], optional: ['props'] },
'remove-component': { required: ['node', 'componentType'], optional: [] },
'set-component-ref': { required: ['node', 'componentType', 'property', 'refNode'], optional: ['refType', 'refSubNode'] },
'set-nested-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: ['subNode'] },
// bulk-set 的 target 是 "node" 或 "component:<type>" 字符串模式,不是 selector
'bulk-set': { required: ['selector', 'target', 'property', 'value'], optional: [], typeOverrides: { target: 'string' } },
'dedupe-component': { required: [], optional: ['node'] },
'set-editbox': { required: ['node'], optional: ['inputMode', 'maxLength', 'placeholder', 'string', 'inputFlag', 'fontSize'] },
'set-label': { required: ['node'], optional: ['text', 'fontSize', 'lineHeight', 'overflow', 'horizontalAlign', 'verticalAlign', 'bold', 'italic', 'underline', 'enableWrapText'] },
'set-button': { required: ['node'], optional: ['interactable', 'transition', 'zoomScale', 'duration'] },
'set-layout': { required: ['node'], optional: ['type', 'resizeMode', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'spacingX', 'spacingY', 'startAxis', 'constraint', 'constraintNum', 'affectedByScale'] },
'set-richtext': { required: ['node'], optional: ['text', 'maxWidth', 'fontSize', 'lineHeight'] },
'set-sprite': { required: ['node'], optional: ['sizeMode', 'type', 'grayscale', 'trim'] },
'set-node-color': { required: ['node'], optional: ['r', 'g', 'b', 'a'] },
'replace-nested-prefab': { required: ['target', 'prefabUuid'], optional: ['clearOverrides'] },
'add-nested-prefab': { required: ['parent', 'prefabUuid'], optional: ['name', 'lpos'] },
'reset-overrides': { required: ['node'], optional: ['property', 'componentType', 'subNode', 'all'] },
// ensure-meta: 给 .ts/.json 文件创建 .metav4 uuid),让后续 className → classId 查表能命中
'ensure-meta': { required: ['path'], optional: [], typeOverrides: { path: 'string' } },
'sync-nested-roots': { required: [], optional: [] },
};
// 已知拼错 → 正确字段映射(友好提示)
const COMMON_TYPOS = {
'comp': 'componentType',
'compType': 'componentType',
'ref': 'refNode',
'propery': 'property',
'val': 'value',
'newName': 'name',
'nodeName': 'node',
};
function _formatType(token) {
switch (token) {
case 'node-selector': return '字符串/数字/{id}/{path}';
case 'string|array': return '字符串或数组';
default: return token;
}
}
function _checkType(value, token) {
if (token === 'any') return true;
if (token === 'node-selector') {
if (typeof value === 'string') return value.length > 0;
if (typeof value === 'number') return Number.isInteger(value) && value >= 0;
if (value && typeof value === 'object' && !Array.isArray(value)) {
return typeof value.id === 'number' || typeof value.path === 'string';
}
return false;
}
if (token === 'string|array') {
return typeof value === 'string' || Array.isArray(value);
}
if (token === 'array') return Array.isArray(value);
if (token === 'object') {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
return typeof value === token; // number / string / boolean
}
function validateOps(ops, knownOpTypes) {
const errors = [];
for (let i = 0; i < ops.length; i++) {
const op = ops[i];
const prefix = `ops[${i}]`;
if (!op || typeof op !== 'object' || Array.isArray(op)) {
errors.push(`${prefix}: 不是对象`);
continue;
}
if (typeof op.op !== 'string') {
errors.push(`${prefix}: 缺 'op' 字段`);
continue;
}
if (!knownOpTypes.includes(op.op)) {
errors.push(`${prefix}: 不支持的 op 类型 "${op.op}",已知: ${knownOpTypes.join(', ')}`);
continue;
}
const schema = SCHEMAS[op.op];
if (!schema) continue; // 没登记 schema 的 op 跳过
const known = new Set(['op', ...schema.required, ...schema.optional]);
// 必填检查
for (const r of schema.required) {
if (!(r in op)) {
errors.push(`${prefix} (${op.op}): 缺必填字段 "${r}"`);
}
}
// 多余字段 + 类型检查
for (const k of Object.keys(op)) {
if (k === 'op') continue;
if (!known.has(k)) {
const suggest = COMMON_TYPOS[k];
if (suggest && known.has(suggest)) {
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",可能想写 "${suggest}"`);
} else {
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",已知: ${[...known].join(', ')}`);
}
continue;
}
// 类型检查(only 在 T 中登记的字段;未登记的留给 handler)
// op 的 typeOverrides 优先级高于全局 T
const token = (schema.typeOverrides && schema.typeOverrides[k]) || T[k];
if (!token) continue;
if (!_checkType(op[k], token)) {
const got = Array.isArray(op[k]) ? 'array' : (op[k] === null ? 'null' : typeof op[k]);
errors.push(
`${prefix} (${op.op}): 字段 "${k}" 类型应为 ${_formatType(token)},实际是 ${got}(值: ${JSON.stringify(op[k])}`
);
}
}
}
if (errors.length > 0) {
throw new Error(`editPrefab: ops schema 校验失败:\n ${errors.join('\n ')}`);
}
}
module.exports = { validateOps, SCHEMAS };
+75
View File
@@ -0,0 +1,75 @@
// add-component: 在节点 _components 数组里加一个指向指定 ccclass 的组件条目
// + 配套的 cc.CompPrefabInfo(含 deterministic fileId
// op: { op: 'add-component', node, componentType, props? }
//
// - componentType: 组件 ccclass 名(如 'TaskBtn' / 'cc.Sprite'
// - props: 可选,初始 @property 字段值,会浅合并到组件对象上
//
// 限制:
// - stub 节点暂不支持
// - 同节点同类型组件已存在时抛错
'use strict';
const { ref, makeCompPrefabInfo } = require('../../primitives.js');
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execAddComponent(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawComponentType, props } = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [add-component]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'add-component');
if (isStub(elements, node)) {
throw new Error(`editPrefab [add-component]: stub 节点挂自定义组件暂未实现(需 PrefabInstance.mountedComponents`);
}
if (findComponent(elements, node, componentType)) {
throw new Error(`editPrefab [add-component]: 节点 "${node._name}" 已挂 "${componentType}" 组件`);
}
let seed = 'unknown';
if (node._prefab && typeof node._prefab.__id__ === 'number') {
const pi = elements[node._prefab.__id__];
if (pi && pi.fileId) seed = pi.fileId;
}
const existingFileIds = collectExistingFileIds(elements);
const compFileId = uniqueFileId(`${seed}#addComp#${componentType}`, existingFileIds);
const compId = elements.length;
const cpiId = compId + 1;
const compObj = Object.assign(
{
__type__: componentType,
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
__prefab: ref(cpiId),
_id: '',
},
props && typeof props === 'object' ? props : {}
);
const cpiObj = makeCompPrefabInfo(compFileId);
elements.push(compObj);
elements.push(cpiObj);
if (!Array.isArray(node._components)) {
node._components = [];
}
node._components.push(ref(compId));
return nodeId;
}
module.exports = { execAddComponent };
+149
View File
@@ -0,0 +1,149 @@
// add-nested-prefab: 在指定父节点下嵌入一个外部 prefab 实例(stub)。
//
// 等效于在 Cocos 编辑器把某个 prefab 文件拖入当前 prefab 树。生成三个对象:
// - 一个 stub cc.Node_name/_active 留空,由子 prefab 默认或 override 决定)
// - 一个 cc.PrefabInfoasset.__uuid__ = prefabUuidinstance 指向 PrefabInstance
// - 一个 cc.PrefabInstanceprefabRootNode 指向外层 prefab 根 = rootId
//
// 可选 name / lpos 通过 propertyOverrides 写到 PrefabInstance 上(targetInfo.localID
// 用子 prefab 内根节点的 PrefabInfo.fileId,需读外部 prefab 文件解析)。
//
// op: { op: 'add-nested-prefab', parent: string|{id:N}, prefabUuid: string, name?: string, lpos?: [x,y,z] }
//
// 协议背景:参 doc/nested-prefab-protocol.md;与 replace-nested-prefab 互补
// (replace 替换 asset uuid 不动节点结构,add 是从零生成嵌套实例)。
'use strict';
const { resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execAddNestedPrefab(prefabData, op) {
const { elements, rootId } = prefabData;
const { parent: parentSelector, prefabUuid, name, lpos } = op;
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
throw new Error(`editPrefab [add-nested-prefab]: prefabUuid 必须是非空字符串`);
}
const cleanUuid = prefabUuid.trim();
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-nested-prefab');
// 父 prefab fileId 作 deterministic 种子
let parentFileId = 'unknown';
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
const parentPi = elements[parentNode._prefab.__id__];
if (parentPi && parentPi.fileId) parentFileId = parentPi.fileId;
}
const existingFileIds = collectExistingFileIds(elements);
const baseSeed = `${parentFileId}#addNested#${cleanUuid}#${name ?? ''}`;
const stubFileId = uniqueFileId(baseSeed, existingFileIds);
const instanceFileId = uniqueFileId(`${baseSeed}#instance`, existingFileIds);
// 分配 idstubNode → prefabInfo → prefabInstance → [TargetInfo + OverrideInfo] × N
const stubNodeId = elements.length;
const prefabInfoId = stubNodeId + 1;
const instanceId = stubNodeId + 2;
let nextId = instanceId + 1;
const propertyOverrideRefs = [];
const overrideElements = [];
// PropertyOverride 的 targetInfo.localID 用 stub 自己在外层 prefab 内的 PrefabInfo.fileId
// (而不是子 prefab 内根节点 fileId)。CC3 协议:targetInfo 定位 override 应用的「目标对象」,
// 对于 stub Node 自己的 _name/_lpos 这类字段,目标对象就是 stub 在外层 prefab 内的标识。
function pushOverride(propertyPath, value) {
const tiId = nextId++;
const oiId = nextId++;
overrideElements.push({
__type__: 'cc.TargetInfo',
localID: [stubFileId],
});
overrideElements.push({
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: tiId },
propertyPath,
value,
});
propertyOverrideRefs.push({ __id__: oiId });
}
if (name !== undefined) pushOverride(['_name'], name);
if (lpos !== undefined) {
pushOverride(['_lpos'], {
__type__: 'cc.Vec3',
x: lpos[0] || 0,
y: lpos[1] || 0,
z: lpos[2] || 0,
});
}
const stubNode = {
__type__: 'cc.Node',
_objFlags: 0,
_parent: { __id__: parentId },
_prefab: { __id__: prefabInfoId },
__editorExtras__: {},
};
const stubPrefabInfo = {
__type__: 'cc.PrefabInfo',
root: { __id__: stubNodeId },
asset: { __uuid__: cleanUuid, __expectedType__: 'cc.Prefab' },
fileId: stubFileId,
instance: { __id__: instanceId },
targetOverrides: null,
};
const prefabInstance = {
__type__: 'cc.PrefabInstance',
fileId: instanceFileId,
prefabRootNode: { __id__: rootId },
mountedChildren: [],
mountedComponents: [],
propertyOverrides: propertyOverrideRefs,
removedComponents: [],
};
elements.push(stubNode);
elements.push(stubPrefabInfo);
elements.push(prefabInstance);
for (const o of overrideElements) elements.push(o);
if (!Array.isArray(parentNode._children)) parentNode._children = [];
parentNode._children.push({ __id__: stubNodeId });
// 同步外层 prefab 根 PrefabInfo.nestedPrefabInstanceRootscocos 加载嵌套实例的入口列表)。
// 缺这一步运行时 stub 节点不会被解析渲染,子 prefab 内容看不到。
syncNestedRoots(elements, rootId);
return stubNodeId;
}
/**
* 重建外层 prefab 根 PrefabInfo.nestedPrefabInstanceRoots,包含所有 _parent 非 null 的活 stub 节点。
* 软删(remove-node)留下的孤儿 stub 自动排除。
*/
function syncNestedRoots(elements, rootId) {
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab) return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance) continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
}
module.exports = { execAddNestedPrefab };
+120
View File
@@ -0,0 +1,120 @@
// add-node: 在指定父节点下新增一个 cc.Node
// op: { op: 'add-node', parent: string|{id:N}, node: { name, lpos?, components? } }
//
// 支持:
// - 普通父节点:新节点进入 parent._children
// - stub 父节点(嵌套 prefab 实例):新节点进入 PrefabInstance.mountedChildren
// 若 node.components 包含 'UITransform',自动创建 cc.UITransform(默认 100×100
'use strict';
const { ref, makeNode, makePrefabInfo, makeCompPrefabInfo, makeUITransform } = require('../../primitives.js');
const { isStub, resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
const SUPPORTED_COMPONENTS = ['UITransform'];
function execAddNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { parent: parentSelector, node: nodeSpec } = op;
if (!nodeSpec || typeof nodeSpec.name !== 'string') {
throw new Error(`editPrefab [add-node]: node.name 必须是字符串`);
}
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-node');
if (Array.isArray(nodeSpec.components)) {
for (const comp of nodeSpec.components) {
if (typeof comp === 'string' && !SUPPORTED_COMPONENTS.includes(comp)) {
throw new Error(
`editPrefab [add-node]: unknown component type: ${comp}(已支持: ${SUPPORTED_COMPONENTS.join(', ')}`
);
}
}
}
const newNodeId = elements.length;
// 父节点 fileId 用作 deterministic 种子
let parentFileId = 'unknown';
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
const parentPrefabInfo = elements[parentNode._prefab.__id__];
if (parentPrefabInfo && parentPrefabInfo.fileId) {
parentFileId = parentPrefabInfo.fileId;
}
}
const baseSeed = `${parentFileId}#addNode#${nodeSpec.name}`;
const existingFileIds = collectExistingFileIds(elements);
const nodeFileId = uniqueFileId(baseSeed, existingFileIds);
const uitFileId = uniqueFileId(`${baseSeed}#uit`, existingFileIds);
const prefabInfoId = newNodeId + 1;
let componentIds = [];
const newObjects = [];
if (Array.isArray(nodeSpec.components) && nodeSpec.components.includes('UITransform')) {
const uitId = newNodeId + 2;
const uitPrefabInfoId = newNodeId + 3;
componentIds = [uitId];
const uitObj = makeUITransform({
nodeId: newNodeId,
width: nodeSpec.width || 100,
height: nodeSpec.height || 100,
anchor: nodeSpec.anchor || [0.5, 0.5],
prefabInfoId: uitPrefabInfoId,
});
const uitCpi = makeCompPrefabInfo(uitFileId);
newObjects.push(uitObj);
newObjects.push(uitCpi);
}
const lpos = nodeSpec.lpos || [0, 0, 0];
const newNodeObj = makeNode({
name: nodeSpec.name,
pos: lpos,
active: nodeSpec.active !== undefined ? nodeSpec.active : true,
parentId,
childIds: [],
componentIds,
prefabId: prefabInfoId,
});
const newPrefabInfoObj = makePrefabInfo({
rootId,
fileId: nodeFileId,
assetId: 0,
nestedPrefabInstanceRoots: null,
});
elements.push(newNodeObj);
elements.push(newPrefabInfoObj);
for (const o of newObjects) elements.push(o);
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (!Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = [];
}
prefabInstance.mountedChildren.push({ __id__: newNodeId });
} else {
if (!Array.isArray(parentNode._children)) {
parentNode._children = [];
}
parentNode._children.push({ __id__: newNodeId });
}
// ref 在此模块虽然没直接用,但保留 import 以便上层调试时一致;
// 实际节点对象的子引用全在 makeNode/makePrefabInfo/makeUITransform 内部生成。
void ref;
return newNodeId;
}
module.exports = { execAddNode };
+41
View File
@@ -0,0 +1,41 @@
// adjust-position: lpos 相对偏移
// op: { op:'adjust-position', node, dx?, dy?, dz? }
//
// 适合"在原位置基础上挪 N 像素"场景,免去先 query 取原值。
// 任一轴缺省视为 0。stub 节点走 setOverrideProperty,与 set-position 一致。
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execAdjustPosition(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, dx = 0, dy = 0, dz = 0 } = op;
if (typeof dx !== 'number' || typeof dy !== 'number' || typeof dz !== 'number') {
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 必须是数字`);
}
if (dx === 0 && dy === 0 && dz === 0) {
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 至少一个非零`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'adjust-position');
const lpos = node._lpos || { x: 0, y: 0, z: 0 };
const newLpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: (lpos.z || 0) + dz,
};
if (isStub(elements, node)) {
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
} else {
node._lpos = newLpos;
}
return nodeId;
}
module.exports = { execAdjustPosition };
+112
View File
@@ -0,0 +1,112 @@
// bulk-set: 按 selector 找一批节点,统一改字段(一条 op 顶 N 条)
// op: { op:'bulk-set', selector, target, property, value }
//
// selector:节点筛选条件
// { byComponent: 'cc.Label' } → 所有挂 cc.Label 的节点
// { byNamePrefix: 'btn' } → 所有 _name 以 'btn' 开头的节点
// { byNameRegex: '^icon_\\d+$' } → 正则匹配
// 多条件并存为 AND
//
// target:要改的对象层
// 'node' → 改节点字段,如 _active / _name
// 'component:<T>' → 改节点上 type=T 的组件字段(每个匹配节点都得有这个组件,否则跳过)
//
// property:字符串或字符串数组(嵌套路径)
// value:写入值
//
// 行为:
// - 匹配 0 个不算错(返回 [] 但 opsApplied 仍 +1
// - stub 节点跳过(bulk-set 不处理 stub,避免不同代码路径混用)
// - 返回所有受影响的 nodeId 数组(editPrefab 主循环会聚合到 affectedNodes
'use strict';
const { isStub, findComponent } = require('../helpers.js');
function _matchSelector(elements, node, selector) {
if (selector.byComponent) {
if (!findComponent(elements, node, selector.byComponent)) return false;
}
if (selector.byNamePrefix) {
if (typeof node._name !== 'string' || !node._name.startsWith(selector.byNamePrefix)) return false;
}
if (selector.byNameRegex) {
if (typeof node._name !== 'string') return false;
const re = new RegExp(selector.byNameRegex);
if (!re.test(node._name)) return false;
}
return true;
}
function _setNested(obj, path, value) {
if (typeof path === 'string') {
obj[path] = value;
return;
}
let cur = obj;
for (let i = 0; i < path.length - 1; i++) {
const k = path[i];
if (cur[k] === null || cur[k] === undefined || typeof cur[k] !== 'object') {
throw new Error(
`bulk-set: 路径 ${path.slice(0, i + 1).join('.')} 不是对象(${JSON.stringify(cur[k])}),无法继续下钻`
);
}
cur = cur[k];
}
cur[path[path.length - 1]] = value;
}
function execBulkSet(prefabData, op) {
const { elements } = prefabData;
const { selector, target, property, value } = op;
if (!selector || typeof selector !== 'object' || Object.keys(selector).length === 0) {
throw new Error(`editPrefab [bulk-set]: selector 必须是非空对象`);
}
if (typeof target !== 'string' || target.length === 0) {
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>'`);
}
if (
!(typeof property === 'string' && property.length > 0) &&
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string'))
) {
throw new Error(`editPrefab [bulk-set]: property 必须是非空字符串或字符串数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [bulk-set]: value 不能是 undefined`);
}
let targetKind = target;
let targetCompType = null;
if (target.startsWith('component:')) {
targetCompType = target.slice('component:'.length);
if (targetCompType.length === 0) {
throw new Error(`editPrefab [bulk-set]: target='component:' 后必须跟组件类型`);
}
targetKind = 'component';
} else if (target !== 'node') {
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>',收到 "${target}"`);
}
const affected = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (isStub(elements, el)) continue;
if (!_matchSelector(elements, el, selector)) continue;
if (targetKind === 'node') {
_setNested(el, property, value);
} else {
const comp = findComponent(elements, el, targetCompType);
if (!comp) continue; // 节点匹配但没这个组件,跳过
_setNested(comp, property, value);
}
affected.push(i);
}
// 至少返回一个 id 让 affectedNodes 不报错(即使 0 匹配也不算 op fail)
return affected.length > 0 ? affected[0] : -1;
}
module.exports = { execBulkSet };
+150
View File
@@ -0,0 +1,150 @@
// clone-node: 深拷贝 source 及其整棵子树,挂到 parent 下
// op: { op: 'clone-node', source: string|{id:N}, parent: string|{id:N}, name: string }
//
// - 为每个新节点/组件分配新 __id__(push 到数组末尾)
// - 为每个新节点和组件生成新 fileIddeterministic,种子基于 source fileId + newName
// - 更新所有内部 _parent 引用指向新副本
// - 新树挂到 parent._children(若 parent 是 stub 则走 mountedChildren
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execCloneNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { source: sourceSelector, parent: parentSelector, name: newName } = op;
if (typeof newName !== 'string') {
throw new Error(`editPrefab [clone-node]: name 必须是字符串`);
}
const { node: sourceNode, nodeId: sourceId } = resolveNode(prefabData, sourceSelector, 'clone-node');
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'clone-node');
const oldToNew = new Map();
function collectSubtreeNodeIds(nodeId) {
const ids = [nodeId];
const node = elements[nodeId];
if (node && Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ === 'number') {
ids.push(...collectSubtreeNodeIds(childRef.__id__));
}
}
}
return ids;
}
const subtreeNodeIds = collectSubtreeNodeIds(sourceId);
const allSourceIds = [];
for (const nid of subtreeNodeIds) {
allSourceIds.push(nid);
const n = elements[nid];
if (!n) continue;
if (n._prefab && typeof n._prefab.__id__ === 'number') {
allSourceIds.push(n._prefab.__id__);
}
if (Array.isArray(n._components)) {
for (const cRef of n._components) {
if (typeof cRef.__id__ === 'number') {
const compId = cRef.__id__;
allSourceIds.push(compId);
const comp = elements[compId];
if (comp && comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
allSourceIds.push(comp.__prefab.__id__);
}
}
}
}
}
const uniqueSourceIds = [...new Set(allSourceIds)];
const insertStart = elements.length;
for (let i = 0; i < uniqueSourceIds.length; i++) {
oldToNew.set(uniqueSourceIds[i], insertStart + i);
elements.push(null);
}
let sourceFileId = 'unknown';
if (sourceNode._prefab && typeof sourceNode._prefab.__id__ === 'number') {
const srcPInfo = elements[sourceNode._prefab.__id__];
if (srcPInfo && srcPInfo.fileId) sourceFileId = srcPInfo.fileId;
}
const cloneBaseSeed = `${sourceFileId}#clone#${newName}`;
const cloneExistingFileIds = collectExistingFileIds(elements);
let cloneGenCounter = 0;
function cloneGen() {
const subSeed = `${cloneBaseSeed}#slot${cloneGenCounter++}`;
return uniqueFileId(subSeed, cloneExistingFileIds);
}
function cloneObj(obj) {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(cloneObj);
if (typeof obj.__id__ === 'number') {
const newId = oldToNew.get(obj.__id__);
if (newId !== undefined) return { __id__: newId };
return { ...obj };
}
const result = {};
for (const k of Object.keys(obj)) {
result[k] = cloneObj(obj[k]);
}
return result;
}
for (const oldId of uniqueSourceIds) {
const newId = oldToNew.get(oldId);
const srcObj = elements[oldId];
if (!srcObj) {
elements[newId] = null;
continue;
}
const cloned = cloneObj(srcObj);
if (cloned.__type__ === 'cc.PrefabInfo') {
cloned.fileId = cloneGen();
cloned.root = { __id__: rootId };
cloned.asset = { __id__: 0 };
cloned.instance = null;
cloned.targetOverrides = null;
cloned.nestedPrefabInstanceRoots = null;
}
if (cloned.__type__ === 'cc.CompPrefabInfo') {
cloned.fileId = cloneGen();
}
elements[newId] = cloned;
}
const newRootId = oldToNew.get(sourceId);
const newRootNode = elements[newRootId];
newRootNode._name = newName;
newRootNode._parent = { __id__: parentId };
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (!Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = [];
}
prefabInstance.mountedChildren.push({ __id__: newRootId });
} else {
if (!Array.isArray(parentNode._children)) {
parentNode._children = [];
}
parentNode._children.push({ __id__: newRootId });
}
return newRootId;
}
module.exports = { execCloneNode };
+132
View File
@@ -0,0 +1,132 @@
// dedupe-component: 合并同节点上同语义但重复挂载的组件条目
//
// 背景:cli 若用 className 写入 __type__(如 "GMUI"),而 Cocos 编辑器 reimport
// 时会把 __type__ 规范化为压缩 classId(如 "a57b6RRA21B5I70mCpu1pBP"),
// 在 TS 脚本尚未注册时 @property refs 会被丢弃,造成同节点出现「字符串版 +
// 压缩版」两份组件,其中一份 refs 完整、另一份全 null。本 op 把它们合并成一条。
//
// op: { op: 'dedupe-component', node? }
// - node: 仅扫指定节点;缺省 → 扫整个 prefab 所有普通节点
//
// 策略:
// 1. 按 normalizeComponentType() 后的 compType 分组
// 2. 同 compType >=2 命中时,选非空 @property 字段最多的作为 keeper
// 3. 把 losers 的非空字段合并进 keeperkeeper 为 null/undefined 才填)
// 4. keeper.__type__ 写成规范化后的 compType
// 5. losers 的 comp idx 和 __prefab CompPrefabInfo idx 进入删除集
// 6. _components 数组过滤被删的引用
// 7. 其他 elements 里 __id__ 指向被删组件的引用映射到 keeper 新 id
// 8. 全部 __id__ 按缩减后的索引重映射 + splice 实际删除
// 限制:stub 节点暂不处理。
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const {
countPropertyRefs,
isReservedCompField,
filterCompRefsInElements,
redirectIdsAcrossElements,
buildShiftMap,
shiftIdsAcrossElements,
} = require('../id-utils.js');
function execDedupeComponent(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op || {};
// ── 1. 决定扫哪些节点
const targets = [];
if (nodeSelector == null) {
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el && el.__type__ === 'cc.Node') targets.push({ node: el, nodeId: i });
}
} else {
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'dedupe-component');
targets.push({ node, nodeId });
}
// ── 2. 逐节点分组,找到所有要合并的 group
const merges = [];
const affectedNodes = new Set();
for (const { node, nodeId } of targets) {
if (isStub(elements, node)) continue;
if (!Array.isArray(node._components)) continue;
const groups = new Map();
for (const cref of node._components) {
if (!cref || typeof cref.__id__ !== 'number') continue;
const comp = elements[cref.__id__];
if (!comp || typeof comp.__type__ !== 'string') continue;
const normalized = normalizeComponentType(comp.__type__, prefabData.resolverStartPath);
if (!groups.has(normalized)) groups.set(normalized, []);
groups.get(normalized).push({ compId: cref.__id__, comp });
}
for (const [normalized, list] of groups.entries()) {
if (list.length < 2) continue;
const scored = list.map((x) => ({ ...x, score: countPropertyRefs(x.comp) }));
scored.sort((a, b) => b.score - a.score || a.compId - b.compId);
const keeper = scored[0];
const losers = scored.slice(1);
for (const loser of losers) {
for (const [k, v] of Object.entries(loser.comp)) {
if (isReservedCompField(k)) continue;
if ((keeper.comp[k] === null || keeper.comp[k] === undefined) && v !== null && v !== undefined) {
keeper.comp[k] = v;
}
}
}
keeper.comp.__type__ = normalized;
merges.push({
keeperCompId: keeper.compId,
loserCompIds: losers.map((x) => x.compId),
normalizedType: normalized,
nodeId,
});
affectedNodes.add(nodeId);
}
}
if (merges.length === 0) return [];
// ── 3. 收集要删除的 elements id 与「loser→keeper」重定向
const deleteSet = new Set();
const redirect = new Map();
for (const m of merges) {
for (const loserId of m.loserCompIds) {
deleteSet.add(loserId);
redirect.set(loserId, m.keeperCompId);
const loserComp = elements[loserId];
const pref = loserComp && loserComp.__prefab;
if (pref && typeof pref.__id__ === 'number') {
deleteSet.add(pref.__id__);
}
}
}
// ── 4. 先把所有节点的 _components / mountedComponents 数组过滤掉被删的引用
filterCompRefsInElements(elements, deleteSet);
// ── 5. __id__ 重定向(loser → keeper
redirectIdsAcrossElements(elements, redirect);
// ── 6. 构建 shift 映射 + 执行删除
const shiftMap = buildShiftMap(elements.length, deleteSet);
shiftIdsAcrossElements(elements, shiftMap);
const sortedToDel = Array.from(deleteSet).sort((a, b) => b - a);
for (const idx of sortedToDel) elements.splice(idx, 1);
// ── 7. 更新 prefabData.rootId
if (typeof prefabData.rootId === 'number' && shiftMap[prefabData.rootId] != null) {
prefabData.rootId = shiftMap[prefabData.rootId];
}
return Array.from(affectedNodes).map((id) => shiftMap[id] ?? id);
}
module.exports = { execDedupeComponent };
+109
View File
@@ -0,0 +1,109 @@
// ensure-meta: 给指定 .ts / .json 文件创建 .meta(如果不存在)
// op: { op:'ensure-meta', path }
//
// 用途:新建 .ts / .ctrl.json 后 cocos 编辑器尚未生成 .meta,但 cli 后续要用
// className → classId 查表(add-component 等)。这时在 add-component 之前插一条
// ensure-meta,主动写一个标准 .meta(v4 uuid + 按扩展名选模板),让 cli 当场能查到表,
// 而不必等 cocos 编辑器异步 import。
//
// 路径规则:path 是绝对路径,或相对项目根(如 'assets/scripts/.../X.ts')。
// 已存在 .meta 时幂等不动。
// dry-run 时不写盘(让 --dry-run 语义一致)。
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { clearCache } = require('../../classid-resolver.js');
function _v4Uuid() {
const b = crypto.randomBytes(16);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = b.toString('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
}
const _META_TEMPLATES = {
'.ts': (uuid) => ({
ver: '4.0.24',
importer: 'typescript',
imported: true,
uuid,
files: [],
subMetas: {},
userData: { simulateGlobals: [] },
}),
'.json': (uuid) => ({
ver: '2.0.1',
importer: 'json',
imported: true,
uuid,
files: ['.json'],
subMetas: {},
userData: {},
}),
};
function _resolveProjectRoot(startPath) {
let dir = fs.statSync(startPath).isDirectory() ? startPath : path.dirname(startPath);
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, 'package.json')) && fs.existsSync(path.join(dir, 'assets'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function execEnsureMeta(prefabData, op) {
if (typeof op.path !== 'string' || op.path.length === 0) {
throw new Error("ensure-meta: 缺必填字段 'path'");
}
let filePath = op.path;
if (!path.isAbsolute(filePath)) {
const projectRoot = _resolveProjectRoot(prefabData.resolverStartPath);
if (!projectRoot) {
throw new Error(
`ensure-meta: 无法定位项目根(含 assets/+package.json),请用绝对 path`
);
}
filePath = path.resolve(projectRoot, op.path);
}
if (!fs.existsSync(filePath)) {
throw new Error(`ensure-meta: 文件不存在: ${filePath}`);
}
const metaPath = filePath + '.meta';
if (fs.existsSync(metaPath)) {
// 幂等:已存在不动
return -1;
}
const ext = path.extname(filePath).toLowerCase();
const template = _META_TEMPLATES[ext];
if (!template) {
throw new Error(
`ensure-meta: 不支持的文件扩展名 "${ext}"(当前支持: ${Object.keys(_META_TEMPLATES).join(' / ')}`
);
}
if (prefabData.dryRun) {
// dry-run 模式不落盘
return -1;
}
const meta = template(_v4Uuid());
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf8');
// 同 batch 后续 op(如 add-component)会调 resolveClassIdByName 查表,
// resolver 有进程内 cache,必须 invalidate 让下次重扫覆盖新建的 meta
clearCache();
return -1;
}
module.exports = { execEnsureMeta };
+78
View File
@@ -0,0 +1,78 @@
// remove-component: 从普通节点 `_components` 数组移除指定组件的引用,
// 组件元素本身保留为 orphan(保持其他 __id__ 稳定,与 remove-node 同策略)。
// 关联的 cc.CompPrefabInfo 也随之 orphan(它只被 component._ _prefab 引用)。
//
// 同步清根 PrefabInfo.targetOverrides 中 source 指向被删组件的悬空条目:
// 外层脚本通过 targetOverride 把嵌套 stub 内部组件/节点挂到自己 @property 字段时,
// 删组件后这些 override 仍被根 PrefabInfo 引用 → 可达悬空引用 → cocos 解析时
// 反序列化 source.__id__ 触发 missing-class 报错。
//
// op: { op: 'remove-component', node, componentType }
//
// 不支持 stub 节点:嵌套 prefab 的组件由子 prefab 拥有,外层无法删除,
// 只能 set-component-enabled 禁用。stub 上调用本 op 会抛错。
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const { cleanupRootTargetOverrides } = require('../id-utils.js');
function execRemoveComponent(prefabData, op) {
const { elements, rootId } = prefabData;
const { node: nodeSelector, componentType: rawCompType } = op;
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
throw new Error(`editPrefab [remove-component]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'remove-component');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 是 stub(嵌套 prefab 根),无法删除其内部组件;改用 set-component-enabled 禁用`
);
}
if (!Array.isArray(node._components) || node._components.length === 0) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 没有 _components 数组`
);
}
let matchedCompId = -1;
const next = [];
for (const ref of node._components) {
if (!ref || typeof ref.__id__ !== 'number') {
next.push(ref);
continue;
}
const comp = elements[ref.__id__];
if (matchedCompId < 0 && comp && comp.__type__ === componentType) {
matchedCompId = ref.__id__;
continue; // 丢弃这条引用
}
next.push(ref);
}
if (matchedCompId < 0) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 上找不到 ${rawCompType} 组件`
);
}
node._components = next;
// 收集被删组件相关 __id__:组件本身 + 它的 cc.CompPrefabInfo__prefab 字段)。
// targetOverride 的 source 一般指向组件本身;带上 CompPrefabInfo 为防御性兜底。
const removedIds = new Set([matchedCompId]);
const matchedComp = elements[matchedCompId];
if (matchedComp && matchedComp.__prefab && typeof matchedComp.__prefab.__id__ === 'number') {
removedIds.add(matchedComp.__prefab.__id__);
}
cleanupRootTargetOverrides(elements, rootId, removedIds);
return nodeId;
}
module.exports = { execRemoveComponent };
+98
View File
@@ -0,0 +1,98 @@
// remove-node: 从父 _children(或 stub 的 mountedChildren)移除节点引用,
// 并递归断开整棵子树所有节点/组件的 _parent 引用。
// 节点元素本身保留在数组(保持其他 __id__ 稳定)。
// op: { op: 'remove-node', target: string|{id:N} }
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
const { disconnectSubtree, cleanupRootTargetOverrides, syncNestedRoots } = require('../id-utils.js');
function execRemoveNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { target: targetSelector } = op;
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'remove-node');
if (!targetNode._parent || typeof targetNode._parent.__id__ !== 'number') {
throw new Error(`editPrefab [remove-node]: 目标节点没有父节点,无法移除(根节点不能删除)`);
}
const parentId = targetNode._parent.__id__;
const parentNode = elements[parentId];
if (!parentNode || parentNode.__type__ !== 'cc.Node') {
throw new Error(`editPrefab [remove-node]: 父节点 __id__=${parentId} 不是有效 cc.Node`);
}
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = prefabInstance.mountedChildren.filter(
(r) => r.__id__ !== targetId
);
}
} else {
if (Array.isArray(parentNode._children)) {
parentNode._children = parentNode._children.filter(
(r) => r.__id__ !== targetId
);
}
}
// 收集整棵子树的所有 __id__(节点/组件/PrefabInfo/PrefabInstance)。
// 必须在 disconnectSubtree 之前——后者会清空 mountedChildren、置 pi.instance=null
// 之后就拿不到嵌套实例的关联对象了。
const subtreeIds = collectSubtreeIds(elements, targetId);
disconnectSubtree(elements, targetId);
// 软删后同步外层 PrefabInfo.nestedPrefabInstanceRoots,清掉孤儿 stub 引用
syncNestedRoots(elements, rootId);
// 清掉根 PrefabInfo.targetOverrides 中 source/target 指向被删子树的悬空条目。
// 外层脚本对嵌套 stub 内部组件/节点的引用(如 _passScoreView → scoreView)走 targetOverride
// 删了 stub 后这条 override 仍被根 PrefabInfo 引用 → 可达悬空引用,运行时解析会报错。
cleanupRootTargetOverrides(elements, rootId, subtreeIds);
return targetId;
}
// 收集子树所有相关 __id__:节点、其组件、_prefab(PrefabInfo)、instance(PrefabInstance)、
// 以及 mountedChildren 指向的嵌套子树。供 targetOverride 悬空判断用。
function collectSubtreeIds(elements, nodeId, acc) {
acc = acc || new Set();
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node' || acc.has(nodeId)) return acc;
acc.add(nodeId);
if (Array.isArray(node._children)) {
for (const c of node._children) {
if (c && typeof c.__id__ === 'number') collectSubtreeIds(elements, c.__id__, acc);
}
}
if (node._prefab && typeof node._prefab.__id__ === 'number') {
acc.add(node._prefab.__id__);
const pi = elements[node._prefab.__id__];
if (pi && pi.instance && typeof pi.instance.__id__ === 'number') {
acc.add(pi.instance.__id__);
const inst = elements[pi.instance.__id__];
if (inst && Array.isArray(inst.mountedChildren)) {
for (const mc of inst.mountedChildren) {
if (mc && typeof mc.__id__ === 'number') collectSubtreeIds(elements, mc.__id__, acc);
}
}
}
}
if (Array.isArray(node._components)) {
for (const c of node._components) {
if (c && typeof c.__id__ === 'number') acc.add(c.__id__);
}
}
return acc;
}
module.exports = { execRemoveNode };
+32
View File
@@ -0,0 +1,32 @@
// rename-node: 改节点 _name
// op: { op:'rename-node', node, name }
//
// 普通节点:直接改 node._name
// stub 节点:name 存在 PrefabInstance.propertyOverrides 而不是 node._name
// 走 setOverrideProperty(['_name']) 与 set-active 同模式
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execRenameNode(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, name } = op;
if (typeof name !== 'string' || name.length === 0) {
throw new Error(`editPrefab [rename-node]: name 必须是非空字符串`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'rename-node');
if (isStub(elements, node)) {
setOverrideProperty(prefabData, nodeId, ['_name'], name);
} else {
node._name = name;
}
return nodeId;
}
module.exports = { execRenameNode };
+75
View File
@@ -0,0 +1,75 @@
// reorder-children: 调整节点的 _children 顺序(影响 UI 渲染层级)
// op: { op:'reorder-children', node, order }
//
// order:子节点名字数组(必须包含全部 _children 的 name),按这个顺序重排
// 或 __id__ 数组:[{id:N}, {id:M}, ...]
//
// stub 节点暂不支持(mountedChildren 顺序场景少见)
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
function execReorderChildren(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, order } = op;
if (!Array.isArray(order) || order.length === 0) {
throw new Error(`editPrefab [reorder-children]: order 必须是非空数组`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reorder-children');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [reorder-children]: 节点 "${node._name}" 是 stubstub 子节点重排暂不支持`
);
}
if (!Array.isArray(node._children)) {
throw new Error(`editPrefab [reorder-children]: 节点 "${node._name}" 没有 _children`);
}
const childMap = new Map(); // key (name 或 id) -> child ref
for (const cref of node._children) {
if (typeof cref.__id__ !== 'number') continue;
const child = elements[cref.__id__];
if (!child) continue;
childMap.set(cref.__id__, cref);
if (typeof child._name === 'string' && child._name.length > 0) {
childMap.set(child._name, cref);
}
}
if (order.length !== node._children.length) {
throw new Error(
`editPrefab [reorder-children]: order 长度 ${order.length} ≠ _children 长度 ${node._children.length}(必须包含所有子节点)`
);
}
const newChildren = [];
const seen = new Set();
for (const item of order) {
let key;
if (typeof item === 'string') {
key = item;
} else if (item && typeof item.id === 'number') {
key = item.id;
} else {
throw new Error(`editPrefab [reorder-children]: order 元素必须是字符串名或 {id:N},收到 ${JSON.stringify(item)}`);
}
const ref = childMap.get(key);
if (!ref) {
throw new Error(`editPrefab [reorder-children]: order 中的 "${key}" 不在 _children 内`);
}
if (seen.has(ref.__id__)) {
throw new Error(`editPrefab [reorder-children]: order 中重复出现 "${key}"`);
}
seen.add(ref.__id__);
newChildren.push(ref);
}
node._children = newChildren;
return nodeId;
}
module.exports = { execReorderChildren };
+91
View File
@@ -0,0 +1,91 @@
// reparent: 把节点从原父节点下移到新父节点下(不复制,原节点搬家)
// op: { op:'reparent', node, parent, index? }
//
// 行为:
// 1. 从原 parent._children 数组里移除 node 引用
// 2. 把 node 引用 push 到新 parent._children(或按 index 插入指定位置)
// 3. 改 node._parent 指向新 parent
//
// 限制:
// - 不支持 stub 节点(嵌套 prefab 实例)作为 source 或 target
// stub 的父子关系存在 PrefabInstance.mountedChildren / nestedPrefabInstanceRoots
// 需要独立的 nested-reparent op,本 op 仅处理普通 inline 节点
// - node 不能是 prefab 根节点(rootId=1),根节点 _parent 必须为 null
// - parent 不能是 node 的后代(避免循环)
// - 不修改 PrefabInfo.fileId(节点身份不变,外部引用仍然有效)
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
/** node 是否是 parent(或其后代)的祖先 → 循环检测 */
function isAncestorOf(elements, ancestorId, candidateId) {
let cur = candidateId;
let safety = 0;
while (cur != null && safety++ < 10000) {
if (cur === ancestorId) return true;
const node = elements[cur];
if (!node || !node._parent) return false;
cur = node._parent.__id__;
}
return false;
}
function execReparent(prefabData, op) {
const { elements, rootId } = prefabData;
const { node: nodeSelector, parent: parentSelector, index } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reparent');
const { node: newParent, nodeId: newParentId } = resolveNode(prefabData, parentSelector, 'reparent');
// 根节点不能搬家
if (nodeId === rootId) {
throw new Error(`editPrefab [reparent]: 根节点(id=${rootId})不能 reparent,其 _parent 必须为 null`);
}
// stub 检查
if (isStub(elements, node)) {
throw new Error(`editPrefab [reparent]: source 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
}
if (isStub(elements, newParent)) {
throw new Error(`editPrefab [reparent]: target parent 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
}
// 同一节点不动
const oldParentId = node._parent ? node._parent.__id__ : null;
if (oldParentId === newParentId) {
// 仅 index 调整 → 走 reorder-children 更清晰;这里允许只换位(reorder 调整)
if (index === undefined) return nodeId;
}
// 循环检测:newParent 不能是 node 的后代
if (isAncestorOf(elements, nodeId, newParentId)) {
throw new Error(`editPrefab [reparent]: 循环引用——新父节点(id=${newParentId})是源节点(id=${nodeId})的后代`);
}
// 1. 从原 parent._children 移除
if (oldParentId != null) {
const oldParent = elements[oldParentId];
if (oldParent && Array.isArray(oldParent._children)) {
oldParent._children = oldParent._children.filter(
(c) => !c || c.__id__ !== nodeId
);
}
}
// 2. 加到新 parent._children
if (!Array.isArray(newParent._children)) newParent._children = [];
const ref = { __id__: nodeId };
if (typeof index === 'number' && index >= 0 && index < newParent._children.length) {
newParent._children.splice(index, 0, ref);
} else {
newParent._children.push(ref);
}
// 3. 改 node._parent
node._parent = { __id__: newParentId };
return nodeId;
}
module.exports = { execReparent };
@@ -0,0 +1,57 @@
// replace-nested-prefab: 替换 stub 节点(嵌套 prefab 实例)引用的外部 prefab asset。
// 改 PrefabInfo.asset.__uuid__;可选清空 PrefabInstance.propertyOverrides。
//
// 适用场景:
// 想把 ListItem.prefab 里某个嵌套子 prefab 从 OldPrefab 换成 NewPrefab,但
// 保留 stub 节点的父子关系、_prefab fileId 不变(即 ListItem 内的 __id__
// 引用稳定)。
//
// 注意:
// - propertyOverrides 里的 targetFileId 是按老 prefab 内部 fileId 写的,新
// prefab 通常没有对应 fileId。默认保留 overrides(编辑器加载时 skip 找不
// 到的 override,不报错);clearOverrides=true 显式清空更干净。
// - 不修改 PrefabInstance.fileId(这个是 stub 在外层 prefab 内的稳定标识,
// 跟外部 prefab 的 fileId 无关)。
//
// op: { op: 'replace-nested-prefab', target: string|{id:N}, prefabUuid: string, clearOverrides?: boolean }
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
function execReplaceNestedPrefab(prefabData, op) {
const { elements } = prefabData;
const { target: targetSelector, prefabUuid, clearOverrides } = op;
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
throw new Error(`editPrefab [replace-nested-prefab]: prefabUuid 必须是非空字符串`);
}
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'replace-nested-prefab');
if (!isStub(elements, targetNode)) {
throw new Error(`editPrefab [replace-nested-prefab]: 目标节点 [${targetId}] 不是嵌套 prefab stub(无 _prefab.instance`);
}
if (!targetNode._prefab || typeof targetNode._prefab.__id__ !== 'number') {
throw new Error(`editPrefab [replace-nested-prefab]: stub 节点 [${targetId}] 没有 _prefab 引用`);
}
const prefabInfo = elements[targetNode._prefab.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`editPrefab [replace-nested-prefab]: _prefab 指向的不是 cc.PrefabInfo`);
}
if (!prefabInfo.asset || typeof prefabInfo.asset !== 'object') {
throw new Error(`editPrefab [replace-nested-prefab]: PrefabInfo 缺 asset 字段`);
}
prefabInfo.asset.__uuid__ = prefabUuid.trim();
if (clearOverrides === true && prefabInfo.instance && typeof prefabInfo.instance.__id__ === 'number') {
const prefabInstance = elements[prefabInfo.instance.__id__];
if (prefabInstance && Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
}
}
module.exports = { execReplaceNestedPrefab };
+127
View File
@@ -0,0 +1,127 @@
// reset-overrides: 清除 stub 节点的 propertyOverrides(回滚到嵌套 prefab 默认值)
// op: { op:'reset-overrides', node, property?, componentType?, subNode?, all? }
//
// 调用形态:
// 1) all=true:清空 stub 的整个 propertyOverrides 数组(一键回滚)
// 不能同时指定 property / componentType
// 2) property(无 componentType):清匹配 stub 节点字段 override
// target = stub 自身 fileIdpropertyPath = [property]
// 常见字段 _lpos / _name / _active / _lscale 等
// 3) property + componentType:清嵌套内某组件字段 override
// subNode 用于嵌套 prefab 内同类型组件消歧(同 set-nested-component-field
//
// 移除的 CCPropertyOverrideInfo / TargetInfo 作为 orphan 留在 elements
// 保持其他 __id__ 稳定(与 remove-node / remove-component 同策略)。
//
// 幂等:未找到匹配 override 不报错(缺省静默,CC3_MCP_DEBUG=1 时打 warn)。
'use strict';
const { isStub, resolveNode, normalizeComponentType } = require('../helpers.js');
const { getNestedCompFileId, getNestedNodeFileId } = require('../nested.js');
function execResetOverrides(prefabData, op) {
const { elements } = prefabData;
const {
node: nodeSelector,
property,
componentType: rawComponentType,
subNode = null,
all = false,
} = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reset-overrides');
if (!isStub(elements, node)) {
throw new Error(
`editPrefab [reset-overrides]: 节点 "${node._name || nodeId}" 不是 stub,普通节点没有 propertyOverrides`
);
}
const prefabInfo = elements[node._prefab.__id__];
const prefabInstance = elements[prefabInfo.instance.__id__];
const stubFileId = prefabInfo.fileId;
// 模式 1:清空全部
if (all) {
if (property !== undefined || rawComponentType !== undefined) {
throw new Error(
`editPrefab [reset-overrides]: all=true 时禁止同时提供 property / componentType`
);
}
if (Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
return nodeId;
}
// 模式 2/3:按 propertyPath 匹配单条
if (property === undefined) {
throw new Error(
`editPrefab [reset-overrides]: 必须提供 property,或显式 all=true 清空全部`
);
}
if (typeof property !== 'string' && !Array.isArray(property)) {
throw new Error(`editPrefab [reset-overrides]: property 必须是字符串或数组`);
}
const propertyPath = Array.isArray(property) ? property : [property];
// 决定要匹配的 localID[0]
// 节点字段 override(无 componentType):嵌套 prefab 内根节点 fileId 是 Cocos 运行时
// 实际识别的 key(见 overrides.js 地雷 3)。
let targetFileId;
if (rawComponentType) {
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
targetFileId = getNestedCompFileId(prefabData.resolverStartPath, elements, nodeId, componentType, subNode);
} else {
if (subNode !== null && subNode !== undefined) {
throw new Error(
`editPrefab [reset-overrides]: subNode 必须与 componentType 一起用(节点字段 override 无嵌套子节点定位)`
);
}
targetFileId = getNestedNodeFileId(prefabData.resolverStartPath, elements, nodeId, null);
}
if (!Array.isArray(prefabInstance.propertyOverrides) || prefabInstance.propertyOverrides.length === 0) {
return nodeId; // 无 override 数组,幂等返回
}
const remaining = [];
let removed = 0;
for (const ref of prefabInstance.propertyOverrides) {
if (!ref || typeof ref.__id__ !== 'number') {
remaining.push(ref);
continue;
}
const info = elements[ref.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') {
remaining.push(ref);
continue;
}
const tiRef = info.targetInfo;
const ti = tiRef && typeof tiRef.__id__ === 'number' ? elements[tiRef.__id__] : null;
if (!ti || !Array.isArray(ti.localID) || ti.localID[0] !== targetFileId) {
remaining.push(ref);
continue;
}
if (!Array.isArray(info.propertyPath) || info.propertyPath.length !== propertyPath.length) {
remaining.push(ref);
continue;
}
if (info.propertyPath.every((p, i) => p === propertyPath[i])) {
removed++;
continue;
}
remaining.push(ref);
}
if (removed === 0 && process.env.CC3_MCP_DEBUG) {
console.warn(
`[reset-overrides] stub [${nodeId}]: 未找到匹配 propertyPath=${JSON.stringify(propertyPath)} target=${targetFileId} 的 override(无操作)`
);
}
prefabInstance.propertyOverrides = remaining;
return nodeId;
}
module.exports = { execResetOverrides };
+28
View File
@@ -0,0 +1,28 @@
// set-active: 设置节点 _active
// op: { op, node, active }
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execSetActive(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, active } = op;
if (typeof active !== 'boolean') {
throw new Error(`editPrefab [set-active]: active 必须是布尔值`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-active');
if (isStub(elements, node)) {
setOverrideProperty(prefabData, id, ['_active'], active);
} else {
node._active = active;
}
return id;
}
module.exports = { execSetActive };
+117
View File
@@ -0,0 +1,117 @@
// set-anchor: cc.UITransform 锚点便捷写法 + 自动补偿 lpos
// op: { op:'set-anchor', node, x?, y?, compensatePosition? }
//
// - x / y 为新 anchor 值(0~1),任一缺省则保留原值
// - compensatePosition: true 时按 anchor 差值 * 节点 size 自动补偿 lpos
// 补偿公式:lpos.x += width * (newAnchorX - oldAnchorX)
// lpos.y += height * (newAnchorY - oldAnchorY)
// 场景:改 anchor 又想保持节点视觉位置不动
// - stub 节点:_anchorPoint 走 PrefabInstance.propertyOverrides 写嵌套 UITransform
// compensate 时 _lpos 走 stub 节点自身的 propertyOverrides(节点字段)。
// oldA / size 从嵌套 prefab 默认值读(不查 propertyOverrides 历史值)。
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
const { setOverrideProperty } = require('../../overrides.js');
const { parsePrefab } = require('../../parse.js');
const { resolveUuidToPath } = require('../../uuid-resolver.js');
// 从嵌套 prefab 内读 root UITransform 的 _anchorPoint / _contentSize(默认值)
function _readNestedUITransform(hostPath, elements, stubNodeId) {
const stub = elements[stubNodeId];
const pi = elements[stub._prefab.__id__];
const nestedUuid = pi.asset.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (el && el.__type__ === 'cc.UITransform') {
const a = el._anchorPoint || { x: 0.5, y: 0.5 };
const s = el._contentSize || { width: 0, height: 0 };
return {
anchor: { x: a.x || 0, y: a.y || 0 },
size: { width: s.width || 0, height: s.height || 0 },
};
}
}
return { anchor: { x: 0.5, y: 0.5 }, size: { width: 0, height: 0 } };
}
function execSetAnchor(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, x, y, compensatePosition = false } = op;
if (x === undefined && y === undefined) {
throw new Error(`editPrefab [set-anchor]: 至少提供 x 或 y 之一`);
}
if (x !== undefined && (typeof x !== 'number' || x < 0 || x > 1)) {
throw new Error(`editPrefab [set-anchor]: x 必须是 0~1 数字`);
}
if (y !== undefined && (typeof y !== 'number' || y < 0 || y > 1)) {
throw new Error(`editPrefab [set-anchor]: y 必须是 0~1 数字`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-anchor');
if (isStub(elements, node)) {
const { anchor: oldA, size } = _readNestedUITransform(
prefabData.resolverStartPath, elements, nodeId
);
const newA = {
__type__: 'cc.Vec2',
x: x === undefined ? oldA.x : x,
y: y === undefined ? oldA.y : y,
};
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_anchorPoint'], newA);
if (compensatePosition) {
// stub 节点 _lpos 改值走自身 propertyOverrides(节点字段,不是组件字段)
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
const dx = size.width * (newA.x - oldA.x);
const dy = size.height * (newA.y - oldA.y);
const newLpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: lpos.z || 0,
};
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
}
return nodeId;
}
const ut = findComponent(elements, node, 'cc.UITransform');
if (!ut) {
throw new Error(`editPrefab [set-anchor]: 节点 "${node._name}" 上没有 cc.UITransform`);
}
const oldA = ut._anchorPoint || { x: 0.5, y: 0.5 };
const newA = {
__type__: 'cc.Vec2',
x: x === undefined ? oldA.x : x,
y: y === undefined ? oldA.y : y,
};
ut._anchorPoint = newA;
if (compensatePosition) {
const size = ut._contentSize || { width: 0, height: 0 };
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
const dx = (size.width || 0) * (newA.x - oldA.x);
const dy = (size.height || 0) * (newA.y - oldA.y);
node._lpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: lpos.z || 0,
};
}
return nodeId;
}
module.exports = { execSetAnchor };
+52
View File
@@ -0,0 +1,52 @@
// set-button: 批量设置节点上 cc.Button 的常用字段
// op: {
// op: 'set-button',
// node,
// interactable?: boolean
// transition?: 0=NONE 1=COLOR 2=SPRITE 3=SCALE
// zoomScale?: numbertransition=SCALE 时的缩放比例)
// duration?: number(过渡动画时长,秒)
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
interactable: '_interactable',
transition: '_transition',
zoomScale: '_zoomScale',
duration: '_duration',
};
function execSetButton(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-button');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-button]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Button');
if (!comp) {
throw new Error(`editPrefab [set-button]: 节点 "${node._name}" 上找不到 cc.Button 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-button]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetButton };
@@ -0,0 +1,44 @@
// set-component-enabled: 改组件 _enabled
// op: { op:'set-component-enabled', node, componentType, enabled }
//
// 普通节点直接改 comp._enabled
// stub 节点:写 PrefabInstance.propertyOverrides(与 set-nested-component-field 同模式)
'use strict';
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetComponentEnabled(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawCompType, enabled, subNode = null } = op;
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
throw new Error(`editPrefab [set-component-enabled]: componentType 必须是非空字符串`);
}
if (typeof enabled !== 'boolean') {
throw new Error(`editPrefab [set-component-enabled]: enabled 必须是布尔值`);
}
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-enabled');
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_enabled'], enabled);
} else {
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(
`editPrefab [set-component-enabled]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
);
}
comp._enabled = enabled;
}
return nodeId;
}
module.exports = { execSetComponentEnabled };
+68
View File
@@ -0,0 +1,68 @@
// set-component-field: 普通节点改任意组件字段
// op: { op:'set-component-field', node, componentType, property, value }
//
// set-nested-component-field 只覆盖 stub 节点;本 op 是普通节点版本。
//
// - node: 普通节点选择器(不能是 stub)
// - property: 字段名,可以是字符串(顶层字段)或字符串数组(嵌套路径)
// 例:'_string' / ['_color', 'r'] / ['_anchorPoint', 'x']
// - value: 任意 JSON-serializable 值;改 cc.Vec2 / cc.Vec3 / cc.Size 时需带 __type__
'use strict';
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
function execSetComponentField(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawComponentType, property, value } = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-component-field]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
if (
!(typeof property === 'string' && property.length > 0) &&
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string' && p.length > 0))
) {
throw new Error(`editPrefab [set-component-field]: property 必须是非空字符串或非空字符串数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [set-component-field]: value 不能是 undefined`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-field');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [set-component-field]: 节点 "${node._name}" 是 stub 代理,请用 set-nested-component-field`
);
}
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(
`editPrefab [set-component-field]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
);
}
// 单层 property
if (typeof property === 'string') {
comp[property] = value;
return nodeId;
}
// 嵌套 property 路径:逐层下钻,路径中断时报错(不自动建中间对象,避免悄悄改坏结构)
let cursor = comp;
for (let i = 0; i < property.length - 1; i++) {
const k = property[i];
if (cursor[k] === null || cursor[k] === undefined || typeof cursor[k] !== 'object') {
throw new Error(
`editPrefab [set-component-field]: 路径 ${property.slice(0, i + 1).join('.')} 不是对象(实际值 ${JSON.stringify(cursor[k])}),无法继续下钻`
);
}
cursor = cursor[k];
}
cursor[property[property.length - 1]] = value;
return nodeId;
}
module.exports = { execSetComponentField };
+138
View File
@@ -0,0 +1,138 @@
// set-component-ref: 把节点上指定组件的 @property 字段序列化指向另一节点/组件
// op: { op: 'set-component-ref', node, componentType, property, refNode, refType?, refSubNode? }
//
// - node: 持有目标组件的节点
// - componentType: 目标组件 ccclass 名
// - property: @property 字段名,支持以下格式:
// "_role" → 普通字段,propertyPath: ["_role"]
// "_items.0" → 数组字段第 0 项,propertyPath: ["_items", 0]
// "_items[0]" → 同上,[] 写法等价
// - refNode: 引用指向的节点(字符串名或 {id:N})
// - refType: 缺省或 'cc.Node' 表示字段指向节点本身;否则指向 refNode 上该类型的第一个组件
// - refSubNode: refNode 是 stub 时指定嵌套 prefab 内的子节点名(可选)
'use strict';
const { ref } = require('../../primitives.js');
const { normalizeComponentType, isStub, indexOfNode, resolveNode, findComponent } = require('../helpers.js');
const { resolveLocalIdChain, addRootTargetOverride } = require('../nested.js');
// ─── propertyPath 解析 ────────────────────────────────────────
//
// 把 property 字符串拆成 propertyPath 数组,传给 addRootTargetOverride
// 和 setByPropertyPath,统一用数字表示数组索引(Cocos 引擎序列化格式)。
//
// 例:
// "_role" → ["_role"]
// "_items.0" → ["_items", 0]
// "_items[2]" → ["_items", 2]
function parsePropertyPath(property) {
// [] 下标 → . 分隔:_items[0] → _items.0
const normalized = property.replace(/\[(\d+)\]/g, '.$1');
const parts = normalized.split('.').filter(p => p.length > 0);
return parts.map(p => {
const n = Number(p);
// 纯整数字符串(不含前导零的,如 "0" "1" "10")→ 数字索引
return Number.isInteger(n) && String(n) === p ? n : p;
});
}
// ─── 按 propertyPath 多层路径赋值 ─────────────────────────────
//
// 支持数组索引(数字 key)和对象属性(字符串 key)的任意组合。
// 中间层不存在时:下一段是数字 → 创建数组;否则 → 创建对象。
function setByPropertyPath(obj, pathParts, value) {
if (pathParts.length === 1) {
obj[pathParts[0]] = value;
return;
}
const head = pathParts[0];
const tail = pathParts.slice(1);
if (!obj[head] || typeof obj[head] !== 'object') {
obj[head] = typeof tail[0] === 'number' ? [] : {};
}
setByPropertyPath(obj[head], tail, value);
}
function execSetComponentRef(prefabData, op) {
const { elements, rootId } = prefabData;
const {
node: nodeSelector,
componentType: rawComponentType,
property,
refNode: refNodeSelector,
refType: rawRefType,
refSubNode = null,
} = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-component-ref]: componentType 必须是非空字符串`);
}
if (typeof property !== 'string' || property.length === 0) {
throw new Error(`editPrefab [set-component-ref]: property 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
const refType = rawRefType
? normalizeComponentType(rawRefType, prefabData.resolverStartPath)
: rawRefType;
// 解析 property 为 propertyPath 数组(支持 "_items.0" / "_items[0]" 数组写法)
const propertyPath = parsePropertyPath(property);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-ref');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [set-component-ref]: 源节点 "${node._name}" 是 stub(嵌套 prefab 代理),` +
`对 stub 自身组件挂 @property 字段的场景(需要 TargetOverrideInfo.sourceInfo)暂不支持`
);
}
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(`editPrefab [set-component-ref]: 节点 "${node._name}" 未挂 "${componentType}" 组件`);
}
const { node: refNode, nodeId: refNodeId } = resolveNode(prefabData, refNodeSelector, 'set-component-ref');
// refNode 是 stub 代理 → 走 cc.TargetOverrideInfo 跨 nested 挂载
if (isStub(elements, refNode)) {
const targetCompType = !refType || refType === 'cc.Node' ? 'cc.Node' : refType;
const localIdChain = resolveLocalIdChain(
prefabData.resolverStartPath,
elements,
refNodeId,
targetCompType,
refSubNode
);
const compId = indexOfNode(elements, comp);
if (compId < 0) {
throw new Error(`editPrefab [set-component-ref]: 源组件索引失败(内部错误)`);
}
// 传入 propertyPath 数组(支持 ["_items", 0] 数组元素挂载)
addRootTargetOverride(prefabData, rootId, compId, propertyPath, refNodeId, localIdChain);
return nodeId;
}
// 普通节点:按 propertyPath 多层路径赋值(支持数组字段 _items[0]/_items[1]...
if (!refType || refType === 'cc.Node') {
setByPropertyPath(comp, propertyPath, ref(refNodeId));
} else {
const refComp = findComponent(elements, refNode, refType);
if (!refComp) {
throw new Error(`editPrefab [set-component-ref]: 引用节点 "${refNode._name}" 未挂 "${refType}" 组件`);
}
const refCompId = indexOfNode(elements, refComp);
if (refCompId < 0) {
throw new Error(`editPrefab [set-component-ref]: 引用组件索引失败(内部错误)`);
}
setByPropertyPath(comp, propertyPath, ref(refCompId));
}
return nodeId;
}
module.exports = { execSetComponentRef };
+56
View File
@@ -0,0 +1,56 @@
// set-editbox: 批量设置节点上 cc.EditBox 的常用字段
// op: {
// op: 'set-editbox',
// node,
// inputMode?: 0=ANY 1=EMAIL_ADDR 2=NUMERIC 3=PHONE_NUMBER 4=URL 5=DECIMAL 6=SINGLE_LINE
// maxLength?: number-1 无限制)
// placeholder?: string
// string?: string(当前文字值)
// inputFlag?: 0=DEFAULT 1=PASSWORD 2=SENSITIVE 3=INITIAL_CAPS_WORD 4=INITIAL_CAPS_SENTENCE 5=INITIAL_CAPS_ALL_CHARACTERS
// fontSize?: number
// }
//
// 至少提供一个可选字段,否则 op 无意义。
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
inputMode: '_inputMode',
maxLength: '_maxLength',
placeholder: '_placeholder',
string: '_string',
inputFlag: '_inputFlag',
fontSize: '_fontSize',
};
function execSetEditBox(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-editbox');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-editbox]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.EditBox');
if (!comp) {
throw new Error(`editPrefab [set-editbox]: 节点 "${node._name}" 上找不到 cc.EditBox 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(`editPrefab [set-editbox]: 至少需要提供一个字段(inputMode/maxLength/placeholder/string/inputFlag/fontSize`);
}
return nodeId;
}
module.exports = { execSetEditBox };
+39
View File
@@ -0,0 +1,39 @@
// set-label-text: 设置节点上 cc.Label 的 _string
// op: { op, node, text, labelNode? }
//
// 普通节点:直接修改 cc.Label._string
// stub 节点:从嵌套 prefab 中找 cc.Label 的 CompPrefabInfo.fileId
// 写入 PrefabInstance.propertyOverrides
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetLabelText(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, text, labelNode = null } = op;
if (typeof text !== 'string') {
throw new Error(`editPrefab [set-label-text]: text 必须是字符串`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-label-text');
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, id, 'cc.Label', labelNode
);
setStubCompOverride(prefabData, id, compFileId, ['_string'], text);
} else {
const comp = findComponent(elements, node, 'cc.Label');
if (!comp) {
throw new Error(`editPrefab [set-label-text]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Label 组件`);
}
comp._string = text;
}
return id;
}
module.exports = { execSetLabelText };
+64
View File
@@ -0,0 +1,64 @@
// set-label: 批量设置节点上 cc.Label 的常用字段
// op: {
// op: 'set-label',
// node,
// text?: string_string
// fontSize?: number
// lineHeight?: number0 = auto
// overflow?: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE
// horizontalAlign?: 0=LEFT 1=CENTER 2=RIGHT
// verticalAlign?: 0=TOP 1=CENTER 2=BOTTOM
// bold?: boolean
// italic?: boolean
// underline?: boolean
// enableWrapText?: boolean_enableWrapText
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
text: '_string',
fontSize: '_fontSize',
lineHeight: '_lineHeight',
overflow: '_overflow',
horizontalAlign: '_horizontalAlign',
verticalAlign: '_verticalAlign',
bold: '_isBold',
italic: '_isItalic',
underline: '_isUnderline',
enableWrapText: '_enableWrapText',
};
function execSetLabel(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-label');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-label]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Label');
if (!comp) {
throw new Error(`editPrefab [set-label]: 节点 "${node._name}" 上找不到 cc.Label 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-label]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetLabel };
+68
View File
@@ -0,0 +1,68 @@
// set-layout: 批量设置节点上 cc.Layout 的常用字段
// op: {
// op: 'set-layout',
// node,
// type?: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID
// resizeMode?: 0=NONE 1=CHILDREN 2=CONTAINER
// paddingLeft?: number
// paddingRight?: number
// paddingTop?: number
// paddingBottom?: number
// spacingX?: number
// spacingY?: number
// startAxis?: 0=HORIZONTAL 1=VERTICALGRID 模式)
// constraint?: 0=NONE 1=FIXED_ROW 2=FIXED_COLGRID 模式)
// constraintNum?: numberconstraint 对应的行数/列数)
// affectedByScale?: boolean
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
type: '_layoutType',
resizeMode: '_resizeMode',
paddingLeft: '_paddingLeft',
paddingRight: '_paddingRight',
paddingTop: '_paddingTop',
paddingBottom: '_paddingBottom',
spacingX: '_spacingX',
spacingY: '_spacingY',
startAxis: '_startAxis',
constraint: '_constraint',
constraintNum: '_constraintNum',
affectedByScale: '_affectedByScale',
};
function execSetLayout(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-layout');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-layout]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Layout');
if (!comp) {
throw new Error(`editPrefab [set-layout]: 节点 "${node._name}" 上找不到 cc.Layout 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-layout]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetLayout };
@@ -0,0 +1,52 @@
// set-nested-component-field: 改 stub 节点展开后内部某组件的字段
// op: { op, node, componentType, property, value, subNode? }
//
// - node: stub 节点名或 {id}(必须是 stub 代理)
// - componentType: 子 prefab 里目标组件类型(如 'cc.Label'
// - property: 字段名(如 '_string' / '_spriteFrame' / 'interactable');支持嵌套路径数组
// - value: 要写入的值(raw JSON
// - subNode: 子 prefab 内部节点名(可选,默认 null = 子 prefab root 上第一个匹配组件)
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetNestedComponentField(prefabData, op) {
const { elements } = prefabData;
const {
node: nodeSelector,
componentType: rawComponentType,
property,
value,
subNode = null,
} = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-nested-component-field]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
if (!property || (typeof property !== 'string' && !Array.isArray(property))) {
throw new Error(`editPrefab [set-nested-component-field]: property 必须是字符串或数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [set-nested-component-field]: value 不能是 undefined`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-nested-component-field');
if (!isStub(elements, node)) {
throw new Error(
`editPrefab [set-nested-component-field]: 节点 "${node._name}" 不是 stub 代理——` +
`普通节点直接用 set-label-text/set-sprite-frame 或在代码里改组件字段`
);
}
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
);
const propertyPath = Array.isArray(property) ? property : [property];
setStubCompOverride(prefabData, nodeId, compFileId, propertyPath, value);
return nodeId;
}
module.exports = { execSetNestedComponentField };
+42
View File
@@ -0,0 +1,42 @@
// set-node-color: 设置节点的 _colorcc.Node 自身颜色,影响整棵子树透明度和染色)
// op: {
// op: 'set-node-color',
// node,
// r?: number (0-255)
// g?: number (0-255)
// b?: number (0-255)
// a?: number (0-255)
// }
//
// 示例:{ op:'set-node-color', node:'btnClose', a:0 } // 全透明
// { op:'set-node-color', node:'bg', r:255, g:200, b:100, a:255 }
'use strict';
const { resolveNode, isStub } = require('../helpers.js');
function execSetNodeColor(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, r, g, b, a } = op;
if (r === undefined && g === undefined && b === undefined && a === undefined) {
throw new Error(`editPrefab [set-node-color]: 至少提供一个分量(r/g/b/a)`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-node-color');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-node-color]: 节点是 stub,请用 set-nested-component-field 改节点颜色分量`);
}
if (!node._color || typeof node._color !== 'object') {
node._color = { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 };
}
if (r !== undefined) node._color.r = r;
if (g !== undefined) node._color.g = g;
if (b !== undefined) node._color.b = b;
if (a !== undefined) node._color.a = a;
return nodeId;
}
module.exports = { execSetNodeColor };
+29
View File
@@ -0,0 +1,29 @@
// set-position: 设置节点本地位置
// op: { op, node, x, y, z? }
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execSetPosition(prefabData, op) {
const { elements } = prefabData;
const { node: nodeId, x, y, z = 0 } = op;
if (typeof x !== 'number' || typeof y !== 'number') {
throw new Error(`editPrefab [set-position]: x/y 必须是数字`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeId, 'set-position');
const newLpos = { __type__: 'cc.Vec3', x, y, z };
if (isStub(elements, node)) {
setOverrideProperty(prefabData, id, ['_lpos'], newLpos);
} else {
node._lpos = newLpos;
}
return id;
}
module.exports = { execSetPosition };
+52
View File
@@ -0,0 +1,52 @@
// set-richtext: 批量设置节点上 cc.RichText 的常用字段
// op: {
// op: 'set-richtext',
// node,
// text?: string_string,支持 BBCode 标签)
// maxWidth?: number0 = 不限制)
// fontSize?: number
// lineHeight?: number
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
text: '_string',
maxWidth: '_maxWidth',
fontSize: '_fontSize',
lineHeight: '_lineHeight',
};
function execSetRichText(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-richtext');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-richtext]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.RichText');
if (!comp) {
throw new Error(`editPrefab [set-richtext]: 节点 "${node._name}" 上找不到 cc.RichText 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-richtext]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetRichText };
+78
View File
@@ -0,0 +1,78 @@
// set-size: 改 cc.UITransform 内容尺寸
// op: { op:'set-size', node, width?, height? }
//
// width / height 任一缺省则保留原值
// stub 节点:走 PrefabInstance.propertyOverrides 写嵌套 UITransform._contentSize
// - 任一缺省时从嵌套 prefab 读默认值补齐
// - 不读 propertyOverrides 里的历史 override(少见且增加复杂度)
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
const { parsePrefab } = require('../../parse.js');
const { resolveUuidToPath } = require('../../uuid-resolver.js');
// 从嵌套 prefab 内读 root UITransform 默认 _contentSize(用作 stub set-size 的缺省补齐)
function _readNestedUITransformSize(hostPath, elements, stubNodeId) {
const stub = elements[stubNodeId];
const pi = elements[stub._prefab.__id__];
const nestedUuid = pi.asset.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (el && el.__type__ === 'cc.UITransform') {
const s = el._contentSize || { width: 0, height: 0 };
return { width: s.width || 0, height: s.height || 0 };
}
}
return { width: 0, height: 0 };
}
function execSetSize(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, width, height } = op;
if (width === undefined && height === undefined) {
throw new Error(`editPrefab [set-size]: 至少提供 width 或 height 之一`);
}
if (width !== undefined && (typeof width !== 'number' || width < 0)) {
throw new Error(`editPrefab [set-size]: width 必须是非负数字`);
}
if (height !== undefined && (typeof height !== 'number' || height < 0)) {
throw new Error(`editPrefab [set-size]: height 必须是非负数字`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-size');
if (isStub(elements, node)) {
const oldSize = _readNestedUITransformSize(prefabData.resolverStartPath, elements, nodeId);
const newSize = {
__type__: 'cc.Size',
width: width === undefined ? oldSize.width : width,
height: height === undefined ? oldSize.height : height,
};
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_contentSize'], newSize);
return nodeId;
}
const ut = findComponent(elements, node, 'cc.UITransform');
if (!ut) {
throw new Error(`editPrefab [set-size]: 节点 "${node._name}" 上没有 cc.UITransform`);
}
const oldSize = ut._contentSize || { width: 0, height: 0 };
ut._contentSize = {
__type__: 'cc.Size',
width: width === undefined ? oldSize.width : width,
height: height === undefined ? oldSize.height : height,
};
return nodeId;
}
module.exports = { execSetSize };
+36
View File
@@ -0,0 +1,36 @@
// set-sprite-frame: 设置节点上 cc.Sprite 的 _spriteFrame uuid
// op: { op, node, uuid, spriteNode? }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetSpriteFrame(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, uuid, spriteNode = null } = op;
if (typeof uuid !== 'string') {
throw new Error(`editPrefab [set-sprite-frame]: uuid 必须是字符串`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-sprite-frame');
const newFrame = { __uuid__: uuid, __expectedType__: 'cc.SpriteFrame' };
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, id, 'cc.Sprite', spriteNode
);
setStubCompOverride(prefabData, id, compFileId, ['_spriteFrame'], newFrame);
} else {
const comp = findComponent(elements, node, 'cc.Sprite');
if (!comp) {
throw new Error(`editPrefab [set-sprite-frame]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Sprite 组件`);
}
comp._spriteFrame = newFrame;
}
return id;
}
module.exports = { execSetSpriteFrame };
+52
View File
@@ -0,0 +1,52 @@
// set-sprite: 批量设置节点上 cc.Sprite 的常用字段(不含 spriteFrame,用 set-sprite-frame
// op: {
// op: 'set-sprite',
// node,
// sizeMode?: 0=CUSTOM 1=TRIMMED 2=RAW
// type?: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH
// grayscale?: boolean_useGrayscale
// trim?: boolean_isTrimmedMode
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
sizeMode: '_sizeMode',
type: '_type',
grayscale: '_useGrayscale',
trim: '_isTrimmedMode',
};
function execSetSprite(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-sprite');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-sprite]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Sprite');
if (!comp) {
throw new Error(`editPrefab [set-sprite]: 节点 "${node._name}" 上找不到 cc.Sprite 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-sprite]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})。更换图片用 set-sprite-frame`
);
}
return nodeId;
}
module.exports = { execSetSprite };
+20
View File
@@ -0,0 +1,20 @@
// sync-nested-roots: 重建根 PrefabInfo.nestedPrefabInstanceRoots,剔除「删了一半」
// 残留的悬空嵌套实例根——节点的父引用已被移除(_parent=null)但根 PrefabInfo 里
// 对它的登记还在,导致残留嵌套 prefab 的 asset 仍被当依赖加载(运行时 404 / 加载失败)。
//
// 只重写 nestedPrefabInstanceRoots 数组(依据当前「有父 + 有 PrefabInfo + instance」的
// 实际 stub 节点),不删 elements、不动其他 __id__、不产生 null 槽;被孤立的残留对象
// 成为不可达 orphan(软删策略,无害)。
//
// op: { op: 'sync-nested-roots' } 无参数,作用于 prefab 根。
'use strict';
const { syncNestedRoots } = require('../id-utils.js');
function execSyncNestedRoots(prefabData) {
const { elements, rootId } = prefabData;
syncNestedRoots(elements, rootId);
return rootId;
}
module.exports = { execSyncNestedRoots };
+97
View File
@@ -0,0 +1,97 @@
// ============================================================
// 确定性 UUID / fileId 生成器(纯 CJS,零三方依赖)
// 与 tools/fgui2cc3/src/utils/DeterministicId.ts byte-for-byte 对齐
// ============================================================
'use strict';
const { createHash } = require('node:crypto');
/**
* 基于种子字符串生成确定性 UUID v4 格式
* 使用 SHA-256 哈希前 16 字节,设置 version=4 和 variant=RFC4122
* @param {string} seed
* @returns {string}
*/
function deterministicUUID(seed) {
const hash = createHash('sha256').update(seed).digest();
// 设置 version (4) 和 variant (RFC 4122)
hash[6] = (hash[6] & 0x0f) | 0x40;
hash[8] = (hash[8] & 0x3f) | 0x80;
const hex = hash.subarray(0, 16).toString('hex');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
/**
* 基于种子字符串生成确定性 fileId(CC3 使用 base64 编码的 16 字节)
* @param {string} seed
* @returns {string}
*/
function deterministicFileId(seed) {
const hash = createHash('sha256').update(seed).digest();
return hash.subarray(0, 16).toString('base64').replace(/=+$/, '');
}
/**
* 创建一个带自增计数器的 fileId 生成器
* 同一组件内每个节点/组件使用递增的序号保证唯一性和稳定性
* @param {string} baseSeed
* @returns {() => string}
*/
function createFileIdGenerator(baseSeed) {
let counter = 0;
return () => deterministicFileId(`${baseSeed}#fid#${counter++}`);
}
// ============================================================
// Cocos Creator 压缩 classId 编解码
// 标准 base64A-Z a-z 0-9 + /),前 5 hex 保留,每 3 hex → 2 base64
// 产物格式:23 字符 = 5 hex + 18 base64
// ============================================================
const _BASE64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/**
* 把 32/36 位 uuid 压缩为 Cocos ccclass id23 字符)。
*
* @param {string} uuid 标准 uuid(含或不含 dash
* @returns {string} 23 字符压缩 classId
*/
function compressUuid(uuid) {
if (typeof uuid !== 'string') {
throw new Error(`compressUuid: uuid 必须是字符串,收到 ${typeof uuid}`);
}
const hex = uuid.replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(hex)) {
throw new Error(`compressUuid: 输入不是合法 uuid${uuid}`);
}
let out = hex.slice(0, 5);
for (let i = 5; i < 32; i += 3) {
const code = parseInt(hex.substr(i, 3), 16);
out += _BASE64_STD[(code >> 6) & 0x3f];
out += _BASE64_STD[code & 0x3f];
}
return out;
}
/**
* 判断字符串是否是 Cocos 压缩 classId 格式(23 字符,前 5 hex + 后 18 base64)。
* 不做语义合法性校验(不查 classId 是否对应真实类)。
*/
function isCompressedClassId(str) {
return typeof str === 'string' && /^[0-9a-f]{5}[A-Za-z0-9+/]{18}$/.test(str);
}
module.exports = {
deterministicUUID,
deterministicFileId,
createFileIdGenerator,
compressUuid,
isCompressedClassId,
};
+27
View File
@@ -0,0 +1,27 @@
'use strict';
// 统一入口:re-export 所有公开 API
const { parsePrefab } = require('./parse.js');
const { writePrefab } = require('./write.js');
const { editPrefab } = require('./editor/index.js');
const { queryPrefab } = require('./query/index.js');
const { setOverrideProperty, listOverrides } = require('./overrides.js');
const { deterministicUUID, deterministicFileId, createFileIdGenerator } = require('./id.js');
const primitives = require('./primitives.js');
const animPrimitives = require('./anim-primitives.js');
module.exports = {
parsePrefab,
writePrefab,
editPrefab,
queryPrefab,
setOverrideProperty,
listOverrides,
deterministicUUID,
deterministicFileId,
createFileIdGenerator,
...primitives,
// .anim 文件对象构建原语(AnimationClip / Track / Curve / Channel),
// parse/write 复用 parsePrefab / writePrefab(同为 JSON 数组 + __id__ 引用格式)
anim: animPrimitives,
};
+311
View File
@@ -0,0 +1,311 @@
// ============================================================
// CC3 Prefab PrefabInstance Override 读写(纯 CJS,零三方依赖)
//
// 三个已知地雷:
// 地雷 1stub 节点(嵌套 prefab 根节点)本身的字段写入无效。
// 必须走 PrefabInstance.propertyOverrides,以 CCPropertyOverrideInfo
// + TargetInfo 结构写入,才能被 Cocos 编辑器识别。
// 地雷 2:新增嵌套 stub 节点后,宿主 prefab 根节点的 cc.PrefabInfo
// 的 nestedPrefabInstanceRoots 必须同步追加该 stub 节点的 __id__
// 否则 Cocos 加载时会忽略该嵌套实例的 override。
// 地雷 3propertyOverride.targetInfo.localID 必须是
// 「嵌套 prefab 内根节点的 cc.PrefabInfo.fileId」,
// 不是「外层 stub 的 cc.PrefabInfo.fileId」。
// Cocos 运行时按嵌套 prefab 内部 fileId 建 targetMap
// 外层 stub fileId 在 targetMap 里查不到。
// 早期 fgui→cc3 转出的 prefab 设计上让这两个 fileId 一致,
// 所以本工具早期版本用 stubFileId 巧合工作;新写或手编的
// 嵌套 prefab 一般两个 fileId 不同,必须读嵌套 prefab 拿真值。
//
// 本工具当前行为(2026-05-20 修后):
// - 写入:强制读嵌套 prefab 拿真值,解析失败抛错。
// - 自动矫正:识别旧 cli 写入的 stubFileId 条目,命中同
// propertyPath 时把 localID 改写为真值(一次性迁移)。
// 迁移完成后旧条目不复存在,listOverrides / reset-overrides
// 只识别真值,不再兼容历史格式。
// ============================================================
'use strict';
const { parsePrefab } = require('./parse.js');
const { resolveUuidToPath } = require('./uuid-resolver.js');
/**
* 查找 stub 节点对应的 PrefabInstance 对象
*
* stub 节点:__type__ = cc.Node_prefab 指向一个 cc.PrefabInfo
* 该 PrefabInfo.instance 指向 cc.PrefabInstance。
*
* @param {object[]} elements prefab 数组
* @param {number} stubId stub 节点的 __id__(数组下标)
* @returns {{ prefabInstance: object, prefabInstanceId: number, prefabInfo: object, prefabInfoId: number } | null}
*/
function _findStubPrefabInstance(elements, stubId) {
const stub = elements[stubId];
if (!stub || stub.__type__ !== 'cc.Node') return null;
const prefabRef = stub._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return null;
const prefabInfoId = prefabRef.__id__;
const prefabInfo = elements[prefabInfoId];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return null;
const instanceRef = prefabInfo.instance;
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return null;
const prefabInstanceId = instanceRef.__id__;
const prefabInstance = elements[prefabInstanceId];
if (!prefabInstance || prefabInstance.__type__ !== 'cc.PrefabInstance') return null;
return { prefabInstance, prefabInstanceId, prefabInfo, prefabInfoId };
}
/**
* 加载嵌套 prefab 拿其根节点的 cc.PrefabInfo.fileId。
* 这是 propertyOverride.targetInfo.localID 正确值(见地雷 3)。
*
* @param {object} prefabData parsePrefab 返回值(外层 prefab),需含 resolverStartPath
* @param {object} prefabInfo stub 节点的 cc.PrefabInfo(包含 asset.__uuid__
* @returns {string} 嵌套 prefab 根节点 PrefabInfo.fileId
*
* @throws 嵌套 prefab UUID 缺失 / 解析失败 / 找不到根节点 fileId 时抛错
*/
function _getStubInnerRootFileId(prefabData, prefabInfo) {
if (!prefabData || !prefabData.resolverStartPath) {
throw new Error(`setOverrideProperty: prefabData 缺 resolverStartPath,无法解析嵌套 prefab`);
}
const assetRef = prefabInfo && prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(`setOverrideProperty: stub PrefabInfo.asset 不是 UUID 引用,无法定位嵌套 prefab`);
}
const nestedPath = resolveUuidToPath(assetRef.__uuid__, prefabData.resolverStartPath);
if (typeof nestedPath !== 'string' || nestedPath.length === 0) {
throw new Error(`setOverrideProperty: UUID "${assetRef.__uuid__}" 找不到对应 prefab 路径`);
}
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (!el || el.__type__ !== 'cc.Node') continue;
// 嵌套 prefab 内根节点 _parent === null
if (el._parent !== null && el._parent !== undefined) continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = nEls[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (typeof pi.fileId === 'string' && pi.fileId.length > 0) {
return pi.fileId;
}
}
throw new Error(`setOverrideProperty: 嵌套 prefab "${nestedPath}" 找不到根节点 PrefabInfo.fileId`);
}
/**
* 找 PrefabInstance 中已有的 CCPropertyOverrideInfo
* 条件:targetInfo 的 localID[0] === targetLocalIdpropertyPath[0] === propertyPath
*
* @param {object[]} elements
* @param {object} prefabInstance
* @param {string} targetLocalId 目标 fileId(嵌套 prefab 内根节点 fileId
* @param {string[]} propertyPath
* @param {string=} legacyLocalId 兼容旧 prefab 写入的 stubFileId(一起匹配)
* @returns {{ info: object, infoId: number } | null}
*/
function _findExistingOverride(elements, prefabInstance, targetLocalId, propertyPath, legacyLocalId) {
if (!Array.isArray(prefabInstance.propertyOverrides)) return null;
for (const overrideRef of prefabInstance.propertyOverrides) {
if (typeof overrideRef.__id__ !== 'number') continue;
const info = elements[overrideRef.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
// 匹配 targetInfo.localID[0]targetLocalId 优先;legacyLocalId 用于兼容旧版 cli 写入的 stubFileId
const targetInfoRef = info.targetInfo;
if (!targetInfoRef || typeof targetInfoRef.__id__ !== 'number') continue;
const targetInfo = elements[targetInfoRef.__id__];
if (!targetInfo || targetInfo.__type__ !== 'cc.TargetInfo') continue;
if (!Array.isArray(targetInfo.localID)) continue;
const lid = targetInfo.localID[0];
if (lid !== targetLocalId && lid !== legacyLocalId) continue;
// 匹配 propertyPath
if (!Array.isArray(info.propertyPath)) continue;
if (info.propertyPath.length !== propertyPath.length) continue;
if (info.propertyPath.every((p, i) => p === propertyPath[i])) {
return { info, infoId: overrideRef.__id__, targetInfo };
}
}
return null;
}
/**
* 设置 stub 节点(嵌套 prefab 根节点)的属性 override
*
* 地雷 1:直接写 stub 节点字段无效,必须通过 PrefabInstance.propertyOverrides。
*
* @param {object} prefabData parsePrefab 的返回值
* @param {number} stubNodeId stub 节点的数组下标(__id__
* @param {string[]} propertyPath 属性路径,如 ['_lpos'] 或 ['_name']
* @param {*} value 要写入的值
* @returns {void}
*
* @throws 如果 stubNodeId 不是有效 stub 节点
*/
function setOverrideProperty(prefabData, stubNodeId, propertyPath, value) {
const { elements } = prefabData;
const stubResult = _findStubPrefabInstance(elements, stubNodeId);
if (!stubResult) {
throw new Error(
`setOverrideProperty: index ${stubNodeId} 不是有效的 stub 节点(需要 _prefab → PrefabInfo.instance → PrefabInstance`
);
}
const { prefabInstance, prefabInfo } = stubResult;
const stubFileId = prefabInfo.fileId;
// 嵌套 prefab 内根节点 fileId 是 Cocos 运行时 targetMap 的正确 key(见地雷 3)。
// 解析失败抛错(不再 fallback),调用方需保证嵌套 prefab 可用。
const targetLocalId = _getStubInnerRootFileId(prefabData, prefabInfo);
// 查找是否已有对应 override
// 自动矫正:当 stubFileId !== targetLocalId 时,识别旧版 cli 写入的 stubFileId 条目,
// 命中后把 localID 改写为真值(一次性迁移历史脏数据)。
const legacyLocalId = stubFileId !== targetLocalId ? stubFileId : null;
const existing = _findExistingOverride(elements, prefabInstance, targetLocalId, propertyPath, legacyLocalId);
if (existing) {
existing.info.value = value;
if (legacyLocalId && existing.targetInfo && Array.isArray(existing.targetInfo.localID)) {
if (existing.targetInfo.localID[0] === legacyLocalId) {
existing.targetInfo.localID[0] = targetLocalId;
}
}
return;
}
// 新建 TargetInfo
const targetInfo = {
__type__: 'cc.TargetInfo',
localID: [targetLocalId],
};
const targetInfoId = elements.length;
elements.push(targetInfo);
// 新建 CCPropertyOverrideInfo
const overrideInfo = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: targetInfoId },
propertyPath: [...propertyPath],
value,
};
const overrideInfoId = elements.length;
elements.push(overrideInfo);
// 追加到 PrefabInstance.propertyOverrides
if (!Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
prefabInstance.propertyOverrides.push({ __id__: overrideInfoId });
}
/**
* 列出 stub 节点的所有 propertyOverrides
*
* @param {object} prefabData parsePrefab 的返回值
* @param {number} stubNodeId stub 节点的数组下标
* @returns {Array<{ propertyPath: string[], value: *, targetFileId: string }>}
*/
function listOverrides(prefabData, stubNodeId) {
const { elements } = prefabData;
const stubResult = _findStubPrefabInstance(elements, stubNodeId);
if (!stubResult) {
throw new Error(
`listOverrides: index ${stubNodeId} 不是有效的 stub 节点`
);
}
const { prefabInstance, prefabInfo } = stubResult;
// stub-node-field override 的 localID 是嵌套 prefab 内根节点 fileId(见地雷 3)。
const targetLocalId = _getStubInnerRootFileId(prefabData, prefabInfo);
const result = [];
if (!Array.isArray(prefabInstance.propertyOverrides)) return result;
for (const overrideRef of prefabInstance.propertyOverrides) {
if (typeof overrideRef.__id__ !== 'number') continue;
const info = elements[overrideRef.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
const targetInfoRef = info.targetInfo;
if (!targetInfoRef || typeof targetInfoRef.__id__ !== 'number') continue;
const targetInfo = elements[targetInfoRef.__id__];
if (!targetInfo || targetInfo.__type__ !== 'cc.TargetInfo') continue;
if (!Array.isArray(targetInfo.localID) || targetInfo.localID[0] !== targetLocalId) continue;
result.push({
propertyPath: [...(info.propertyPath || [])],
value: info.value,
targetFileId: targetInfo.localID[0],
});
}
return result;
}
/**
* 同步 nestedPrefabInstanceRoots(地雷 2
*
* 宿主 prefab 的根节点 PrefabInfo.nestedPrefabInstanceRoots 必须包含所有嵌套 stub 节点。
* 调用此函数后会从 elements 中自动扫描所有 cc.PrefabInstance
* 找到对应的 stub 节点 __id__,并更新根节点 PrefabInfo 的 nestedPrefabInstanceRoots。
*
* 通常在新增 stub 节点后调用一次即可。
*
* @param {object} prefabData parsePrefab 的返回值
*/
function syncNestedPrefabInstanceRoots(prefabData) {
const { elements, rootId } = prefabData;
// 找根节点 PrefabInforoot 指向自己的那个)
const rootNode = elements[rootId];
if (!rootNode) throw new Error('syncNestedPrefabInstanceRoots: 根节点不存在');
const rootPrefabRef = rootNode._prefab;
if (!rootPrefabRef || typeof rootPrefabRef.__id__ !== 'number') {
throw new Error('syncNestedPrefabInstanceRoots: 根节点没有 _prefab 引用');
}
const rootPrefabInfo = elements[rootPrefabRef.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error('syncNestedPrefabInstanceRoots: 根节点 _prefab 不是 cc.PrefabInfo');
}
// 收集所有 stub 节点 __id__(有 PrefabInstance 的 cc.Node
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance || typeof pi.instance.__id__ !== 'number') continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
// 这是一个 stub 节点
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.length > 0
? stubIds.map((id) => ({ __id__: id }))
: null;
}
module.exports = {
setOverrideProperty,
listOverrides,
syncNestedPrefabInstanceRoots,
};
+140
View File
@@ -0,0 +1,140 @@
// ============================================================
// CC3 Prefab 解析器(纯 CJS,零三方依赖)
// 读取 prefab JSON → 构建 __id__ 索引 → 暴露查询接口
// ============================================================
'use strict';
const fs = require('fs');
/**
* 解析 prefab 文件
*
* 返回对象结构:
* {
* raw: string, // 原始文件内容(供 write.js 保留格式用)
* elements: object[], // 顶层数组(原始引用,可直接修改)
* rootId: number, // cc.Prefab data 指向的根节点 __id__
* findNodeByName(name), // 递归按 _name 查首个匹配节点(返回 element)
* findNodesByType(type), // 按 __type__ 查所有匹配 element
* getRoot(), // 返回根 cc.Node element
* resolveRef(refObj), // { __id__: N } → element
* }
*
* @param {string} filePath
* @returns {PrefabData}
*/
function parsePrefab(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
let elements;
try {
elements = JSON.parse(raw);
} catch (e) {
throw new Error(`parsePrefab: JSON 解析失败(${filePath}: ${e.message}`);
}
if (!Array.isArray(elements)) {
throw new Error(`parsePrefab: 顶层不是数组(${filePath}`);
}
// 构建 __id__ → element 的 O(1) 索引
// CC3 prefab 数组下标即 __id__,无需额外映射,但封装成函数方便维护
// 同时校验数组长度
const idIndex = elements; // 直接按下标访问即可
// 找 cc.Prefab 资产头(通常在 index 0),取 rootId
let rootId = -1;
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el && typeof el === 'object' && el.__type__ === 'cc.Prefab') {
if (el.data && typeof el.data.__id__ === 'number') {
rootId = el.data.__id__;
}
break;
}
}
if (rootId < 0) {
throw new Error(`parsePrefab: 未找到 cc.Prefab 头或 data.__id__${filePath}`);
}
// ─── 辅助:按 __id__ 解引用 ───────────────────────────────
function resolveRef(refObj) {
if (!refObj || typeof refObj.__id__ !== 'number') {
throw new Error(`resolveRef: 参数不是有效引用对象: ${JSON.stringify(refObj)}`);
}
const el = idIndex[refObj.__id__];
if (el === undefined) {
throw new Error(`resolveRef: __id__ ${refObj.__id__} 超出数组范围`);
}
return el;
}
// ─── 辅助:收集节点的所有子节点(递归) ─────────────────
function _collectChildren(node, visited) {
if (!node || !Array.isArray(node._children)) return [];
const result = [];
for (const childRef of node._children) {
if (typeof childRef.__id__ !== 'number') continue;
const id = childRef.__id__;
if (visited.has(id)) continue; // 防环
visited.add(id);
const child = idIndex[id];
if (child) {
result.push(child);
result.push(..._collectChildren(child, visited));
}
}
return result;
}
// ─── getRoot ─────────────────────────────────────────────
function getRoot() {
return idIndex[rootId];
}
// ─── findNodeByName ───────────────────────────────────────
// 从根节点递归 DFS,返回第一个 _name 匹配的 cc.Node
function findNodeByName(name) {
const root = getRoot();
if (!root) return null;
return _findByName(root, name, new Set([rootId]));
}
function _findByName(node, name, visited) {
if (node._name === name) return node;
if (!Array.isArray(node._children)) return null;
for (const childRef of node._children) {
if (typeof childRef.__id__ !== 'number') continue;
const id = childRef.__id__;
if (visited.has(id)) continue;
visited.add(id);
const child = idIndex[id];
if (!child) continue;
const found = _findByName(child, name, visited);
if (found) return found;
}
return null;
}
// ─── findNodesByType ──────────────────────────────────────
// 遍历整个数组,按 __type__ 过滤(不限于节点树)
function findNodesByType(type) {
return elements.filter(
(el) => el && typeof el === 'object' && el.__type__ === type
);
}
return {
raw,
elements,
rootId,
findNodeByName,
findNodesByType,
getRoot,
resolveRef,
};
}
module.exports = { parsePrefab };
+476
View File
@@ -0,0 +1,476 @@
// ============================================================
// CC3 Prefab 对象构建原语(纯 CJS,零三方依赖)
// 输入:朴素参数(name/pos/size 等)
// 输出:可直接插入 prefab 数组的裸对象
// ============================================================
'use strict';
// ─────────────────────────────────────────────
// 基础数据类型工厂
// ─────────────────────────────────────────────
/** @param {number} x @param {number} y @param {number} [z] */
function vec3(x, y, z = 0) {
return { __type__: 'cc.Vec3', x, y, z };
}
/** @param {number} w @param {number} h */
function ccSize(w, h) {
return { __type__: 'cc.Size', width: w, height: h };
}
/** @param {number} x @param {number} y */
function vec2(x, y) {
return { __type__: 'cc.Vec2', x, y };
}
/** @param {number} r @param {number} g @param {number} b @param {number} [a] */
function ccColor(r, g, b, a = 255) {
return { __type__: 'cc.Color', r, g, b, a };
}
/** @param {number} id */
function ref(id) {
return { __id__: id };
}
// ─────────────────────────────────────────────
// cc.Node
// ─────────────────────────────────────────────
/**
* 构造 cc.Node 裸对象
* @param {object} opts
* @param {string} opts.name - 节点名称
* @param {number[]} [opts.pos] - 本地位置 [x, y, z],默认 [0,0,0]
* @param {number[]} [opts.scale] - 本地缩放 [x, y, z],默认 [1,1,1]
* @param {boolean} [opts.active] - 是否激活,默认 true
* @param {number} [opts.layer] - 渲染层,默认 33554432UI 层)
* @param {number|null} [opts.parentId] - 父节点 __id__null 表示根节点
* @param {number[]} [opts.childIds] - 子节点 __id__ 数组
* @param {number[]} [opts.componentIds] - 组件 __id__ 数组
* @param {number|null} [opts.prefabId] - cc.PrefabInfo 的 __id__
* @returns {object}
*/
function makeNode(opts) {
const {
name,
pos = [0, 0, 0],
scale = [1, 1, 1],
active = true,
layer = 33554432,
parentId = null,
childIds = [],
componentIds = [],
prefabId = null,
} = opts;
return {
__type__: 'cc.Node',
_name: name,
_objFlags: 0,
__editorExtras__: {},
_parent: parentId !== null ? ref(parentId) : null,
_children: childIds.map(ref),
_active: active,
_components: componentIds.map(ref),
_prefab: prefabId !== null ? ref(prefabId) : null,
_lpos: vec3(pos[0] ?? 0, pos[1] ?? 0, pos[2] ?? 0),
_lrot: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 },
_lscale: vec3(scale[0] ?? 1, scale[1] ?? 1, scale[2] ?? 1),
_mobility: 0,
_layer: layer,
_euler: vec3(0, 0, 0),
_id: '',
};
}
// ─────────────────────────────────────────────
// cc.UITransform
// ─────────────────────────────────────────────
/**
* 构造 cc.UITransform 裸对象
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {number} opts.width - 宽度
* @param {number} opts.height - 高度
* @param {number[]} [opts.anchor] - 锚点 [x, y],默认 [0.5, 0.5]
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
* @returns {object}
*/
function makeUITransform(opts) {
const {
nodeId,
width,
height,
anchor = [0.5, 0.5],
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.UITransform',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_contentSize: ccSize(width, height),
_anchorPoint: vec2(anchor[0] ?? 0.5, anchor[1] ?? 0.5),
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.Sprite
// ─────────────────────────────────────────────
/**
* 构造 cc.Sprite 裸对象
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {string} [opts.spriteFrameUuid] - 图片 UUID(格式 "uuid@f9941"),null 表示无图
* @param {number} [opts.type] - 0=SIMPLE(默认)/1=SLICED/2=TILED/3=FILLED
* @param {number[]} [opts.color] - [r,g,b,a],默认白色不透明
* @param {boolean} [opts.isTrimmedMode] - 默认 true
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
* @returns {object}
*/
function makeSprite(opts) {
const {
nodeId,
spriteFrameUuid = null,
type = 0,
color = [255, 255, 255, 255],
isTrimmedMode = true,
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.Sprite',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: ccColor(color[0] ?? 255, color[1] ?? 255, color[2] ?? 255, color[3] ?? 255),
_spriteFrame: spriteFrameUuid
? { __uuid__: spriteFrameUuid, __expectedType__: 'cc.SpriteFrame' }
: null,
_type: type,
_fillType: 0,
_sizeMode: 0,
_fillCenter: vec2(0, 0),
_fillStart: 0,
_fillRange: 0,
_isTrimmedMode: isTrimmedMode,
_useGrayscale: false,
_atlas: null,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.Label
// ─────────────────────────────────────────────
/**
* 构造 cc.Label 裸对象
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {string} [opts.string] - 文字内容,默认空字符串
* @param {number} [opts.fontSize] - 字号,默认 20
* @param {number} [opts.lineHeight] - 行高,0 表示自动
* @param {number} [opts.horizontalAlign] - 0=LEFT/1=CENTER/2=RIGHT,默认 1(CENTER)
* @param {number} [opts.verticalAlign] - 0=TOP/1=CENTER/2=BOTTOM,默认 1(CENTER)
* @param {number} [opts.overflow] - 0=NONE/1=CLAMP/2=SHRINK/3=RESIZE_HEIGHT,默认 0
* @param {string|null} [opts.fontUuid] - 字体资产 UUIDnull 使用系统字体
* @param {number[]} [opts.color] - [r,g,b,a],默认黑色不透明
* @param {boolean} [opts.enableOutline] - 是否启用描边,默认 false
* @param {number[]} [opts.outlineColor] - 描边颜色 [r,g,b,a]
* @param {number} [opts.outlineWidth] - 描边宽度,默认 2
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
* @returns {object}
*/
function makeLabel(opts) {
const {
nodeId,
string = '',
fontSize = 20,
lineHeight = 0,
horizontalAlign = 1,
verticalAlign = 1,
overflow = 0,
fontUuid = null,
color = [0, 0, 0, 255],
enableOutline = false,
outlineColor = [0, 0, 0, 255],
outlineWidth = 2,
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.Label',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: ccColor(color[0] ?? 0, color[1] ?? 0, color[2] ?? 0, color[3] ?? 255),
_string: string,
_horizontalAlign: horizontalAlign,
_verticalAlign: verticalAlign,
_actualFontSize: fontSize,
_fontSize: fontSize,
_fontFamily: 'Arial',
_lineHeight: lineHeight,
_overflow: overflow,
_enableWrapText: true,
_font: fontUuid ? { __uuid__: fontUuid, __expectedType__: 'cc.Font' } : null,
_isSystemFontUsed: fontUuid === null,
_isItalic: false,
_isBold: false,
_isUnderline: false,
_cacheMode: 0,
_enableOutline: enableOutline,
_outlineColor: ccColor(
outlineColor[0] ?? 0,
outlineColor[1] ?? 0,
outlineColor[2] ?? 0,
outlineColor[3] ?? 255
),
_outlineWidth: outlineWidth,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.Widget
// ─────────────────────────────────────────────
/**
* 构造 cc.Widget 裸对象
*
* alignFlags 位掩码(可组合):
* LEFT=1, RIGHT=2, TOP=4, BOTTOM=8, HORIZONTAL_CENTER=16, VERTICAL_CENTER=32
*
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {number} [opts.alignFlags] - 对齐标志位掩码,默认 0(无对齐)
* @param {number} [opts.left] - 左边距
* @param {number} [opts.right] - 右边距
* @param {number} [opts.top] - 上边距
* @param {number} [opts.bottom] - 下边距
* @param {boolean} [opts.isAbsLeft] - left 是否为绝对像素,默认 true
* @param {boolean} [opts.isAbsRight] - right 是否为绝对像素,默认 true
* @param {boolean} [opts.isAbsTop] - top 是否为绝对像素,默认 true
* @param {boolean} [opts.isAbsBottom] - bottom 是否为绝对像素,默认 true
* @param {boolean} [opts.isAbsHorizontalCenter] - horizontalCenter 是否为绝对像素,默认 true
* @param {boolean} [opts.isAbsVerticalCenter] - verticalCenter 是否为绝对像素,默认 true
* @param {number} [opts.horizontalCenter] - 水平居中偏移
* @param {number} [opts.verticalCenter] - 垂直居中偏移
* @param {number} [opts.alignMode] - 0=ONCE/1=ON_WINDOW_RESIZE/2=ALWAYS,默认 1
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
* @returns {object}
*/
function makeWidget(opts) {
const {
nodeId,
alignFlags = 0,
left = 0,
right = 0,
top = 0,
bottom = 0,
isAbsLeft = true,
isAbsRight = true,
isAbsTop = true,
isAbsBottom = true,
isAbsHorizontalCenter = true,
isAbsVerticalCenter = true,
horizontalCenter = 0,
verticalCenter = 0,
alignMode = 1,
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.Widget',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_alignFlags: alignFlags,
_target: null,
_left: left,
_right: right,
_top: top,
_bottom: bottom,
_horizontalCenter: horizontalCenter,
_verticalCenter: verticalCenter,
_isAbsLeft: isAbsLeft,
_isAbsRight: isAbsRight,
_isAbsTop: isAbsTop,
_isAbsBottom: isAbsBottom,
_isAbsHorizontalCenter: isAbsHorizontalCenter,
_isAbsVerticalCenter: isAbsVerticalCenter,
_originalWidth: 0,
_originalHeight: 0,
_alignMode: alignMode,
_lockFlags: 0,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// sp.Skeleton
// ─────────────────────────────────────────────
/**
* 构造 sp.Skeleton 裸对象
*
* 给 spine prefab 用。运行时通过 loadAsset<Prefab> + instantiate +
* getComponent(sp.Skeleton) 获取并播放动画。
*
* 字段默认值与 Cocos 编辑器从右键菜单挂 sp.Skeleton 的产物保持一致:
* 缓存策略 PRIVATE_CACHE(1)、blend func 2/4、_useTint/_premultipliedAlpha
* 等均为 false / 默认。
*
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {string} opts.skeletonUuid - .skel 资产 UUIDcc.AssetManager 注册的资源 ID
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
* @returns {object}
*/
function makeSpSkeleton(opts) {
const { nodeId, skeletonUuid, prefabInfoId = null } = opts;
const obj = {
__type__: 'sp.Skeleton',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: ccColor(255, 255, 255, 255),
_skeletonData: { __uuid__: skeletonUuid, __expectedType__: 'sp.SkeletonData' },
defaultSkin: '',
defaultAnimation: '',
_premultipliedAlpha: false,
_timeScale: 1,
_preCacheMode: 1,
_cacheMode: 1,
_defaultCacheMode: 1,
_sockets: [],
_useTint: false,
_debugMesh: false,
_debugBones: false,
_debugSlots: false,
_enableBatch: false,
loop: false,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.PrefabInfo / cc.CompPrefabInfo
// ─────────────────────────────────────────────
/**
* 构造节点的 cc.PrefabInfo 裸对象(非嵌套普通节点用)
* @param {object} opts
* @param {number} opts.rootId - 根节点 __id__(通常为 1
* @param {string} opts.fileId - 该节点在 prefab 内的唯一 IDbase64 22-24字符)
* @param {number} [opts.assetId] - cc.Prefab 资产 __id__,默认 0
* @param {number[]|null} [opts.nestedPrefabInstanceRoots] - 嵌套 stub 节点索引,根节点 PrefabInfo 用
* @returns {object}
*/
function makePrefabInfo(opts) {
const {
rootId,
fileId,
assetId = 0,
nestedPrefabInstanceRoots = null,
} = opts;
return {
__type__: 'cc.PrefabInfo',
root: ref(rootId),
asset: ref(assetId),
fileId,
instance: null,
targetOverrides: null,
nestedPrefabInstanceRoots: nestedPrefabInstanceRoots
? nestedPrefabInstanceRoots.map(ref)
: null,
};
}
/**
* 构造组件的 cc.CompPrefabInfo 裸对象
* @param {string} fileId - 该组件在 prefab 内的唯一 ID
* @returns {object}
*/
function makeCompPrefabInfo(fileId) {
return { __type__: 'cc.CompPrefabInfo', fileId };
}
/**
* 构造 cc.Prefab 文件头对象(下标 0
* @param {object} opts
* @param {string} opts.name - prefab 名称
* @param {number} opts.rootId - 根节点 __id__(通常为 1
* @returns {object}
*/
function makePrefabRoot(opts) {
const { name, rootId = 1 } = opts;
return {
__type__: 'cc.Prefab',
_name: name,
_objFlags: 0,
__editorExtras__: {},
_native: '',
data: ref(rootId),
optimizationPolicy: 0,
persistent: false,
};
}
module.exports = {
// 基础类型工厂(供外部使用)
vec3,
vec2,
ccSize,
ccColor,
ref,
// 节点/组件构建
makeNode,
makeUITransform,
makeSprite,
makeLabel,
makeWidget,
makeSpSkeleton,
// Prefab 元信息
makePrefabInfo,
makeCompPrefabInfo,
makePrefabRoot,
};
+76
View File
@@ -0,0 +1,76 @@
// ============================================================
// query/comp-fields.js — 组件字段提取(query 共用工具)
// ============================================================
'use strict';
// 系统级字段(每个 cc 组件都有,对调试无信息量)过滤掉
const RESERVED_COMP_FIELDS = new Set([
'__type__',
'_objFlags',
'__editorExtras__',
'node',
'_enabled',
'__prefab',
'_id',
]);
/** 提取组件的业务字段(过滤系统字段) */
function extractCompFields(comp) {
if (!comp || typeof comp !== 'object') return {};
const fields = {};
for (const key of Object.keys(comp)) {
if (RESERVED_COMP_FIELDS.has(key)) continue;
fields[key] = comp[key];
}
return fields;
}
/** 节点 _components 列表 → [{type, id, fields}] */
function componentDetails(elements, node) {
if (!Array.isArray(node._components)) return [];
const out = [];
for (const ref of node._components) {
if (typeof ref.__id__ !== 'number') continue;
const comp = elements[ref.__id__];
if (!comp) continue;
out.push({
type: comp.__type__,
id: ref.__id__,
fields: extractCompFields(comp),
});
}
return out;
}
/** 节点 _components 列表 → 类型名数组(轻量版,不展开字段) */
function componentTypes(elements, node) {
if (!Array.isArray(node._components)) return [];
const types = [];
for (const ref of node._components) {
if (typeof ref.__id__ !== 'number') continue;
const comp = elements[ref.__id__];
if (comp && comp.__type__) types.push(comp.__type__);
}
return types;
}
/** 判断节点是否是 stub(嵌套 prefab 根节点)— query 内部用 */
function isStub(elements, node) {
if (!node || node.__type__ !== 'cc.Node') return false;
const prefabRef = node._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return false;
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return false;
const instanceRef = prefabInfo.instance;
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return false;
const instance = elements[instanceRef.__id__];
return !!(instance && instance.__type__ === 'cc.PrefabInstance');
}
module.exports = {
extractCompFields,
componentDetails,
componentTypes,
isStub,
};
+32
View File
@@ -0,0 +1,32 @@
// query/field.js — 单组件单字段值(脚本管道用)
'use strict';
function queryField(prefabData, args) {
const { elements } = prefabData;
const { name, componentType, field } = args;
if (!name) throw new Error('queryPrefab: selector.type="field" 必须提供 name');
if (!componentType) throw new Error('queryPrefab: selector.type="field" 必须提供 componentType');
if (!field) throw new Error('queryPrefab: selector.type="field" 必须提供 field');
const node = prefabData.findNodeByName(name);
if (!node) throw new Error(`queryPrefab[field]: 找不到节点 "${name}"`);
if (!Array.isArray(node._components)) {
throw new Error(`queryPrefab[field]: 节点 "${name}" 没有组件`);
}
for (const ref of node._components) {
if (typeof ref.__id__ !== 'number') continue;
const comp = elements[ref.__id__];
if (!comp || comp.__type__ !== componentType) continue;
if (!(field in comp)) {
throw new Error(
`queryPrefab[field]: 节点 "${name}" 的 ${componentType} 没有字段 "${field}"`
);
}
return comp[field];
}
throw new Error(`queryPrefab[field]: 节点 "${name}" 没有 ${componentType} 组件`);
}
module.exports = { queryField };
+17
View File
@@ -0,0 +1,17 @@
// query/find.js — 按 __type__ 列出所有匹配 element 的 id
'use strict';
function queryFind(prefabData, nodeType) {
const { elements } = prefabData;
const ids = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el && typeof el === 'object' && el.__type__ === nodeType) {
ids.push(i);
}
}
return ids;
}
module.exports = { queryFind };
+56
View File
@@ -0,0 +1,56 @@
// ============================================================
// query/index.js — 只读查询主入口
//
// queryPrefab(filePath, selector)
// selector.type = 'tree' → 节点树(默认;selector.withComps 展开组件字段)
// selector.type = 'node' → 单节点详情(同上 withComps)
// selector.type = 'find' → 列所有 __type__ 匹配的 id
// selector.type = 'field' → 单组件单字段值
// selector.type = 'overrides' → 列 stub 节点 propertyOverrides + root targetOverrides
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { queryTree } = require('./tree.js');
const { queryNode } = require('./node.js');
const { queryFind } = require('./find.js');
const { queryField } = require('./field.js');
const { queryOverrides } = require('./overrides.js');
function queryPrefab(filePath, selector) {
const prefabData = parsePrefab(filePath);
// overrides 需要按 uuid 反查嵌套 prefab,必须知道 host path 用作 UuidResolver 起点
prefabData.resolverStartPath = filePath;
const type = selector && selector.type ? selector.type : 'tree';
const opts = { withComps: !!(selector && selector.withComps) };
if (type === 'tree') {
return queryTree(prefabData, opts);
}
if (type === 'node') {
const name = selector && selector.name;
if (!name) throw new Error('queryPrefab: selector.type="node" 时必须提供 selector.name');
return queryNode(prefabData, name, opts);
}
if (type === 'find') {
const nodeType = selector && selector.nodeType;
if (!nodeType) throw new Error('queryPrefab: selector.type="find" 时必须提供 selector.nodeType');
return queryFind(prefabData, nodeType);
}
if (type === 'field') {
return queryField(prefabData, selector);
}
if (type === 'overrides') {
return queryOverrides(prefabData, selector);
}
throw new Error(`queryPrefab: 未知 selector.type "${type}",支持 tree / node / find / field / overrides`);
}
module.exports = { queryPrefab };
+68
View File
@@ -0,0 +1,68 @@
// query/node.js — 按名称查单节点详情
'use strict';
const { listOverrides } = require('../overrides.js');
const { isStub, componentTypes, componentDetails } = require('./comp-fields.js');
function queryNode(prefabData, name, opts) {
const { elements } = prefabData;
const withComps = !!(opts && opts.withComps);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
const stub = isStub(elements, el);
let resolvedName = el._name;
// stub 节点的实际名称可能存在 overrides 的 _name 字段
if (stub && resolvedName === undefined) {
try {
const ovs = listOverrides(prefabData, i);
const nameOv = ovs.find(
(o) => o.propertyPath.length === 1 && o.propertyPath[0] === '_name'
);
if (nameOv) resolvedName = nameOv.value;
} catch (_) {
// ignore
}
}
if (resolvedName !== name) continue;
// stub 节点:把身份信息(isStub/stubAsset/overrides)放在 raw 之前,
// 避免被 raw 大对象淹没。stub 节点的 _components / _children 字段在 raw 里是空的,
// 真实组件/子节点都属于被引用 prefab 内部,不在当前文件里。
const result = {
id: i,
name: resolvedName,
type: el.__type__,
active: el._active !== undefined ? el._active : null,
isStub: stub,
};
if (stub) {
const prefabInfo = elements[el._prefab.__id__];
result.stubAsset = prefabInfo && prefabInfo.asset && prefabInfo.asset.__uuid__
? prefabInfo.asset.__uuid__
: null;
try {
result.overrides = listOverrides(prefabData, i);
} catch (_) {
result.overrides = [];
}
result._note = 'stub 节点本身 _components/_children 为空;真实组件和子树在被引用 prefab 内(见 stubAsset),改 stub 内部字段用 set-nested-component-field / set-component-ref refSubNode';
}
result.componentTypes = componentTypes(elements, el);
if (withComps) {
result.components = componentDetails(elements, el);
}
result.raw = el;
return result;
}
return null;
}
module.exports = { queryNode };
+158
View File
@@ -0,0 +1,158 @@
// query/overrides.js — 列出 stub 节点当前所有 propertyOverrides + 关联的 root targetOverrides
//
// 输出:每条 override 标注落点(stub 自身节点字段 / 嵌套内某组件字段 / 嵌套内某节点字段),
// 配合 reset-overrides op 调试/回滚。
//
// args:
// - node: 节点 selectorname / id / {id} / {path});必须是 stub
//
// 输出结构:
// {
// stubNodeId, stubFileId, nestedPrefab,
// propertyOverrides: [
// { target: { kind, ...}, propertyPath, value }
// ],
// rootTargetOverrides: [
// { source: {id}, propertyPath, target: {...}, localIDChain }
// ]
// }
'use strict';
const { parsePrefab } = require('../parse.js');
const { resolveUuidToPath } = require('../uuid-resolver.js');
const { resolveNode, isStub } = require('../editor/helpers.js');
function _buildFileIdIndex(nestedElements) {
const index = new Map();
for (let i = 0; i < nestedElements.length; i++) {
const el = nestedElements[i];
if (!el) continue;
// 节点 PrefabInfo.fileId
if (el.__type__ === 'cc.Node' && el._prefab && typeof el._prefab.__id__ === 'number') {
const pi = nestedElements[el._prefab.__id__];
if (pi && pi.__type__ === 'cc.PrefabInfo' && typeof pi.fileId === 'string') {
index.set(pi.fileId, {
kind: 'nested-node',
nodeName: el._name || (el._parent ? null : '(root)'),
nodeId: i,
});
}
}
// 组件 CompPrefabInfo.fileId
if (el.__type__ && el.__prefab && typeof el.__prefab.__id__ === 'number') {
const cpi = nestedElements[el.__prefab.__id__];
if (cpi && cpi.__type__ === 'cc.CompPrefabInfo' && typeof cpi.fileId === 'string') {
const ownerNode = el.node && typeof el.node.__id__ === 'number'
? nestedElements[el.node.__id__] : null;
index.set(cpi.fileId, {
kind: 'nested-component',
componentType: el.__type__,
ownerNodeName: ownerNode ? (ownerNode._name || '(root)') : null,
ownerNodeId: ownerNode ? el.node.__id__ : null,
});
}
}
}
return index;
}
function queryOverrides(prefabData, args) {
const { elements } = prefabData;
const nodeSelector = args && args.node;
if (nodeSelector === undefined || nodeSelector === null) {
throw new Error('queryPrefab[overrides]: 必须提供 node');
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'overrides');
if (!isStub(elements, node)) {
throw new Error(`queryPrefab[overrides]: 节点 [${nodeId}] 不是 stub(无嵌套 prefab`);
}
const prefabInfo = elements[node._prefab.__id__];
const stubFileId = prefabInfo.fileId;
const prefabInstance = elements[prefabInfo.instance.__id__];
// 加载嵌套 prefab,建 fileId 反查索引
const nestedUuid = prefabInfo.asset && prefabInfo.asset.__uuid__;
let nestedPath = null;
let fileIdIndex = new Map();
if (typeof nestedUuid === 'string') {
try {
nestedPath = resolveUuidToPath(nestedUuid, prefabData.resolverStartPath);
const nestedData = parsePrefab(nestedPath);
fileIdIndex = _buildFileIdIndex(nestedData.elements);
} catch (_) {
// 嵌套 prefab 加载失败不阻断查询,target 会被标 unknown
}
}
const propertyOverrides = [];
if (Array.isArray(prefabInstance.propertyOverrides)) {
for (const ref of prefabInstance.propertyOverrides) {
if (!ref || typeof ref.__id__ !== 'number') continue;
const info = elements[ref.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
const tiRef = info.targetInfo;
const ti = tiRef && typeof tiRef.__id__ === 'number' ? elements[tiRef.__id__] : null;
const localID = ti && Array.isArray(ti.localID) ? ti.localID : [];
const fid = localID[0];
let target;
if (fid === stubFileId) {
target = { kind: 'stub-node-field', nodeName: node._name || null };
} else {
const entry = fileIdIndex.get(fid);
target = entry ? { ...entry, fileId: fid } : { kind: 'unknown', fileId: fid };
}
if (localID.length > 1) {
target.localIDChain = [...localID];
}
propertyOverrides.push({
target,
propertyPath: [...(info.propertyPath || [])],
value: info.value,
});
}
}
// 关联此 stub 的 root targetOverridescc.TargetOverrideInfo
const rootTargetOverrides = [];
const rootNode = elements[prefabData.rootId];
if (rootNode && rootNode._prefab && typeof rootNode._prefab.__id__ === 'number') {
const rootPi = elements[rootNode._prefab.__id__];
if (rootPi && Array.isArray(rootPi.targetOverrides)) {
for (const r of rootPi.targetOverrides) {
if (!r || typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (!ov.target || ov.target.__id__ !== nodeId) continue;
const ti = ov.targetInfo && typeof ov.targetInfo.__id__ === 'number'
? elements[ov.targetInfo.__id__] : null;
const localID = ti && Array.isArray(ti.localID) ? [...ti.localID] : [];
const fid = localID[0];
const entry = fileIdIndex.get(fid);
const target = entry ? { ...entry, fileId: fid } : { kind: 'unknown', fileId: fid };
rootTargetOverrides.push({
source: { compId: ov.source ? ov.source.__id__ : null },
propertyPath: [...(ov.propertyPath || [])],
target,
localIDChain: localID,
});
}
}
}
return {
stubNodeId: nodeId,
stubFileId,
nestedPrefab: nestedPath,
propertyOverrides,
rootTargetOverrides,
};
}
module.exports = { queryOverrides };
+79
View File
@@ -0,0 +1,79 @@
// query/tree.js — 精简节点树(递归 DFS)
'use strict';
const { listOverrides } = require('../overrides.js');
const { isStub, componentTypes, componentDetails } = require('./comp-fields.js');
function buildTree(prefabData, nodeId, visited, opts) {
const { elements } = prefabData;
const node = elements[nodeId];
const stub = isStub(elements, node);
const withComps = !!(opts && opts.withComps);
// stub 节点真实 _name 字段为 null,需要从 propertyOverrides 里反查 _name override
let resolvedName = node._name !== undefined ? node._name : null;
if (stub) {
try {
const ovs = listOverrides(prefabData, nodeId);
const nameOv = ovs.find((o) => o.propertyPath.length === 1 && o.propertyPath[0] === '_name');
if (nameOv) resolvedName = nameOv.value;
} catch (_) {
// ignore
}
}
// stub 节点 name 加 (stub) 后缀,肉眼一眼区分嵌套实例和普通节点
// stub 无 _name override 时 resolvedName 为 null,显示纯 "(stub)" 字面值
let displayName;
if (stub) {
displayName = resolvedName !== null ? `${resolvedName} (stub)` : '(stub)';
} else {
displayName = resolvedName;
}
const treeNode = {
id: nodeId,
name: displayName,
type: node.__type__,
active: node._active !== undefined ? node._active : null,
isStub: stub,
};
if (stub) {
const prefabInfo = elements[node._prefab.__id__];
treeNode.stubAsset = prefabInfo && prefabInfo.asset && prefabInfo.asset.__uuid__
? prefabInfo.asset.__uuid__
: null;
try {
treeNode.overrides = listOverrides(prefabData, nodeId);
} catch (_) {
treeNode.overrides = [];
}
}
treeNode.children = [];
if (withComps) {
treeNode.components = componentDetails(elements, node);
} else {
treeNode.componentTypes = componentTypes(elements, node);
}
if (Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ !== 'number') continue;
const cid = childRef.__id__;
if (visited.has(cid)) continue;
visited.add(cid);
treeNode.children.push(buildTree(prefabData, cid, visited, opts));
}
}
return treeNode;
}
function queryTree(prefabData, opts) {
const { rootId } = prefabData;
const visited = new Set([rootId]);
return buildTree(prefabData, rootId, visited, opts);
}
module.exports = { queryTree };
+162
View File
@@ -0,0 +1,162 @@
// ============================================================
// UuidResolver:惰性扫描 assets/ 下所有 .prefab.meta 文件
// 构建 uuid → prefab 磁盘路径 索引,仅扫描一次后缓存于内存。
//
// 设计约束:
// - 不引入第三方依赖(纯 Node.js fs + path + child_process
// - uuid 索引只扫一次,重复调用复用缓存
// - 解析失败时明确抛错,不静默降级
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ─── 模块级缓存 ───────────────────────────────────────────────
/**
* 缓存结构:Map<projectRoot, Map<uuid, absolutePrefabPath>>
* 按项目根分隔,支持同进程内多项目(虽然实际上只会有一个)。
*/
const _cache = new Map();
// ─── 内部:定位 projectRoot ───────────────────────────────────
/**
* 从路径(文件或目录)往上查找项目根(含有 assets/ 子目录 + package.json
*
* @param {string} startPath 绝对路径(文件或目录均可)
* @returns {string} 项目根绝对路径
* @throws 找不到时抛错
*/
function _findProjectRoot(startPath) {
const resolved = path.resolve(startPath);
// 若是文件取其目录,若是目录直接用
let dir;
try {
dir = fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
} catch (_) {
// 路径不存在(如 tmp 文件已被删除)也从 dirname 开始
dir = path.dirname(resolved);
}
// 最多向上 20 层
for (let i = 0; i < 20; i++) {
const hasAssets = fs.existsSync(path.join(dir, 'assets'));
const hasPkg = fs.existsSync(path.join(dir, 'package.json'));
if (hasAssets && hasPkg) return dir;
const parent = path.dirname(dir);
if (parent === dir) break; // 到达文件系统根
dir = parent;
}
throw new Error(
`UuidResolver: 无法从 "${startPath}" 向上找到项目根(含 assets/ + package.json 的目录)`
);
}
// ─── 内部:扫描并建立索引 ─────────────────────────────────────
/**
* 扫描 projectRoot/assets/ 下所有 .prefab.meta,建立 uuid → absolutePath 映射
*
* @param {string} projectRoot
* @returns {Map<string, string>}
*/
function _buildIndex(projectRoot) {
const assetsDir = path.join(projectRoot, 'assets');
if (!fs.existsSync(assetsDir)) {
throw new Error(`UuidResolver: assets 目录不存在: ${assetsDir}`);
}
// 用 find 命令比 Node 递归快,且无需手写 readdir 递归
let metaFiles;
try {
const raw = execSync(
`find "${assetsDir}" -name "*.prefab.meta" -type f`,
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
);
metaFiles = raw.trim().split('\n').filter(Boolean);
} catch (e) {
throw new Error(`UuidResolver: find 命令失败: ${e.message}`);
}
const index = new Map();
for (const metaFile of metaFiles) {
let meta;
try {
meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
} catch (_) {
// meta 损坏时跳过,不中断整体扫描
continue;
}
if (typeof meta.uuid !== 'string') continue;
// 对应的 prefab 路径 = 去掉 .meta 后缀
const prefabPath = metaFile.replace(/\.meta$/, '');
index.set(meta.uuid, prefabPath);
}
return index;
}
// ─── 公开 API ─────────────────────────────────────────────────
/**
* 根据路径推断项目根,返回 uuid → prefab 绝对路径 的 Map。
* 多次调用同一 projectRoot 时,直接返回缓存,不重复扫描。
*
* @param {string} startPath 起点路径:可以是宿主 prefab 的绝对路径(从它向上找项目根),
* 也可以直接是项目根目录(含 assets/ + package.json)。
* 当 filePath 是 /tmp/ 临时文件时,应直接传入项目根目录。
* @returns {Map<string, string>}
*/
function getUuidIndex(startPath) {
const projectRoot = _findProjectRoot(startPath);
if (_cache.has(projectRoot)) {
return _cache.get(projectRoot);
}
const index = _buildIndex(projectRoot);
_cache.set(projectRoot, index);
return index;
}
/**
* 将 uuid 解析为 prefab 磁盘路径(绝对路径)。
*
* @param {string} uuid 资产 UUID
* @param {string} startPath 起点路径(宿主 prefab 路径 或 项目根目录),用于推断项目根
* @returns {string} prefab 文件绝对路径
* @throws uuid 不存在时抛错
*/
function resolveUuidToPath(uuid, startPath) {
const index = getUuidIndex(startPath);
const result = index.get(uuid);
if (!result) {
throw new Error(
`UuidResolver: 找不到 uuid "${uuid}" 对应的 prefab 文件。` +
`已扫描项目内 ${index.size} 个 prefab。` +
`请确认该 uuid 对应的 prefab 存在于 assets/ 目录下。`
);
}
return result;
}
/**
* 清除缓存(测试用,正常使用不需要调用)
*/
function clearCache() {
_cache.clear();
}
module.exports = { getUuidIndex, resolveUuidToPath, clearCache };
+57
View File
@@ -0,0 +1,57 @@
// ============================================================
// CC3 Prefab 格式保真写回(纯 CJS,零三方依赖)
// 探测原文件缩进 + 末尾换行字节,minimal-diff 写回
// ============================================================
'use strict';
const fs = require('fs');
/**
* 探测原始文件的缩进单位(空格数)
* 取所有以空格开头的行中最小缩进数
* @param {string} raw
* @returns {number} 缩进空格数,默认 2
*/
function detectIndent(raw) {
const matches = [...raw.matchAll(/^( +)\S/gm)].map((m) => m[1].length);
if (matches.length === 0) return 2;
return Math.min(...matches);
}
/**
* 探测原始文件末尾是否有换行符
* @param {string} raw
* @returns {boolean}
*/
function detectTrailingNewline(raw) {
return raw.length > 0 && (raw[raw.length - 1] === '\n' || raw[raw.length - 1] === '\r');
}
/**
* 写回 prefab 文件,保留原始格式(缩进 + 末尾换行)
*
* @param {string} filePath 写入目标路径(可与读取路径不同,T7 写临时路径)
* @param {object[]} data 修改后的 elements 数组
* @param {string} originalRaw 原始文件内容(用于探测格式)
*/
function writePrefab(filePath, data, originalRaw) {
if (!Array.isArray(data)) {
throw new Error('writePrefab: data 必须是数组');
}
if (typeof originalRaw !== 'string') {
throw new Error('writePrefab: originalRaw 必须是字符串');
}
const indent = detectIndent(originalRaw);
const trailingNewline = detectTrailingNewline(originalRaw);
let newRaw = JSON.stringify(data, null, indent);
if (trailingNewline) {
newRaw += '\n';
}
fs.writeFileSync(filePath, newRaw, 'utf8');
}
module.exports = { writePrefab, detectIndent, detectTrailingNewline };
+1594
View File
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
'use strict';
// ============================================================
// T10/T11 CLI 集成测试
// 用 child_process.spawnSync 跑 bin,覆盖:
// - query tree
// - query node --name X
// - query find --type cc.Label
// - set label.text
// - set active
// - batch
// ============================================================
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const BIN = path.resolve(__dirname, '../bin/cocos-mcp-cli.js');
const FIXTURE = path.resolve(__dirname, 'fixtures/HomeUI.prefab');
function run(args) {
return spawnSync(process.execPath, [BIN, ...args], {
encoding: 'utf8',
cwd: __dirname,
});
}
function tmpCopy() {
const dest = path.join(os.tmpdir(), `HomeUI-cli-test-${Date.now()}.prefab`);
fs.copyFileSync(FIXTURE, dest);
return dest;
}
// ─── query tree ──────────────────────────────────────────────
test('CLI query tree: 退出码 0,输出可解析 JSON,含 name=HomeUI 的根节点', () => {
const result = run(['query', FIXTURE, '--selector', 'tree']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
let tree;
assert.doesNotThrow(() => { tree = JSON.parse(result.stdout); }, 'stdout 应是合法 JSON');
assert.equal(tree.name, 'HomeUI', '根节点名称应为 HomeUI');
assert.ok(Array.isArray(tree.children), 'tree.children 应是数组');
});
// ─── query node ──────────────────────────────────────────────
test('CLI query node --name touchArea: 返回单节点详情', () => {
const result = run(['query', FIXTURE, '--selector', 'node', '--name', 'touchArea']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
let node;
assert.doesNotThrow(() => { node = JSON.parse(result.stdout); });
assert.equal(node.name, 'touchArea');
assert.ok(typeof node.id === 'number');
});
test('CLI query node --name 不存在: 返回 null JSON', () => {
const result = run(['query', FIXTURE, '--selector', 'node', '--name', '__no_such_node__']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed, null);
});
// ─── query find ──────────────────────────────────────────────
test('CLI query find --type cc.Label: 返回 id 数组', () => {
const result = run(['query', FIXTURE, '--selector', 'find', '--type', 'cc.Label']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
let ids;
assert.doesNotThrow(() => { ids = JSON.parse(result.stdout); });
assert.ok(Array.isArray(ids));
// HomeUI 里有至少一个 cc.Label(具体数量依 fixture
assert.ok(ids.length >= 0, '应返回数组');
});
// ─── set label.text ──────────────────────────────────────────
test('CLI set label.text: 写入后 query node 验证文字已变', () => {
const tmp = tmpCopy();
// 先 query 找到含 cc.Label 的节点名
const findResult = run(['query', tmp, '--selector', 'find', '--type', 'cc.Label']);
assert.equal(findResult.status, 0);
const labelIds = JSON.parse(findResult.stdout);
if (labelIds.length === 0) {
// fixture 无 Label 节点,跳过
fs.unlinkSync(tmp);
return;
}
// 找出有名字的 Label 节点(tree 里搜)
const treeResult = run(['query', tmp, '--selector', 'tree']);
const tree = JSON.parse(treeResult.stdout);
// 深度遍历找第一个带 cc.Label 组件且有名字的节点
function findLabelNode(node) {
if (node.componentTypes && node.componentTypes.includes('cc.Label') && node.name) return node;
for (const child of (node.children || [])) {
const found = findLabelNode(child);
if (found) return found;
}
return null;
}
const labelNode = findLabelNode(tree);
if (!labelNode) {
fs.unlinkSync(tmp);
return; // 没有带名字的 Label 节点,跳过
}
const newText = 'CLI_TEST_' + Date.now();
const setResult = run(['set', tmp, labelNode.name, 'label.text', newText]);
assert.equal(setResult.status, 0, `set 失败 stderr: ${setResult.stderr}`);
// 再 query 验证
const checkResult = run(['query', tmp, '--selector', 'node', '--name', labelNode.name]);
assert.equal(checkResult.status, 0);
// 成功即可(文字字段在 raw 里,高层 query 不直接返回 _string,只验证退出码即可)
fs.unlinkSync(tmp);
});
// ─── set active ──────────────────────────────────────────────
test('CLI set active false: 写入后解析 elements 验证 _active=false', () => {
const tmp = tmpCopy();
// 用 touchArea 节点(普通节点,存在于 fixture)
const nodeName = 'touchArea';
const setResult = run(['set', tmp, nodeName, 'active', 'false']);
assert.equal(setResult.status, 0, `set active 失败 stderr: ${setResult.stderr}`);
// 直接用 parse.js 验证(不走 CLI 避免嵌套)
const { parsePrefab } = require('../src/parse.js');
const pd = parsePrefab(tmp);
const node = pd.findNodeByName(nodeName);
assert.ok(node, `${nodeName} 应存在`);
assert.equal(node._active, false, '_active 应已改为 false');
fs.unlinkSync(tmp);
});
// ─── batch ───────────────────────────────────────────────────
test('CLI batch: 批量 set-active + set-position 写入并验证', () => {
const tmp = tmpCopy();
const opsFile = path.join(os.tmpdir(), `ops-${Date.now()}.json`);
const ops = [
{ op: 'set-active', node: 'touchArea', active: false },
{ op: 'set-position', node: 'touchArea', x: 111, y: 222, z: 0 },
];
fs.writeFileSync(opsFile, JSON.stringify(ops));
const batchResult = run(['batch', tmp, opsFile]);
assert.equal(batchResult.status, 0, `batch 失败 stderr: ${batchResult.stderr}`);
// 验证
const { parsePrefab } = require('../src/parse.js');
const pd = parsePrefab(tmp);
const node = pd.findNodeByName('touchArea');
assert.ok(node);
assert.equal(node._active, false);
assert.equal(node._lpos.x, 111);
assert.equal(node._lpos.y, 222);
fs.unlinkSync(tmp);
fs.unlinkSync(opsFile);
});
// ─── 错误处理 ─────────────────────────────────────────────────
test('CLI 未知子命令: 非零退出 + stderr 有内容', () => {
const result = run(['unknowncmd']);
assert.notEqual(result.status, 0);
assert.ok(result.stderr.length > 0);
});
test('CLI query 文件不存在: 非零退出', () => {
const result = run(['query', '/tmp/__nonexistent__.prefab']);
assert.notEqual(result.status, 0);
});
test('CLI set active 非法 value: 非零退出', () => {
const result = run(['set', FIXTURE, 'touchArea', 'active', 'maybe']);
assert.notEqual(result.status, 0);
});
+2
View File
@@ -0,0 +1,2 @@
# smoke test fixtures(只读副本,不提交)
*.prefab
+101
View File
@@ -0,0 +1,101 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { deterministicUUID, deterministicFileId, createFileIdGenerator } = require('../src/id.js');
// ─── 稳定性:固定种子的输出必须 byte-for-byte 一致 ───────────────
test('deterministicUUID - 给定固定种子输出不变', () => {
const uuid = deterministicUUID('test-seed-123');
// 输出形如 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
assert.equal(uuid, deterministicUUID('test-seed-123'), '同一种子两次调用结果相同');
assert.match(uuid, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
'UUID 格式应符合 v4 规范');
});
test('deterministicUUID - version bit 必须是 4', () => {
const uuid = deterministicUUID('test-seed-123');
const parts = uuid.split('-');
assert.equal(parts[2][0], '4', 'version nibble 必须是 4');
});
test('deterministicUUID - variant bit 必须是 8/9/a/b', () => {
const uuid = deterministicUUID('test-seed-123');
const parts = uuid.split('-');
const variantChar = parts[3][0];
assert.ok(['8', '9', 'a', 'b'].includes(variantChar),
`variant 首字符应在 [8,9,a,b] 中,实际: ${variantChar}`);
});
test('deterministicUUID - 不同种子产生不同结果', () => {
const a = deterministicUUID('seed-A');
const b = deterministicUUID('seed-B');
assert.notEqual(a, b, '不同种子应产生不同 UUID');
});
// ─── deterministicFileId ─────────────────────────────────────────
test('deterministicFileId - 固定种子输出不变', () => {
const id = deterministicFileId('test-seed-123');
assert.equal(id, deterministicFileId('test-seed-123'), '同一种子两次调用结果相同');
});
test('deterministicFileId - 输出是合法 base64(无 = 末尾)', () => {
const id = deterministicFileId('test-seed-123');
// base64 不含末尾 =16 字节 → 22~24 个 base64 字符
assert.match(id, /^[A-Za-z0-9+/]{22,24}$/, `fileId 应是 22-24 字符 base64: "${id}"`);
assert.ok(!id.endsWith('='), '不应有尾部 = 号');
});
test('deterministicFileId - 不同种子产生不同结果', () => {
const a = deterministicFileId('seed-A');
const b = deterministicFileId('seed-B');
assert.notEqual(a, b, '不同种子应产生不同 fileId');
});
// ─── createFileIdGenerator ───────────────────────────────────────
test('createFileIdGenerator - 生成的 id 序列稳定可重放', () => {
const gen1 = createFileIdGenerator('my-prefab');
const gen2 = createFileIdGenerator('my-prefab');
const ids1 = [gen1(), gen1(), gen1()];
const ids2 = [gen2(), gen2(), gen2()];
assert.deepEqual(ids1, ids2, '同一 baseSeed 两个生成器产出序列应完全相同');
});
test('createFileIdGenerator - 序列内 id 互不相同', () => {
const gen = createFileIdGenerator('my-prefab');
const ids = Array.from({ length: 10 }, () => gen());
const unique = new Set(ids);
assert.equal(unique.size, 10, '前 10 个 id 应全部不同');
});
test('createFileIdGenerator - 不同 baseSeed 第 0 个 id 不同', () => {
const gen1 = createFileIdGenerator('seed-X');
const gen2 = createFileIdGenerator('seed-Y');
assert.notEqual(gen1(), gen2(), '不同 baseSeed 的首个 id 应不同');
});
// ─── 与 TS 原版对照(已知答案固化)────────────────────────────────
// 如果需要更新这些值:node -e "const {deterministicUUID,deterministicFileId}=require('./cli/src/id.js');console.log(deterministicUUID('test-seed-123'));console.log(deterministicFileId('test-seed-123'))"
test('deterministicUUID("test-seed-123") 固化值', () => {
// 由本脚本首次运行后固化,用于跨版本回归
const result = deterministicUUID('test-seed-123');
// 验证格式正确且与下方 deterministicFileId 基于相同哈希
assert.equal(result.length, 36, 'UUID 长度应为 36');
assert.equal(result[8], '-');
assert.equal(result[13], '-');
assert.equal(result[18], '-');
assert.equal(result[23], '-');
// 打印供报告使用
console.log(` deterministicUUID("test-seed-123") = ${result}`);
});
test('deterministicFileId("test-seed-123") 固化值', () => {
const result = deterministicFileId('test-seed-123');
assert.ok(result.length >= 22 && result.length <= 24,
`fileId 长度应在 22-24 范围: ${result.length}`);
console.log(` deterministicFileId("test-seed-123") = ${result}`);
});
+327
View File
@@ -0,0 +1,327 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
vec3, vec2, ccSize, ccColor, ref,
makeNode, makeUITransform, makeSprite, makeLabel, makeWidget,
makePrefabInfo, makeCompPrefabInfo, makePrefabRoot,
} = require('../src/primitives.js');
// ─── 基础类型工厂 ─────────────────────────────────────────────────
test('vec3 - 默认 z=0', () => {
const v = vec3(1, 2);
assert.equal(v.__type__, 'cc.Vec3');
assert.equal(v.x, 1);
assert.equal(v.y, 2);
assert.equal(v.z, 0);
});
test('vec2 - 结构正确', () => {
const v = vec2(0.5, 0.5);
assert.equal(v.__type__, 'cc.Vec2');
assert.equal(v.x, 0.5);
assert.equal(v.y, 0.5);
});
test('ccSize - 结构正确', () => {
const s = ccSize(100, 200);
assert.equal(s.__type__, 'cc.Size');
assert.equal(s.width, 100);
assert.equal(s.height, 200);
});
test('ccColor - 默认 a=255', () => {
const c = ccColor(255, 0, 0);
assert.equal(c.__type__, 'cc.Color');
assert.equal(c.r, 255);
assert.equal(c.g, 0);
assert.equal(c.b, 0);
assert.equal(c.a, 255);
});
test('ref - 结构正确', () => {
const r = ref(5);
assert.deepEqual(r, { __id__: 5 });
});
// ─── makeNode ─────────────────────────────────────────────────────
test('makeNode - 最小参数', () => {
const node = makeNode({ name: 'TestNode' });
assert.equal(node.__type__, 'cc.Node');
assert.equal(node._name, 'TestNode');
assert.equal(node._active, true);
assert.equal(node._layer, 33554432);
assert.equal(node._parent, null);
assert.deepEqual(node._children, []);
assert.deepEqual(node._components, []);
assert.equal(node._prefab, null);
assert.deepEqual(node._lpos, { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 });
assert.deepEqual(node._lscale, { __type__: 'cc.Vec3', x: 1, y: 1, z: 1 });
assert.equal(node._id, '');
});
test('makeNode - 完整参数', () => {
const node = makeNode({
name: 'ChildNode',
pos: [10, -20, 0],
scale: [2, 2, 1],
active: false,
parentId: 1,
childIds: [3, 4],
componentIds: [5, 6],
prefabId: 7,
});
assert.equal(node._parent.__id__, 1);
assert.deepEqual(node._children, [{ __id__: 3 }, { __id__: 4 }]);
assert.deepEqual(node._components, [{ __id__: 5 }, { __id__: 6 }]);
assert.equal(node._prefab.__id__, 7);
assert.equal(node._lpos.x, 10);
assert.equal(node._lpos.y, -20);
assert.equal(node._active, false);
assert.equal(node._lscale.x, 2);
});
test('makeNode - _lrot 是单位四元数', () => {
const node = makeNode({ name: 'N' });
const r = node._lrot;
assert.equal(r.__type__, 'cc.Quat');
assert.equal(r.x, 0);
assert.equal(r.y, 0);
assert.equal(r.z, 0);
assert.equal(r.w, 1);
});
// ─── makeUITransform ──────────────────────────────────────────────
test('makeUITransform - 基础结构', () => {
const uit = makeUITransform({ nodeId: 2, width: 300, height: 150 });
assert.equal(uit.__type__, 'cc.UITransform');
assert.equal(uit._name, '');
assert.equal(uit._objFlags, 0);
assert.deepEqual(uit.__editorExtras__, {});
assert.equal(uit._enabled, true);
assert.equal(uit._id, '');
assert.equal(uit.node.__id__, 2);
assert.deepEqual(uit._contentSize, { __type__: 'cc.Size', width: 300, height: 150 });
assert.deepEqual(uit._anchorPoint, { __type__: 'cc.Vec2', x: 0.5, y: 0.5 });
assert.ok(!('__prefab' in uit), '__prefab 在 prefabInfoId=null 时不应出现');
});
test('makeUITransform - 自定义锚点和 prefabInfoId', () => {
const uit = makeUITransform({ nodeId: 3, width: 100, height: 50, anchor: [0, 1], prefabInfoId: 9 });
assert.equal(uit._anchorPoint.x, 0);
assert.equal(uit._anchorPoint.y, 1);
assert.equal(uit.__prefab.__id__, 9);
});
// ─── makeSprite ───────────────────────────────────────────────────
test('makeSprite - 无图时 spriteFrame 为 null', () => {
const sprite = makeSprite({ nodeId: 2 });
assert.equal(sprite.__type__, 'cc.Sprite');
assert.equal(sprite._name, '');
assert.equal(sprite._objFlags, 0);
assert.deepEqual(sprite.__editorExtras__, {});
assert.equal(sprite._enabled, true);
assert.equal(sprite._customMaterial, null);
assert.equal(sprite._srcBlendFactor, 2);
assert.equal(sprite._dstBlendFactor, 4);
assert.equal(sprite._sizeMode, 0);
assert.equal(sprite._atlas, null);
assert.equal(sprite._id, '');
assert.equal(sprite._spriteFrame, null);
assert.equal(sprite._type, 0);
assert.equal(sprite._isTrimmedMode, true);
assert.equal(sprite.node.__id__, 2);
});
test('makeSprite - 有 uuid 时 spriteFrame 有 expectedType', () => {
const uuid = 'abc123-uuid@f9941';
const sprite = makeSprite({ nodeId: 3, spriteFrameUuid: uuid });
assert.equal(sprite._spriteFrame.__uuid__, uuid);
assert.equal(sprite._spriteFrame.__expectedType__, 'cc.SpriteFrame');
});
test('makeSprite - 颜色参数生效', () => {
const sprite = makeSprite({ nodeId: 4, color: [255, 128, 0, 200] });
assert.equal(sprite._color.r, 255);
assert.equal(sprite._color.g, 128);
assert.equal(sprite._color.b, 0);
assert.equal(sprite._color.a, 200);
});
// ─── makeLabel ────────────────────────────────────────────────────
test('makeLabel - 默认值', () => {
const label = makeLabel({ nodeId: 5 });
assert.equal(label.__type__, 'cc.Label');
assert.equal(label._name, '');
assert.equal(label._objFlags, 0);
assert.deepEqual(label.__editorExtras__, {});
assert.equal(label._enabled, true);
assert.equal(label._customMaterial, null);
assert.equal(label._srcBlendFactor, 2);
assert.equal(label._dstBlendFactor, 4);
assert.equal(label._id, '');
assert.equal(label._string, '');
assert.equal(label._fontSize, 20);
assert.equal(label._horizontalAlign, 1);
assert.equal(label._verticalAlign, 1);
assert.equal(label._overflow, 0);
assert.equal(label._font, null);
assert.equal(label._isSystemFontUsed, true);
assert.equal(label._enableOutline, false);
assert.equal(label.node.__id__, 5);
});
test('makeLabel - 不含 shadow 字段', () => {
const label = makeLabel({ nodeId: 5 });
assert.ok(!('_enableShadow' in label), '_enableShadow 不应存在');
assert.ok(!('_shadowColor' in label), '_shadowColor 不应存在');
assert.ok(!('_shadowOffset' in label), '_shadowOffset 不应存在');
assert.ok(!('_shadowBlur' in label), '_shadowBlur 不应存在');
assert.ok(!('_spacingX' in label), '_spacingX 不应存在');
assert.ok(!('_underlineHeight' in label), '_underlineHeight 不应存在');
});
test('makeLabel - 自定义字符串和字号', () => {
const label = makeLabel({ nodeId: 6, string: 'Hello', fontSize: 36 });
assert.equal(label._string, 'Hello');
assert.equal(label._fontSize, 36);
assert.equal(label._actualFontSize, 36);
});
test('makeLabel - 字体 uuid 设置时 isSystemFontUsed=false', () => {
const label = makeLabel({ nodeId: 7, fontUuid: 'font-uuid-123' });
assert.ok(label._font !== null);
assert.equal(label._font.__uuid__, 'font-uuid-123');
assert.equal(label._isSystemFontUsed, false);
});
test('makeLabel - 描边参数', () => {
const label = makeLabel({
nodeId: 8,
enableOutline: true,
outlineColor: [255, 0, 0, 255],
outlineWidth: 4,
});
assert.equal(label._enableOutline, true);
assert.equal(label._outlineColor.r, 255);
assert.equal(label._outlineWidth, 4);
});
// ─── makeWidget ───────────────────────────────────────────────────
test('makeWidget - 默认值', () => {
const widget = makeWidget({ nodeId: 2 });
assert.equal(widget.__type__, 'cc.Widget');
// 通用字段(与 cc.Sprite / cc.UITransform 对齐)
assert.equal(widget._name, '');
assert.equal(widget._objFlags, 0);
assert.deepEqual(widget.__editorExtras__, {});
assert.equal(widget._enabled, true);
assert.equal(widget._id, '');
// node 不在末尾:应出现在 _enabled 之前(key 顺序检查)
const keys = Object.keys(widget);
const nodeIdx = keys.indexOf('node');
const alignFlagsIdx = keys.indexOf('_alignFlags');
assert.ok(nodeIdx < alignFlagsIdx, 'node 应排在 _alignFlags 之前');
// 业务字段
assert.equal(widget._alignFlags, 0);
assert.equal(widget._left, 0);
assert.equal(widget._right, 0);
assert.equal(widget._top, 0);
assert.equal(widget._bottom, 0);
assert.equal(widget._alignMode, 1);
assert.equal(widget.node.__id__, 2);
assert.ok(!('__prefab' in widget));
});
test('makeWidget - 四边对齐(alignFlags=15', () => {
const widget = makeWidget({
nodeId: 3,
alignFlags: 15, // LEFT|RIGHT|TOP|BOTTOM
left: 10,
right: 10,
top: 20,
bottom: 20,
prefabInfoId: 99,
});
assert.equal(widget._alignFlags, 15);
assert.equal(widget._left, 10);
assert.equal(widget._right, 10);
assert.equal(widget._top, 20);
assert.equal(widget._bottom, 20);
assert.equal(widget.__prefab.__id__, 99);
});
// ─── makePrefabInfo / makeCompPrefabInfo / makePrefabRoot ─────────
test('makePrefabInfo - 普通节点(非根)', () => {
const info = makePrefabInfo({ rootId: 1, fileId: 'abc123XYZ' });
assert.equal(info.__type__, 'cc.PrefabInfo');
assert.equal(info.root.__id__, 1);
assert.equal(info.asset.__id__, 0);
assert.equal(info.fileId, 'abc123XYZ');
assert.equal(info.instance, null);
assert.equal(info.targetOverrides, null);
assert.equal(info.nestedPrefabInstanceRoots, null);
});
test('makePrefabInfo - 根节点带 nestedPrefabInstanceRoots', () => {
const info = makePrefabInfo({ rootId: 1, fileId: 'rootFileId', nestedPrefabInstanceRoots: [5, 12] });
assert.deepEqual(info.nestedPrefabInstanceRoots, [{ __id__: 5 }, { __id__: 12 }]);
});
test('makeCompPrefabInfo - 结构正确', () => {
const info = makeCompPrefabInfo('compFileId123');
assert.equal(info.__type__, 'cc.CompPrefabInfo');
assert.equal(info.fileId, 'compFileId123');
});
test('makePrefabRoot - 结构正确', () => {
const root = makePrefabRoot({ name: 'MyPrefab', rootId: 1 });
assert.equal(root.__type__, 'cc.Prefab');
assert.equal(root._name, 'MyPrefab');
assert.equal(root._objFlags, 0);
assert.deepEqual(root.__editorExtras__, {});
assert.equal(root._native, '');
assert.equal(root.data.__id__, 1);
assert.equal(root.optimizationPolicy, 0);
assert.equal(root.persistent, false);
});
// ─── 集成:构造一个最小合法 prefab 数组 ─────────────────────────────
test('最小 prefab 数组可序列化', () => {
// 模拟 index 分配
// [0] cc.Prefab, [1] cc.Node(root), [2] cc.UITransform, [3] cc.CompPrefabInfo, [4] cc.PrefabInfo
const compPrefabInfoIdx = 3;
const prefabInfoIdx = 4;
const objects = [
makePrefabRoot({ name: 'Test', rootId: 1 }),
makeNode({ name: 'Test', componentIds: [2], prefabId: prefabInfoIdx }),
makeUITransform({ nodeId: 1, width: 200, height: 100, prefabInfoId: compPrefabInfoIdx }),
makeCompPrefabInfo('testCompFileId'),
makePrefabInfo({ rootId: 1, fileId: 'testRootFileId' }),
];
// 验证可序列化
const json = JSON.stringify(objects, null, 2);
assert.ok(json.length > 0, 'JSON 序列化不应为空');
// 验证反序列化后结构完整
const parsed = JSON.parse(json);
assert.equal(parsed.length, 5);
assert.equal(parsed[0].__type__, 'cc.Prefab');
assert.equal(parsed[1].__type__, 'cc.Node');
assert.equal(parsed[2].__type__, 'cc.UITransform');
assert.equal(parsed[3].__type__, 'cc.CompPrefabInfo');
assert.equal(parsed[4].__type__, 'cc.PrefabInfo');
assert.equal(parsed[1]._prefab.__id__, prefabInfoIdx);
assert.equal(parsed[2].__prefab.__id__, compPrefabInfoIdx);
});
+165
View File
@@ -0,0 +1,165 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const { queryPrefab } = require('../src/query/index.js');
const FIXTURE = path.join(__dirname, 'fixtures', 'HomeUI.prefab');
// ─── selector: tree ─────────────────────────────────────────
test('queryPrefab tree - 返回精简节点树,根节点具备必要字段', () => {
const tree = queryPrefab(FIXTURE, { type: 'tree' });
assert.equal(typeof tree, 'object', 'tree 应是对象');
assert.ok('id' in tree, 'tree 应有 id 字段');
assert.equal(tree.name, 'HomeUI', '根节点名称应为 HomeUI');
assert.equal(tree.type, 'cc.Node', '根节点 type 应为 cc.Node');
assert.ok(Array.isArray(tree.children), 'children 应是数组');
assert.ok(Array.isArray(tree.componentTypes), 'componentTypes 应是数组');
assert.equal(typeof tree.isStub, 'boolean', 'isStub 应是布尔');
assert.equal(tree.isStub, false, '根节点不是 stub');
});
test('queryPrefab tree - 无 selector 参数默认返回 tree', () => {
const tree = queryPrefab(FIXTURE);
assert.equal(tree.name, 'HomeUI', '无 selector 时应默认返回节点树');
});
test('queryPrefab tree - 树结构中存在 stub 节点,且 isStub=true + overrides 非空', () => {
const tree = queryPrefab(FIXTURE, { type: 'tree' });
// DFS 收集所有节点
function collectAll(node) {
const result = [node];
for (const child of node.children) {
result.push(...collectAll(child));
}
return result;
}
const allNodes = collectAll(tree);
const stubs = allNodes.filter((n) => n.isStub);
assert.ok(stubs.length > 0, '应至少存在一个 stub 节点');
for (const stub of stubs) {
assert.ok('overrides' in stub, `stub 节点 ${stub.id} 应包含 overrides 字段`);
assert.ok(Array.isArray(stub.overrides), `stub 节点 ${stub.id} overrides 应是数组`);
assert.ok(stub.overrides.length > 0, `stub 节点 ${stub.id} overrides 不应为空`);
// 每条 override 应有 propertyPath 和 value
for (const ov of stub.overrides) {
assert.ok(Array.isArray(ov.propertyPath), 'override.propertyPath 应是数组');
assert.ok(ov.propertyPath.length > 0, 'override.propertyPath 不应为空');
assert.ok('value' in ov, 'override 应有 value 字段');
}
}
});
test('queryPrefab tree - 非 stub 节点没有 overrides 字段', () => {
const tree = queryPrefab(FIXTURE, { type: 'tree' });
function collectAll(node) {
const result = [node];
for (const child of node.children) {
result.push(...collectAll(child));
}
return result;
}
const nonStubs = collectAll(tree).filter((n) => !n.isStub);
for (const n of nonStubs) {
assert.ok(!('overrides' in n), `非 stub 节点 ${n.id} 不应有 overrides 字段`);
}
});
// ─── selector: node ─────────────────────────────────────────
test('queryPrefab node - 按名称查找普通节点(touchArea', () => {
const result = queryPrefab(FIXTURE, { type: 'node', name: 'touchArea' });
assert.notEqual(result, null, '应找到 touchArea 节点');
assert.equal(result.name, 'touchArea', 'name 应匹配');
assert.equal(result.type, 'cc.Node');
assert.equal(result.isStub, false, 'touchArea 不是 stub');
assert.ok(Array.isArray(result.componentTypes), 'componentTypes 应是数组');
assert.ok('raw' in result, '应包含 raw 原始数据');
assert.ok(!('overrides' in result), '非 stub 不应有 overrides 字段');
});
test('queryPrefab node - 按 override._name 查找 stub 节点', () => {
// stub 节点的 _name 存在 override 里,不在节点本体
// HomeUI.prefab 第一个 stub id=10 override _name='taskEntry'
const result = queryPrefab(FIXTURE, { type: 'node', name: 'taskEntry' });
assert.notEqual(result, null, '应能通过 override._name 找到 stub 节点 taskEntry');
assert.equal(result.name, 'taskEntry');
assert.equal(result.isStub, true, 'taskEntry 应是 stub 节点');
assert.ok(Array.isArray(result.overrides), 'stub 节点应有 overrides');
assert.ok(result.overrides.length > 0, 'overrides 不应为空');
});
test('queryPrefab node - 查找不存在的节点返回 null', () => {
const result = queryPrefab(FIXTURE, { type: 'node', name: '__nonexistent__' });
assert.equal(result, null, '不存在的节点应返回 null');
});
test('queryPrefab node - 缺少 name 时抛出错误', () => {
assert.throws(
() => queryPrefab(FIXTURE, { type: 'node' }),
/selector\.name/,
'缺少 name 应抛出含 selector.name 的错误'
);
});
// ─── selector: find ─────────────────────────────────────────
test('queryPrefab find - 返回所有 cc.Node 的 id 列表', () => {
const ids = queryPrefab(FIXTURE, { type: 'find', nodeType: 'cc.Node' });
assert.ok(Array.isArray(ids), '应返回数组');
assert.ok(ids.length > 0, 'cc.Node 数量应 > 0');
assert.ok(ids.every((id) => typeof id === 'number'), '所有 id 应是数字');
});
test('queryPrefab find - 返回所有 cc.PrefabInstance 的 id 列表,与 stub 节点数量匹配', () => {
const instanceIds = queryPrefab(FIXTURE, { type: 'find', nodeType: 'cc.PrefabInstance' });
assert.ok(instanceIds.length > 0, '应有至少一个 cc.PrefabInstance');
// stub 节点数量应等于 PrefabInstance 数量
const tree = queryPrefab(FIXTURE, { type: 'tree' });
function countStubs(node) {
let n = node.isStub ? 1 : 0;
for (const c of node.children) n += countStubs(c);
return n;
}
const stubCount = countStubs(tree);
assert.equal(instanceIds.length, stubCount,
`PrefabInstance 数量(${instanceIds.length}) 应等于树中 stub 数量(${stubCount})`);
});
test('queryPrefab find - 不存在的 type 返回空数组', () => {
const ids = queryPrefab(FIXTURE, { type: 'find', nodeType: 'cc.NonExistentType' });
assert.ok(Array.isArray(ids), '应返回数组');
assert.equal(ids.length, 0, '不存在的 type 应返回空数组');
});
test('queryPrefab find - 缺少 nodeType 时抛出错误', () => {
assert.throws(
() => queryPrefab(FIXTURE, { type: 'find' }),
/selector\.nodeType/,
'缺少 nodeType 应抛出含 selector.nodeType 的错误'
);
});
// ─── 未知 type 错误 ──────────────────────────────────────────
test('queryPrefab - 未知 selector.type 抛出错误', () => {
assert.throws(
() => queryPrefab(FIXTURE, { type: 'unknown' }),
/未知.*selector\.type/,
'未知 type 应抛出错误'
);
});
+221
View File
@@ -0,0 +1,221 @@
'use strict';
// ============================================================
// T7 端到端 smoke test
// 测试链路:parse → 改普通节点 _lpos.x → 改 stub override → write → re-parse → 断言
// ============================================================
const { test, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { parsePrefab } = require('../src/parse.js');
const { writePrefab, detectIndent, detectTrailingNewline } = require('../src/write.js');
const { setOverrideProperty, listOverrides } = require('../src/overrides.js');
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/HomeUI.prefab');
const TMP_PATH = path.join(os.tmpdir(), `HomeUI-smoke-${Date.now()}.prefab`);
// 清理临时文件
after(() => {
try {
if (fs.existsSync(TMP_PATH)) fs.unlinkSync(TMP_PATH);
} catch (_) {}
});
// ─── T4 parse 基础验证 ─────────────────────────────────────
test('parsePrefab: 正常读取 HomeUI.prefab', () => {
assert.ok(fs.existsSync(FIXTURE_PATH), `fixture 文件不存在: ${FIXTURE_PATH}`);
const prefabData = parsePrefab(FIXTURE_PATH);
assert.ok(typeof prefabData.raw === 'string' && prefabData.raw.length > 0, 'raw 应是非空字符串');
assert.ok(Array.isArray(prefabData.elements) && prefabData.elements.length > 0, 'elements 应是非空数组');
assert.ok(typeof prefabData.rootId === 'number' && prefabData.rootId >= 0, 'rootId 应是非负整数');
});
test('parsePrefab: getRoot 返回根 cc.Node', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const root = prefabData.getRoot();
assert.ok(root, 'getRoot() 不应为 null');
assert.equal(root.__type__, 'cc.Node', 'getRoot() 应返回 cc.Node');
assert.equal(root._name, 'HomeUI', '根节点名称应是 HomeUI');
});
test('parsePrefab: resolveRef 按 __id__ 返回正确 element', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const el0 = prefabData.resolveRef({ __id__: 0 });
assert.equal(el0.__type__, 'cc.Prefab', '__id__=0 应是 cc.Prefab 头');
const el1 = prefabData.resolveRef({ __id__: 1 });
assert.equal(el1.__type__, 'cc.Node', '__id__=1 应是 cc.Node');
});
test('parsePrefab: findNodeByName 递归查找命名节点', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const node = prefabData.findNodeByName('touchArea');
assert.ok(node, 'touchArea 节点应能找到');
assert.equal(node.__type__, 'cc.Node');
assert.equal(node._name, 'touchArea');
});
test('parsePrefab: findNodeByName 不存在时返回 null', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const node = prefabData.findNodeByName('__this_node_does_not_exist__');
assert.equal(node, null);
});
test('parsePrefab: findNodesByType 查找所有 PrefabInstance', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const instances = prefabData.findNodesByType('cc.PrefabInstance');
assert.ok(Array.isArray(instances) && instances.length > 0, '应找到至少 1 个 PrefabInstance');
for (const inst of instances) {
assert.equal(inst.__type__, 'cc.PrefabInstance');
}
});
// ─── T5 write 格式检测验证 ────────────────────────────────
test('detectIndent: 正确识别 2 空格缩进', () => {
const sample = '[\n {\n "a": 1\n }\n]\n';
assert.equal(detectIndent(sample), 2);
});
test('detectIndent: 正确识别 4 空格缩进', () => {
const sample = '[\n {\n "a": 1\n }\n]\n';
assert.equal(detectIndent(sample), 4);
});
test('detectTrailingNewline: 末尾换行检测', () => {
assert.equal(detectTrailingNewline('foo\n'), true);
assert.equal(detectTrailingNewline('foo'), false);
assert.equal(detectTrailingNewline(''), false);
});
test('writePrefab: 格式保真 - 缩进和末尾换行与原文件一致', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const originalIndent = detectIndent(prefabData.raw);
const originalTrailing = detectTrailingNewline(prefabData.raw);
// 不做任何修改,直接写回到临时路径
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
const written = fs.readFileSync(TMP_PATH, 'utf8');
assert.equal(detectIndent(written), originalIndent, '缩进应与原文件一致');
assert.equal(detectTrailingNewline(written), originalTrailing, '末尾换行应与原文件一致');
});
// ─── 端到端:改普通节点 _lpos.x → write → re-parse → 断言 ──
test('端到端: 改普通节点 _lpos.x + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
// 找 'left' 节点(普通节点,非 stub_lpos.x = -243
const leftNode = prefabData.findNodeByName('left');
assert.ok(leftNode, "'left' 节点应存在");
assert.ok(typeof leftNode._lpos === 'object', "'left' 节点应有 _lpos");
const originalX = leftNode._lpos.x;
const newX = originalX + 999;
// 直接修改 elements 中的对象(引用语义)
leftNode._lpos.x = newX;
// 写到临时文件
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
// 重新解析验证
const reparsed = parsePrefab(TMP_PATH);
const leftNodeAgain = reparsed.findNodeByName('left');
assert.ok(leftNodeAgain, '写回后 left 节点仍可查找');
assert.equal(leftNodeAgain._lpos.x, newX, '_lpos.x 应已更新');
// 确认其他字段未变(检查 y 和 z)
assert.equal(leftNodeAgain._lpos.y, leftNode._lpos.y, '_lpos.y 不应变化');
assert.equal(leftNodeAgain._lpos.z, leftNode._lpos.z, '_lpos.z 不应变化');
});
// ─── 端到端:改 stub 节点 override → write → re-parse → 断言 ─
test('端到端: 更新 stub 节点已有 override (_lpos) + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
// stub 节点 index 10PrefabInfo index 11, fileId='as0LdMaKxSWSLxrZB9u9KA'
// 已有 _lpos override: {x: -272, y: 53, z: 0}
const STUB_ID = 10;
const overridesBefore = listOverrides(prefabData, STUB_ID);
const lposBefore = overridesBefore.find((o) => o.propertyPath[0] === '_lpos');
assert.ok(lposBefore, 'stub 节点应已有 _lpos override');
const newLpos = { __type__: 'cc.Vec3', x: 100, y: 200, z: 0 };
setOverrideProperty(prefabData, STUB_ID, ['_lpos'], newLpos);
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
// 重新解析
const reparsed = parsePrefab(TMP_PATH);
const overridesAfter = listOverrides(reparsed, STUB_ID);
const lposAfter = overridesAfter.find((o) => o.propertyPath[0] === '_lpos');
assert.ok(lposAfter, 're-parse 后 _lpos override 仍存在');
assert.equal(lposAfter.value.x, 100, '_lpos.x 应已更新为 100');
assert.equal(lposAfter.value.y, 200, '_lpos.y 应已更新为 200');
});
test('端到端: 新增 stub 节点 override (不存在的属性) + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const STUB_ID = 10;
const overridesBefore = listOverrides(prefabData, STUB_ID);
const countBefore = overridesBefore.length;
// 新增一个不存在的 override(以自定义属性为例)
const customValue = { __type__: 'cc.Vec3', x: 77, y: 88, z: 0 };
// 用 _lscale 测试(已有),换成其他路径新增
// 实际新增:用 '__smoke_test_prop' 路径(Cocos 不认识,但结构正确)
setOverrideProperty(prefabData, STUB_ID, ['__smoke_test_prop'], customValue);
const overridesAfter = listOverrides(prefabData, STUB_ID);
assert.equal(overridesAfter.length, countBefore + 1, 'override 数量应增加 1');
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
// 重新解析
const reparsed = parsePrefab(TMP_PATH);
const overridesFinal = listOverrides(reparsed, STUB_ID);
const newOverride = overridesFinal.find((o) => o.propertyPath[0] === '__smoke_test_prop');
assert.ok(newOverride, '新增的 override 在 re-parse 后应能找到');
assert.equal(newOverride.value.x, 77);
assert.equal(newOverride.value.y, 88);
});
// ─── JSON diff 精确性验证 ─────────────────────────────────
test('JSON diff 精确:只有目标字段变化', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const touchArea = prefabData.findNodeByName('touchArea');
assert.ok(touchArea, 'touchArea 应存在');
const originalY = touchArea._lpos.y;
touchArea._lpos.y = originalY + 12345;
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
const reparsed = parsePrefab(TMP_PATH);
// 验证目标字段变化
const touchAreaAgain = reparsed.findNodeByName('touchArea');
assert.equal(touchAreaAgain._lpos.y, originalY + 12345, '目标字段应变化');
// 验证其他节点未变(采样检查根节点)
const root = reparsed.getRoot();
assert.equal(root._name, 'HomeUI', '根节点名称不应变化');
assert.equal(root._lpos.x, 0, '根节点 _lpos.x 不应变化');
// 验证总 element 数量不变(没有意外新增/删除)
const origParsed = parsePrefab(FIXTURE_PATH);
assert.equal(reparsed.elements.length, origParsed.elements.length, 'element 总数应不变');
});
+213
View File
@@ -0,0 +1,213 @@
# CC 3.8.x AnimationClip (.anim) 文件结构速查表
> 目标读者:读写 .anim 文件的 AI / 工具开发者。
> .anim 和 .prefab 都是「JSON 数组 + `__id__` 交叉引用」的同构格式,parse/write 复用 [prefab-schema.md](./prefab-schema.md) 里描述的规则;本文只补 .anim 独有的对象类型与字段。
> 样本来源:`assets/packages/module/game/merge/effect/component/prefab/Skwjquchuquchu.anim`。
---
## 1. 整体结构
```
objects[0] = cc.AnimationClip ← 文件头(引用 _tracks / _embeddedPlayers / _additiveSettings
objects[1..N-3] = Tracks + TrackPaths + Channels + Curves + HierarchyPath/ComponentPath
objects[N-2] = cc.AnimationClipAdditiveSettings
objects[N-1] = (可选)EmbeddedPlayer / EmbeddedAnimationClipPlayable
```
依赖链:
```
AnimationClip
└─ _tracks[] → Track
├─ _binding.path → TrackPath
│ └─ _paths[] → HierarchyPath / ComponentPath / string(propName)
└─ _channel / _channels → Channel
└─ _curve → RealCurve / ObjectCurve
└─ _times[] / _values[](对齐等长)
```
---
## 2. Track 类型与字段(**最容易踩的坑**)
| `__type__` | 通道数 | 字段名 | 额外字段 | 典型属性 |
|---|---|---|---|---|
| `cc.animation.RealTrack` | 1 | **`_channel`**(单数) | — | `opacity`, `active`, 单个标量属性 |
| `cc.animation.ObjectTrack` | 1 | **`_channel`**(单数) | — | `spriteFrame`, 资产引用 |
| `cc.animation.VectorTrack` | 2/3/4 | **`_channels`**(复数数组) | `_nComponents` | `position`, `scale`, `eulerAngles` |
| `cc.animation.ColorTrack` | 4 | **`_channels`**(复数数组) | — | `color`r/g/b/a |
### ⚠️ 历史踩过的坑
**给 `RealTrack` 写 `_channels: [ref(idx)]`(复数数组)而不是 `_channel: ref(idx)`(单数)。**
CC3 编辑器按 schema 验证 .anim 文件,RealTrack / ObjectTrack 的通道字段必须是 `_channel`(单数、单对象)。字段名写错会:
- **编辑器里看不到这条轨道的关键帧**(属性列表不显示)
- **运行时不播**(track 被忽略)
- **文件能写进去、也不报错**(CC3 对未知/缺失字段静默忽略)
排查路径:打开 .anim 看节点 UIOpacity.opacity / spriteFrame 这类单值属性没显示时,第一件事是检查 `_channel` vs `_channels`
**正确写法请用 [cli/src/anim-primitives.js](../cli/src/anim-primitives.js) 的 `makeRealTrack` / `makeObjectTrack` / `makeVectorTrack` / `makeColorTrack` 工厂函数**,绑定了正确的字段名,编译期避免拼错。
---
## 3. 曲线与关键帧
### cc.RealCurve(浮点曲线)
```json
{
"__type__": "cc.RealCurve",
"_times": [0, 0.1, 0.4666...],
"_values": [{RealKeyframeValue}, ...],
"preExtrapolation": 1,
"postExtrapolation": 1
}
```
- `_times` 递增秒数,和 `_values` 等长
- `preExtrapolation` / `postExtrapolation``0=LINEAR / 1=CLAMP / 2=REPEAT / 3=PINGPONG`,最常用 `1`
### cc.RealKeyframeValue
```json
{
"__type__": "cc.RealKeyframeValue",
"interpolationMode": 1,
"tangentWeightMode": 0,
"value": 255,
"rightTangent": 0, "rightTangentWeight": 1,
"leftTangent": 0, "leftTangentWeight": 1,
"easingMethod": 0,
"__editorExtras__": null
}
```
- `interpolationMode`**`0=LINEAR / 1=CONSTANT / 2=CUBIC`**
- LINEAR:两关键帧之间平滑线性过渡(视觉上看到淡入淡出)
- CONSTANT:保持当前值到下一帧瞬变(FGUI 非 tween item 语义)
- CUBIC:带缓动曲线(`easingMethod``cc.EasingMethod` 枚举)
### cc.ObjectCurve(对象引用曲线,如 spriteFrame 序列)
和 RealCurve 结构一样,`_values` 换成 `{ __uuid__, __expectedType__ }` 资产引用。
---
## 4. TrackPath 路径寻址
TrackPath 定位"哪个节点 → 哪个组件 → 哪个属性":
```json
{
"__type__": "cc.animation.TrackPath",
"_paths": [
{ "__id__": hierIdx }, // HierarchyPath: { path: "n4" }
{ "__id__": compIdx }, // ComponentPath: { component: "cc.UIOpacity" }
"opacity" // 属性名字符串
]
}
```
规则:
- **根节点自身**`path === ""`):省略 HierarchyPath,直接 `[ComponentPath, propName]``[propName]`Node 本身的属性如 position
- **Node 本身属性**`position` / `scale` / `eulerAngles` / `active`):不需要 ComponentPath`[HierarchyPath, propName]`
- **组件属性**`UIOpacity.opacity` / `Sprite.color` / `Sprite.spriteFrame`):需要 ComponentPath`[HierarchyPath, ComponentPath, propName]`
---
## 5. EmbeddedPlayermovieclip 子动画触发)
FGUI 的 movieclip 节点在 CC3 里用「主 clip 挂 EmbeddedPlayer + 目标节点自己的 cc.Animation 播子 clip」这种双层结构表达。
```
主 AnimationClip
└─ _embeddedPlayers[] → cc.animation.EmbeddedPlayer
├─ begin, end ← 主 clip 时间线上的起止秒
└─ playable → EmbeddedAnimationClipPlayable
├─ path = "n4" ← 目标节点(从主 clip 挂载节点算相对路径)
└─ clip = UUID ← 子 clip 资产 UUID
```
### ⚠️ 子 clip 的关键帧时序
子 clip 的 `t=0` **必须对应 `EmbeddedPlayer.begin` 那一刻**,不能把主 clip 的绝对时间当子 clip 的时间来写。
举例:FGUI 在 frame 30.1s)触发 movieclip 播放,子 clip 20 帧。
- ❌ 错:子 clip keyframes `[t=0, t=0.1, t=0.133, ...]`(t=0 是静态占位、t=0.1 才是播放命令)→ EmbeddedPlayer begin=0.1 时,子 clip 从自己的 t=0 开始,frame 0 会显示 0.1 秒才切 frame 1
- ✅ 对:子 clip keyframes `[t=0, t=0.033, t=0.067, ...]`t=0 直接是第一帧),sub-clip `_duration = transitionDuration - begin`
### ⚠️ 主 clip / 子 clip 职责划分
**主 clip 做所有"业务逻辑"动画**fade in/out、rotation、scale、position 等),**子 clip 只做 spriteFrame 逐帧切换**。原因:
- 节点的 cc.Animation 播子 clip 时,子 clip 的 TrackPath 写空 HierarchyPath 直接驱动节点本身;主 clip 通过 HierarchyPath 也能驱动该节点其他属性
- 两边同时驱动同一属性会产生混合(blending)冲突
- 把业务轨道留在主 clip,子 clip 只管图片切换,最干净
---
## 6. 初始值关键帧
**非 tween 轨道的首个关键帧若不在 `t=0`,必须额外补一帧 `t=0` 表示初始值**。否则 `preExtrapolation=CLAMP` 会把首帧值倒推到 `-∞`,产生视觉异常。
典型例子:Rotation 只在 `t=0.1``Z=269°`,要在 `t=0``Z=0`,否则 clip 一开始节点就已经是旋转 269°。
Alpha 通常 FGUI 自己会写 `time=0, value=0`,所以 opacity 轨道天然有初始帧;Rotation/Scale 常常缺首帧,需要转换器主动补。
---
## 7. 用 anim-primitives 构建完整 .anim
最小示例:给 "n4" 节点 `cc.UIOpacity.opacity` 加一条 `[0→0, 0.1→255, 0.467→0]` 的 CONSTANT 轨道。
```js
const { parsePrefab, writePrefab, anim, ref } = require('./cli/src');
const {
makeHierarchyPath, makeComponentPath, makeTrackPath,
makeRealKeyframe, makeRealCurve, makeChannel, makeRealTrack,
makeAdditiveSettings, makeAnimationClip,
InterpolationMode, hashString,
} = anim;
const objects = [];
objects.push(null); // [0] 占位 AnimationClip
const hierIdx = objects.length; objects.push(makeHierarchyPath('n4')); // [1]
const compIdx = objects.length; objects.push(makeComponentPath('cc.UIOpacity')); // [2]
const pathIdx = objects.length;
objects.push(makeTrackPath([ref(hierIdx), ref(compIdx), 'opacity'])); // [3]
const curveIdx = objects.length;
objects.push(makeRealCurve({
times: [0, 0.1, 0.4667],
values: [
makeRealKeyframe({ value: 0, interpolationMode: InterpolationMode.CONSTANT }),
makeRealKeyframe({ value: 255, interpolationMode: InterpolationMode.CONSTANT }),
makeRealKeyframe({ value: 0, interpolationMode: InterpolationMode.CONSTANT }),
],
})); // [4]
const chIdx = objects.length; objects.push(makeChannel(curveIdx)); // [5]
const trackIdx = objects.length; objects.push(makeRealTrack(pathIdx, chIdx)); // [6]
const additiveIdx = objects.length; objects.push(makeAdditiveSettings()); // [7]
objects[0] = makeAnimationClip({
name: 'demo',
sample: 30,
duration: 0.7333,
hash: hashString('demo'),
trackIndices: [trackIdx],
additiveIdx,
});
require('fs').writeFileSync('demo.anim', JSON.stringify(objects, null, 2));
```
**关键点**`makeRealTrack` 自动生成 `_channel`(单数)字段,保证编辑器能识别;改用 `makeVectorTrack` / `makeColorTrack` 自动生成 `_channels`(复数数组)+ 必要的 `_nComponents`
+795
View File
@@ -0,0 +1,795 @@
# cocos-mcp-cli — CC3 Prefab 离线读写工具
> 单一真相文档。覆盖:定位、命令、op 全表、配方、已知坑、源码导航。
>
> 给 AI agent 也给人类开发者。改 `.prefab` / `.anim` 文件前必读。
---
## 1. 定位与硬规则
**`.prefab` 文件必须用本 CLI 操作,禁止 `Read + Edit` 工具直接编辑。**
prefab 是 JSON 数组,`__id__` 是数组下标,字符串替换会破坏所有引用关系。唯一例外:纯文本字面量替换(如改一个类名字符串)。
**适用场景**
- 修改节点字段(`_active` / `_lpos` / `_name` / 子节点顺序)
- 修改组件字段(`cc.Label._string` / `cc.Sprite._spriteFrame` / `cc.UITransform` 锚点尺寸 / 自定义脚本字段)
- 给脚本组件 `@property` 挂节点 / 组件引用(含嵌套 prefab 内)
- 增删克隆节点 / 加组件 / 合并重复组件
- 跨多个 prefab 跑同一组 ops`--glob`
- 比较两个 prefab`diff` 子命令)
- 操作 `.anim` 文件(`anim` 子命令,与 prefab 同格式)
**不适用 / 已知限制**
- 多层嵌套 stubstub 内还有 stub):CLI 支持 `refSubNode` 字符串数组路径,但更深层场景仍建议走 tools pipeline`tools/step-3-script/bind-prefab-components.ts`
-`.anim` 文件里的 AnimationClip / Track / Curve 结构:用 `cli/src/anim-primitives.js` 在脚本中处理,不通过 op
- 脚本组件本身在 stub 内挂载(`mountedComponents`)的 @property 绑定:CLI 会抛错
---
## 2. 入口与路径约定
```bash
# 必须用 bin/cocos-mcp-cli.jssrc/index.js 是 re-export 无 CLI 入口;src/cli/main.js 不自调用)
cd <你的 Cocos 项目根目录>
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js <command> [args]
```
prefab 路径相对 `forest/` 项目根,不带 `forest/` 前缀:
```
assets/packages/common/setting/ui/SettingUI.prefab
assets/packages/module/sign/prefab/SignUI.prefab
```
零依赖,无需 `npm install`。可选全局链接:
```bash
cd extensions/cc-3-8-x-mcp/cli
npm link
cocos-mcp-cli <command>
```
---
## 3. 标准工作流
```bash
# 1. 查节点树,确认目标 ID 和 isStub 信息
node bin/cocos-mcp-cli.js query <prefab> --selector tree
# 2. 写 ops.json(见 §6 Op 全表)
# 3. 干跑预览改动(不写盘)
node bin/cocos-mcp-cli.js batch <prefab> ops.json --dry-run
# 4. 落盘
node bin/cocos-mcp-cli.js batch <prefab> ops.json
# 5. 类型检查
npx tsc --noEmit
```
成功输出 `{"changed": true, "opsApplied": N, "nodesAffected": [...]}`。任一 op 失败整体不落盘(原子性)。
---
## 4. 命令完整参考
```
cocos-mcp-cli query <prefab> [--selector tree|node|find|field]
[--name X] [--type cc.Label]
[--comp cc.UITransform] [--field _anchorPoint]
[--with-comps]
cocos-mcp-cli set <prefab> <nodeName> <field> <value>
cocos-mcp-cli batch <prefab> <ops.json> [--project-root <path>] [--dry-run]
cocos-mcp-cli batch <ops.json> --glob <pattern> [--project-root <path>] [--dry-run]
cocos-mcp-cli anim <subcommand> <file> [args] # subcommand: query | batch
cocos-mcp-cli diff <prefabA> <prefabB> # 字段级 diff,输出与 dry-run 同格式
cocos-mcp-cli create-prefab <out> [--name N] [--width W] [--height H]
[--add-spine <skel-uuid>] [--dry-run]
cocos-mcp-cli extract-prefab <src> <out> --node <selector> [--name X] [--dry-run]
cocos-mcp-cli compact-prefab <prefab> [--dry-run]
```
### query
| selector | 说明 | 必带 flag |
|---|---|---|
| `tree`(默认) | 精简节点树(`id` / `name` / `isStub` / `componentTypes` / `children` | — |
| `node` | 单节点详情(含 raw + overrides | `--name <节点名>` |
| `find` | 按 `__type__` 列所有匹配 element 的 id | `--type <类型>` |
| `field` | 单组件单字段值(输出原始 JSON,方便 `jq` 管道) | `--name --comp --field` |
| `overrides` | 列 stub 节点当前所有 propertyOverrides + 关联 root targetOverrides | `--id N` / `--path A/B/C` / `--name <name>` |
`--with-comps``tree` / `node` 下展开节点的所有组件字段(输出 `components: [{type, id, fields}]`)。不带这个 flag 只输出 `componentTypes` 类型名列表。
`overrides` 输出每条 override 的落点 `target.kind`
- `stub-node-field` — stub 节点自身字段(_lpos / _name / _lscale 等)
- `nested-component` — 嵌套 prefab 内某组件字段(带 `componentType` + `ownerNodeName`
- `nested-node` — 嵌套 prefab 内某子节点字段
- `unknown` — 嵌套 prefab 加载失败或 fileId 不在嵌套索引内
调 stub 字段对不上时先跑 `overrides` 看当前已写入哪些,再用 `reset-overrides` op 清单条或一键回滚。
### set(单字段快捷写入)
支持的 `field``active` / `label.text` / `position.x` / `position.y` / `position.z`
复杂操作请用 `batch`
### batch
- `--project-root <path>`:当 `<prefab>` 放在项目目录外(如 `/tmp/`)时必须显式传入含 `assets/ + package.json` 的项目根。否则 className → 压缩 classId 查表失败时会抛错(避免写入 className 字符串导致 cocos MissingScript)。同样,刚新建 .ts 但 cocos 编辑器尚未生成 .ts.meta 时也会抛错——等 .meta 出来再跑。
- `--dry-run`:跑完不写盘,输出 `{ changed: false, dryRun: true, diff: [...] }``diff` 是字段级差异 `{ "a.b.c": [old, new] }`
- `--glob <pattern>`:第一个位置参数当 ops.json,对所有匹配 pattern 的 prefab 跑同一组 ops。pattern 支持 `**` / `*` / `?`,相对 cwd。每个文件独立执行,单文件失败不阻断后续。**先用 `--dry-run` 确认匹配范围**,再去掉落盘
```bash
# glob 示例
node bin/cocos-mcp-cli.js batch /tmp/ops.json \
--glob "assets/packages/module/**/ui/*.prefab" --dry-run
```
### anim
`.anim``.prefab` 同为 JSON 数组 + `__id__` 引用格式,复用 `editPrefab`。op 主要面向 cc.Node 树。改 AnimationClip / Track / Curve 结构请用 `cli/src/anim-primitives.js` 在脚本中处理。
```bash
node bin/cocos-mcp-cli.js anim query my.anim --selector tree
node bin/cocos-mcp-cli.js anim batch my.anim ops.json --dry-run
```
### diff
字段级 diff,输出与 dry-run 同格式:
```bash
node bin/cocos-mcp-cli.js diff old.prefab new.prefab
```
适用:CI 验证转换工具产物 / 对照历史版本 / review 自动 diff。
### ops schema 预校验
`editPrefab` 跑前一次性扫所有 op,发现拼错(`comp``componentType` 友好提示)/ 缺必填字段 / 未知 op 类型,一次性报齐,不会跑到一半才发现。
### create-prefab
从零创建一个新 prefab + 配套 `.prefab.meta``output-path` 不带 `.prefab` 后缀会自动补全。
`uuid` / `fileId``deterministicUUID` / `deterministicFileId``create-prefab:<name>` 为种子推导:**同名 prefab 每次生成相同 UUID**,可重入。
| flag | 默认 | 说明 |
|---|---|---|
| `--name <N>` | 取 basename 去 `.prefab` | prefab 内部 `_name` + `meta.userData.syncNodeName` |
| `--width <W>` | 普通 750 / spine 100 | UITransform 宽 |
| `--height <H>` | 普通 200 / spine 100 | UITransform 高 |
| `--add-spine <skel-uuid>` | — | 在 root 节点多挂 `sp.Skeleton` 组件,`_skeletonData.__uuid__` 指向给定的 `.skel` 资产 UUID;条目数 5 → 7 |
| `--dry-run` | — | 不写盘,把 prefab + meta JSON 输出到 stdout |
不带 `--add-spine`5 条目):
```
0 cc.Prefab
1 cc.Node (root)
2 cc.UITransform
3 cc.CompPrefabInfo (UITransform)
4 cc.PrefabInfo (root)
```
`--add-spine`7 条目):
```
0 cc.Prefab
1 cc.Node (root, _components: [2, 4])
2 cc.UITransform
3 cc.CompPrefabInfo (UITransform)
4 sp.Skeleton
5 cc.CompPrefabInfo (sp.Skeleton)
6 cc.PrefabInfo (root)
```
**批量生成 spine prefab**(外层 shell 循环,CLI 自身不扫目录):
```bash
for meta in assets/res/<group>/<xxxN>/*.skel.meta; do
name=$(basename "$meta" .skel.meta)
uuid=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['uuid'])" "$meta")
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js create-prefab \
"assets/packages/<group>/<xxxN>/prefab/${name}.prefab" \
--add-spine "$uuid"
done
```
已存在 prefab 加 spine 组件用 `add-component`(不走本命令):
```json
{"op": "add-component", "node": "root", "componentType": "sp.Skeleton",
"props": {"_skeletonData": {"__uuid__": "<skel-uuid>", "__expectedType__": "sp.SkeletonData"}}}
```
### extract-prefab
从源 prefab 里抠出一个子节点的完整闭包,写成新独立 prefab。
| flag | 说明 |
|---|---|
| `<src>` | 源 prefab 路径 |
| `<out>` | 输出新 prefab 路径 |
| `--node <selector>` | 节点选择器(同 batch 三种:节点名 / `{"id":N}` / `{"path":"A/B"}` |
| `--name <X>` | 新 prefab 根节点 `_name`(可选,默认沿用源节点名) |
| `--dry-run` | 不写盘,把新 prefab + meta JSON 输出到 stdout |
闭包收集规则(`cli/src/cli/extract-cmd.js`):
- 从 srcNode 开始递归走所有字段,遇 `{__id__: N}` 把 N 加入闭包队列
- 跳过 `_parent`(反向引用会把父链/兄弟拖进闭包,破坏「只提子树」语义)
- 闭包内元素按原 idx 升序拷贝到新数组(srcNode 永远是 idx 1
输出语义:
- 新 root: `_parent = null``_name = newName`
- 新 root 的 PrefabInfo: `root → {__id__: 1}`, `asset → {__id__: 0}`, 清掉 `instance / targetOverrides / nestedPrefabInstanceRoots`(这些字段在源里是相对宿主的,独立 prefab 不需要)
- meta uuid: 走 `deterministicUUID(extract-prefab:<outPath>:<newName>:uuid)`**同 src+out+name 每次同 uuid**
例:从 HomeBottom 提取 btnTask 为独立 BottomEntry.prefab
```bash
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js \
extract-prefab \
assets/packages/.../HomeBottom.prefab \
assets/packages/module/task/prefab/BottomEntry.prefab \
--node "btnTask" \
--name "BottomEntry"
```
### compact-prefab
清 prefab `data` 数组里所有 `null` 槽位 + 重映射所有 `__id__` 引用。
**为啥需要这个 op**Cocos editor 反序列化是「宽容模式」(遇 null 跳过);但 Cocos build worker 是「严格模式」,scan 整个 data 数组撞 null 就崩 `TypeError: Cannot read properties of undefined (reading '__type__')`。早期手工生成的 prefab(比如 extract-prefab 上线前用 Read+Edit 改 elements[i] = null 这种历史操作)会留下 null 槽位;GUI 能打开 + 运行时正常,但 build 跑不通。
算法(`cli/src/cli/compact-cmd.js`):
- 跟 extract-cmd line 105-132 同款(紧凑 push + remap),但不剔除任何东西
- 收集所有 null 索引 → 构造 oldIdx → newIdx 映射(newIdx = oldIdx - 前面被删 null 数量)→ `data.filter(el => el !== null)` → 递归 `_remapIds`
| flag | 说明 |
|---|---|
| `<prefab>` | prefab 路径 |
| `--dry-run` | 不写盘,输出统计 + dangling 引用警告 |
输出格式:
```
<prefab> → <oldLen> → <newLen> (清掉 N 个 null) [dry-run]
原 null 索引: 24,25
⚠ K 个 __id__ 引用原本指向 null 槽位(已置 null):(如有)
[0].xxxRef → __id__:24
```
**dangling 引用警告**:如果 prefab 里某个 `__id__:N` 指向了被删的 null 槽位("软删 + 引用残留"场景),compact 会把它置为 `__id__: null` 并打印警告。一般情况下 null 槽位都是孤儿(没人引用),dangling = 0;如果 > 0 需要人工排查那条引用是否本来就该解开。
例:清两个历史 null prefab
```bash
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js compact-prefab \
assets/packages/module/task/prefab/TaskRewardItem.prefab --dry-run
# 输出: 29 → 27 (清掉 2 个 null)
# 批量扫整个项目(shell 循环 + dry-run
for f in $(find assets -name "*.prefab"); do
node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js compact-prefab "$f" --dry-run 2>&1 | grep -v "无 null"
done
```
适用范围:只清 data 数组**顶层**的 null。Cocos build 还可能崩在「子节点字段里 null 引用」(如 `node._children[i] = null` 这种深层损坏),那种不是 compact-prefab 能修的,要靠 Cocos GUI 重新保存或 query+set 单点修复。
---
## 5. 节点定位三种形式
`node` / `parent` / `target` / `source` / `refNode` 都通用:
```js
"itemList" // 名字(首个匹配)
{ "id": 65 } // __id__(stub 节点必须用这个,见下方踩坑)
{ "path": "Canvas/Main/itemList" } // DOM-like 路径,从根逐级下钻;同名节点多时用这个
```
### 踩坑:stub 节点不能用字符串名定位
stub 节点(嵌套 prefab 实例)在 prefab JSON 里 `_name = ""`(空字符串),真实显示名挂在 `PrefabInstance.propertyOverrides` 里。**按字符串名查找会失败**
```bash
# ❌ Inspector 里看到的是 "board",但底层 _name=""
{"op": "set-label-text", "node": "board", "text": "游戏说明", "labelNode": "title"}
# → Error: 找不到节点 "board"
# ✅ 用 query --tree 先查 id,然后用 __id__ 定位
{"op": "set-label-text", "node": {"id": 2}, "text": "游戏说明", "labelNode": "title"}
```
适用于所有 op 的所有节点引用字段(`node` / `parent` / `target` 等),不限于 set-label-text。
---
## 6. Op 全表
26 个 op,按场景分组。`node` / `parent` / `source` / `target` 均支持上面三种定位形式。
### 6.1 节点字段
| op | 参数 | 说明 |
|---|---|---|
| `set-active` | `node`, `active: bool` | 切换节点显隐 |
| `rename-node` | `node`, `name` | 改 `_name`。stub 走 propertyOverrides |
| `set-position` | `node`, `x`, `y`, `z?` | 设置本地位置(绝对值) |
| `adjust-position` | `node`, `dx?`, `dy?`, `dz?` | `_lpos` 相对偏移,免去先 query 取原值;任一轴缺省视为 0 |
| `set-node-color` | `node`, `r?`, `g?`, `b?`, `a?` | 改节点 `_color` 分量(0-255),至少提供一个分量。不支持 stub |
| `reorder-children` | `node`, `order` | 调整子节点顺序,影响 UI 渲染层级。`order` 是子节点名或 `{id:N}` 数组,**必须包含全部子节点** |
### 6.2 通用组件字段
| op | 参数 | 说明 |
|---|---|---|
| `set-component-field` | `node`, `componentType`, `property`, `value` | **普通节点**改任意组件任意字段。`property` 接字符串(顶层)或字符串数组(嵌套路径,如 `["_color","r"]`)。改 cc.Vec2/Vec3/Size 时 `value` 必须带 `__type__` |
| `set-component-enabled` | `node`, `componentType`, `enabled`, `subNode?` | 改组件 `_enabled`stub 走 propertyOverrides |
| `set-nested-component-field` | `node`stub, `componentType`, `property`, `value`, `subNode?` | **stub 节点**改内部任意组件的任意字段。`property` 支持字符串或嵌套路径数组。SpriteFrame 等资源 `value` 自备 `{__uuid__,__expectedType__}` |
| `reset-overrides` | `node`stub, `property?`, `componentType?`, `subNode?`, `all?` | 清 stub 已写入的 propertyOverrides。`all: true` 清空整个数组(一键回滚到嵌套默认);`property` 单条匹配——无 componentType = 节点字段(_lpos / _name 等),有 componentType = 嵌套内组件字段。幂等 |
| `ensure-meta` | `path` | 给 `.ts` / `.json` 文件创建 `.meta`(v4 uuid + 按扩展名选模板)。`path` 可绝对或相对项目根。已存在时幂等。**典型用法**:新建脚本 → `ensure-meta``add-component` 同 batch 内联动,免等 cocos 编辑器异步 import。dry-run 时不写盘。同 batch 内自动 invalidate classid-resolver cache,让后续 op 能查到新 className |
### 6.3 cc.UITransform 便捷
| op | 参数 | 说明 |
|---|---|---|
| `set-anchor` | `node`, `x?`, `y?`, `compensatePosition?` | `_anchorPoint` 便捷写法。`compensatePosition: true` 时按 anchor 差值 × size 自动补偿 `_lpos`,保持节点视觉位置不变。**stub 节点支持**:自动走 propertyOverrides 改嵌套 UITransformcompensate 时 `oldA/size` 从嵌套 prefab 默认值读 |
| `set-size` | `node`, `width?`, `height?` | `_contentSize` 便捷写法。**stub 节点支持**:自动走 propertyOverrides 改嵌套 UITransform |
### 6.4 cc.* 引擎组件多字段快捷 op
不含 stub 支持。改 stub 内同字段请用 `set-nested-component-field`
| op | 参数 | 关键 enum |
|---|---|---|
| `set-label` | `node`, `text?`, `fontSize?`, `lineHeight?`, `overflow?`, `horizontalAlign?`, `verticalAlign?`, `bold?`, `italic?`, `underline?`, `enableWrapText?` | `overflow`: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE |
| `set-label-text` | `node`, `text`, `labelNode?` | 改 `_string`stub 节点走 propertyOverrides`labelNode` 指定嵌套 prefab 内持有 Label 的子节点名 |
| `set-richtext` | `node`, `text?`, `maxWidth?`, `fontSize?`, `lineHeight?` | 支持 BBCode 标签 |
| `set-sprite` | `node`, `sizeMode?`, `type?`, `grayscale?`, `trim?` | `type`: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH。**换图用 `set-sprite-frame`** |
| `set-sprite-frame` | `node`, `uuid: string`, `spriteNode?` | 替换 SpriteFrame uuidstub 走 propertyOverrides |
| `set-button` | `node`, `interactable?`, `transition?`, `zoomScale?`, `duration?` | `transition`: 0=NONE 1=COLOR 2=SPRITE 3=SCALE |
| `set-editbox` | `node`, `inputMode?`, `maxLength?`, `placeholder?`, `string?`, `inputFlag?`, `fontSize?` | `inputMode`: 0=ANY 1=EMAIL 2=NUMERIC 3=PHONE 4=URL 5=DECIMAL 6=SINGLE_LINE |
| `set-layout` | `node`, `type?`, `resizeMode?`, `paddingLeft?`, `paddingRight?`, `paddingTop?`, `paddingBottom?`, `spacingX?`, `spacingY?`, `startAxis?`, `constraint?`, `constraintNum?`, `affectedByScale?` | `type`: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID |
### 6.5 节点结构
| op | 参数 | 说明 |
|---|---|---|
| `add-node` | `parent`, `node: {name, lpos?, active?, components?, width?, height?, anchor?}` | 新增 cc.Nodeparent 是 stub 时走 mountedChildren。`components: ["UITransform"]` 自动建 UITransform |
| `remove-node` | `target` | 从父节点移除引用;元素本身保留(orphan),保持其他 `__id__` 稳定 |
| `sync-nested-roots` | (无) | 重建根 `PrefabInfo.nestedPrefabInstanceRoots`,剔除「删了一半」残留的悬空嵌套实例根(节点 `_parent` 已移除但根登记残留 → 残留嵌套 prefab 的 asset 仍被当依赖加载,运行时 404)。只重写该数组,不删 elements、不动其他 `__id__`、不产生 null 槽;被孤立的残留对象成为不可达 orphan。复用 remove-node 内部同名逻辑 |
| `clone-node` | `source`, `parent`, `name` | 深拷贝整棵子树,分配新 `__id__` + 新 fileId |
#### add-node 的 node 字段
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `name` | string | 必填 | 节点名 |
| `lpos` | [x,y,z] | [0,0,0] | 本地位置 |
| `active` | bool | true | 是否激活 |
| `components` | string[] | [] | `["UITransform"]` 自动创建 cc.UITransform |
| `width` / `height` | number | 100 | UITransform 尺寸(仅 components 含时生效) |
| `anchor` | [x,y] | [0.5,0.5] | UITransform 锚点 |
### 6.6 组件 / 引用
| op | 参数 | 说明 |
|---|---|---|
| `add-component` | `node`, `componentType`, `props?` | 在 `_components` 新挂一个组件 + 配套 CompPrefabInfo。`componentType` 支持 @ccclass 名(`"GMUI"`)、压缩 classId`"a57b6RRA21B5I70mCpu1pBP"`)、引擎类(`"cc.Button"` |
| `remove-component` | `node`, `componentType` | 从普通节点 `_components` 移除指定组件引用。组件元素与其 CompPrefabInfo 作为 orphan 保留在数组里,保持其他 `__id__` 稳定(与 `remove-node` 同策略)。**不支持 stub**——嵌套 prefab 的组件归子 prefab 拥有,外层只能用 `set-component-enabled` 禁用 |
| `set-component-ref` | `node`, `componentType`, `property`, `refNode`, `refType?`, `refSubNode?` | 给脚本组件 `@property` 挂节点 / 组件引用。详见 §6.7 |
| `dedupe-component` | `node?` | 合并同节点上同语义但重复挂载的组件条目(cli 写 className + 编辑器 reimport 写压缩 classId 形成两份的场景)。按规范化 classId 分组,留字段非空数最多的为 keeper,losers 字段并入后删除并重映射 `__id__``node` 缺省扫整 prefab |
### 6.7 set-component-ref 完整字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `op` | string | ✓ | `"set-component-ref"` |
| `node` | name / `{id}` / `{path}` | ✓ | 挂脚本的节点(持有 @property 字段) |
| `componentType` | string | ✓ | 脚本组件类型,支持 @ccclass 名、压缩 classId、`cc.Button` 等 |
| `property` | string | ✓ | @property 字段名(如 `"_role"` |
| `refNode` | name / `{id}` / `{path}` | ✓ | 要绑定的目标节点。**stub 必须用 `{id:N}`**_name 为 null |
| `refType` | string | 可选 | 目标类型。省略 = 取 refNode 第一个非引擎组件;`"cc.Node"` = 绑节点本身(localID = 嵌套 prefab 根节点 PrefabInfo.fileId |
| `refSubNode` | string \| string[] | 可选 | stub 内部子节点定位。**字符串**指定单层 stub 的子节点名;**字符串数组**走多层嵌套(每层一段名字,最后一段配合 refType 决定终点) |
**常见拼错**`"comp"``componentType``"ref"``refNode`。schema 校验会友好提示。
`refNode` 是 stub 时自动走 `cc.TargetOverrideInfo` 协议(详见 [`nested-prefab-protocol.md`](./nested-prefab-protocol.md))。
### 6.8 按场景速查
| 场景 | op |
|---|---|
| 改节点 _active / _name / _lpos | `set-active` / `rename-node` / `set-position`(普通+stub 通用) |
| _lpos 相对偏移 | `adjust-position` |
| cc.UITransform 锚点 + 自动补偿 lpos | `set-anchor`(带 `compensatePosition: true` |
| cc.UITransform 尺寸 | `set-size` |
| 改普通节点任意组件字段(含嵌套路径) | `set-component-field` |
| 改 stub 内任意组件字段 | `set-nested-component-field` |
| 启用/禁用某组件 | `set-component-enabled` |
| 改子节点渲染顺序 | `reorder-children` |
| 一次改一批节点(按组件类型 / 名前缀 / 正则) | `bulk-set` |
| 改 cc.Label / Sprite / Button / EditBox / Layout / RichText 多字段 | `set-label` / `set-sprite` / `set-button` / `set-editbox` / `set-layout` / `set-richtext` |
| 改节点 _color | `set-node-color` |
| 给脚本 @property 挂引用(节点 / 组件 / 单层 stub / 多层 stub | `set-component-ref`(多层用 `refSubNode: ["A","B"]` |
| 加 / 删 / 复制节点 | `add-node` / `remove-node` / `clone-node` |
| 加 / 删组件 | `add-component` / `remove-component` |
| 合并重复组件 | `dedupe-component` |
| 跨多 prefab 跑同一组 ops | `batch --glob` |
| 比较两个 prefab | `diff` 子命令 |
| 操作 .anim 文件 | `anim query` / `anim batch` |
---
## 7. Op 配方(按场景示例)
### 7.1 绑定 @property 到普通节点上的组件
```json
{
"op": "set-component-ref",
"node": "SettingUI",
"componentType": "SettingUI",
"property": "_btnClose",
"refNode": "btnClose",
"refType": "cc.Button"
}
```
### 7.2 绑定 @property 到嵌套 prefabstub)内部的组件
```json
{
"op": "set-component-ref",
"node": "SettingUI",
"componentType": "SettingUI",
"property": "_someLabel",
"refNode": {"id": 33},
"refType": "cc.Label"
}
```
`refNode` 必须用 `{"id": N}`stub `_name` 为 null)。CLI 自动在 `cc.PrefabInfo.targetOverrides``cc.TargetOverrideInfo`
### 7.3 绑定 @property 到 stub 根节点本身(cc.Node 类型)
```json
{
"op": "set-component-ref",
"node": "SettingUI",
"componentType": "SettingUI",
"property": "_role",
"refNode": {"id": 33},
"refType": "cc.Node"
}
```
`refType: "cc.Node"` 表示绑定节点本身。CLI 找嵌套 prefab 根节点(`_parent === null`)的 `cc.PrefabInfo.fileId`,生成 `localID: [fileId]`
### 7.4 多层嵌套 stub @property 挂载
```json
{
"op": "set-component-ref",
"node": "Main",
"componentType": "MainUI",
"property": "_innerLabel",
"refNode": {"id": 12},
"refType": "cc.Label",
"refSubNode": ["B", "C"]
}
```
主 prefab stub → 第一层嵌套内的 stub `B` → 第二层嵌套内的 `C` 节点上的 `cc.Label`。每跨一层 PrefabInstance 边界 push 一个 fileId 到 localID 链。1 层时仍可写字符串 `"B"`,向后兼容。
### 7.5 改 stub 内部某组件的字段
```json
{
"op": "set-nested-component-field",
"node": {"id": 33},
"componentType": "cc.Label",
"property": "_string",
"value": "新文字"
}
```
### 7.6 改普通节点 cc.UITransform 锚点 + 保持视觉位置不变
```json
{
"op": "set-anchor",
"node": "itemList",
"y": 1,
"compensatePosition": true
}
```
内部按 `lpos.y += height * (newAnchorY - oldAnchorY)` 自动调整位置。
### 7.7 改普通节点的组件字段(嵌套路径)
```json
{
"op": "set-component-field",
"node": "label",
"componentType": "cc.Label",
"property": ["_color", "r"],
"value": 255
}
```
`property` 接字符串(顶层)或字符串数组(嵌套路径,逐级下钻)。中间路径不是对象会报错,不会自动建中间结构。
### 7.8 bulk-set:批量改一类节点
```json
{
"op": "bulk-set",
"selector": { "byComponent": "cc.Label" },
"target": "component:cc.Label",
"property": "_isItalic",
"value": true
}
```
`selector` 可组合(AND):
- `{ byComponent: "cc.X" }`:节点上挂指定组件
- `{ byNamePrefix: "btn" }``_name` 前缀匹配
- `{ byNameRegex: "^icon_\\d+$" }`:正则
`target``"node"`(改节点字段)或 `"component:<Type>"`(改节点上某组件字段)。匹配 0 个不算错。**bulk-set 不处理 stub 节点**(避免代码路径混用)。
### 7.9 reorder-children
```json
{"op": "reorder-children", "node": "list", "order": ["item3", "item1", "item2"]}
```
`order` 必须包含全部子节点(数量校验),元素是 `string`_name)或 `{id:N}`
### 7.10 path 选择器(同名节点多 / id 不稳)
```json
{"op": "set-active", "node": {"path": "Canvas/Main/itemList"}, "active": false}
```
从根节点逐级按 `_name` 匹配 `_children`,遇 stub 不下钻。根节点名匹配第一段时可省略。同名段会取首个匹配。
### 7.11 dry-run 预览
```bash
node bin/cocos-mcp-cli.js batch <prefab> ops.json --dry-run
```
输出:
```json
{
"changed": false,
"opsApplied": 2,
"nodesAffected": ["itemList"],
"dryRun": true,
"diff": [
{ "id": 65, "type": "cc.Node", "name": "itemList",
"changes": { "_lpos.y": [-28, 412] } },
{ "id": 66, "type": "cc.UITransform", "name": "",
"changes": { "_anchorPoint.y": [0.5, 1] } }
]
}
```
`diff` 是字段级,路径用点分(嵌套对象会展平)。
### 7.12 query 单字段值(脚本管道)
```bash
node bin/cocos-mcp-cli.js query <prefab> --selector field \
--name itemList --comp cc.UITransform --field _anchorPoint
# → {"__type__": "cc.Vec2", "x": 0.5, "y": 1}
```
### 7.13 query 树带组件字段
```bash
node bin/cocos-mcp-cli.js query <prefab> --selector tree --with-comps
```
每个节点附 `components: [{type, id, fields}]``fields` 过滤掉系统字段(`__type__` / `node` / `_enabled` / `__prefab` 等)后的业务字段。
---
## 8. 已知坑
### 坑 1stub 节点 `_name` 为 null
prefab JSON 里 stub 节点的 `_name``null`(不是字符串)。名字来自 `cc.PrefabInstance.propertyOverrides`,运行时才填。
**做法**:先 `query --selector tree``id`,然后用 `{"id": N}` 定位。
### 坑 2:入口文件错
- `bin/cocos-mcp-cli.js`
- `src/index.js` ✗(只是 re-export,无 CLI 入口)
- `src/cli/main.js` ✗(导出 `main()` 但不自调用)
### 坑 3localID 链多层嵌套
CLI 的 `resolveLocalIdChain``src/editor/nested.js`)支持 `refSubNode` 字符串数组路径走多层。但每层都假定能按节点名定位 stub;不支持靠组件类型在中间层定位。极端深嵌套场景仍建议走 tools pipeline`step-3-script/bind-prefab-components.ts`)。
### 坑 4root 节点检测依赖 `_parent === null`
`getNestedNodeFileId` 通过 `el._parent === null` 判断嵌套 prefab 的根节点。不是靠 `cc.PrefabInfo.asset.__uuid__`——嵌套 prefab JSON 里所有节点的 PrefabInfo.asset 都是 `{__id__: 0}`in-file 引用),`__uuid__` 字段不存在。
### 坑 5`sourceInfo` 为 null vs cc.TargetInfo
- `source`(挂 @property 的脚本组件)在主 prefab 根节点上 → `sourceInfo: null`
- 脚本组件本身在某个 stub 内(`mountedComponents`)→ `sourceInfo` 要填 `cc.TargetInfo`。CLI 当前不支持,会抛错
### 坑 6set-component-field vs set-nested-component-field 不通用
- **普通节点**改组件字段 → `set-component-field`(直改 elements 数组)
- **stub 节点**改组件字段 → `set-nested-component-field`(写 PrefabInstance.propertyOverrides + 嵌套 prefab fileId
用错时 CLI 抛明确错误:「节点 X 是 stub 代理,请用 set-nested-component-field」。判别 stub 看 `query --selector tree` 输出的 `isStub` 字段。
### 坑 7cc.Vec2/Vec3/Size 写入必须带 `__type__`
`set-component-field` 改这类字段时 `value` 必须形如 `{"__type__": "cc.Vec2", "x": 0.5, "y": 1}`,缺 `__type__` 会被 Cocos 反序列化为普通对象,运行时 `getAnchorPoint()` 等 API 拿不到正确值。
`set-anchor` / `set-size` op 内部已带 `__type__`,无需自己处理。
### 坑 8bulk-set 跳过 stub 节点
`bulk-set` 实现不处理 stub。要批量改 stub 内字段,先 `query --selector find` 拿 stub id 列表,再逐个 `set-nested-component-field`
### 坑 9reorder-children.order 必须含全部子节点
不允许只列要前置的几个、剩下的自动补尾。order 长度 ≠ _children 长度直接抛错。避免「你以为剩下的会按原序,但 CLI 默认丢掉了」这种隐式行为。
### 坑 10:path 选择器同名段必须消歧
`{path: "A/B/C"}` 走每段时如果 `A._children` 下有 ≥2 个同名 B,CLI 直接抛错并列出候选 `__id__`**不静默取首个**。同名场景请用 `{id:N}`,或组合"父用 path、当前层用 id"。
### 坑 11schema 类型校验(已加)
schema 校验既检查"字段拼写 + 必填存在"也检查字段类型。`width: "100"` 这种类型错跑前直接报,不会进 handler 才崩。复杂值(`value` / `props` / `selector` / `refSubNode`)走 `any`,仍由 handler 报场景错。
### 坑 12className → 压缩 classId 自动规范化
Cocos 编辑器反序列化时 `__type__` 可填 @ccclass 名(`"GMUI"`)或压缩 classId`"a57b6RRA21B5I70mCpu1pBP"`),但**保存 prefab 时会 round-trip 为后者**。如果 TS 脚本注册前触发 reimport@property refs 会被丢,导致出现「字符串版 + 压缩版」两份组件。
CLI 两端防护:
1. **写入前**`add-component` / `set-component-ref``componentType` / `refType` 自动扫 `assets/scripts` 反查 `.ts.meta` uuid,转 23 字符压缩 classId 写入。引擎类(`cc.*`/`sp.*`/`dragonBones.*`)和已压缩格式原样透传
2. **写入后兜底**:用 `dedupe-component` op 合并已经被 round-trip 过的 prefab
### 坑 13stub-node-field override 的 localID 用错 fileId
`set-position` / `rename-node` / `adjust-position` / `set-active` 等 op 对 stub 节点(嵌套 prefab 代理)写入字段时,走 `setOverrideProperty`,产物形如:
```json
{ "__type__": "cc.TargetInfo", "localID": ["<某 fileId>"] }
{ "__type__": "CCPropertyOverrideInfo", "targetInfo": { ... }, "propertyPath": ["_lpos"], "value": ... }
```
**正确的 localID 是「嵌套 prefab 内部根节点 PrefabInfo.fileId」**,不是「外层主 prefab 里 stub 自己的 PrefabInfo.fileId」。Cocos 运行时 `generateTargetMap` 按嵌套 prefab 内 fileId 建 targetMap,外层 stub fileId 在 map 里查不到,override 静默失效。
早期 fgui→cc3 转出的 prefab 设计上让这两个 fileId 一致(PrefabBuilder 复用同一 fileId),所以本工具早期版本里用 stubFileId 巧合工作;手编 prefab 或重新设计的标杆 prefab(如 `common-new/button/btnClose.prefab`)两个 fileId 一般不同,必须读嵌套 prefab JSON 拿真实根 fileId。
CLI 行为(2026-05-20 修,`cli/src/overrides.js`):
1. **写入前**`setOverrideProperty` 通过 `prefabInfo.asset.__uuid__` + `resolveUuidToPath` 加载嵌套 prefab,找根节点(`_parent === null`)的 `PrefabInfo.fileId` 作为 localID。**解析失败抛错**(不再 fallback 到 stubFileId),调用方需保证嵌套 prefab 可用。
2. **历史脏数据自动矫正(一次性迁移)**:识别旧版 cli 写入的 `stubFileId` 形式条目,命中同 propertyPath 时把 localID 改写成真值。仓库里现存的 fgui→cc3 转出 prefab 跑一次新 cli 就会逐步收敛到真值,无需手工迁移。
3. **`listOverrides` / `reset-overrides`**:只识别真值 localID(已迁移完的状态)。如果手工保留旧 stubFileId 条目不跑 cli 矫正,list/reset 会忽略它们。
诊断方法:在主 prefab JSON 里 grep `CCPropertyOverrideInfo` 找到目标条目,看 `targetInfo.localID[0]` 是否等于「嵌套 prefab 内根节点的 PrefabInfo.fileId」(在嵌套 prefab JSON 里直接 grep 根节点的 `fileId` 字段,或用 `query --selector overrides --id <stubId>` 看 cli 报告)。
### 坑 14rootTargetOverrides 单字段 override 必须排在数组字段 override 之前
Cocos 加载 prefab 时遍历 `cc.PrefabInfo.targetOverrides` 数组应用 override。**实测 cocos 3.8.x 行为**:若数组前面有数组字段 override(`propertyPath = ["_items", N]`),数组后面的单字段 override(`propertyPath = ["_btnClose"]`)会被静默跳过,运行时 `ui._btnClose === null`
最小可复现:TurnUI 有 14 个 `_items[N]` override,新 `set-component-ref _btnClose` 追加到数组末尾 → cocos 加载后 `ui._btnClose` 为 null。把这条 `_btnClose` 用 Python 移到数组首位 → 立即正常。对照实验同样的文件 mtime + reimport 流程,仅位置差异就触发不同结果,确认是顺序问题不是 reimport 时机。
CLI 防护(2026-05-20 修,`cli/src/editor/nested.js` `addRootTargetOverride`):
1. **新条目按 `propertyPath.length` 分插**
- `length === 1`(单字段,如 `["_btnClose"]`):`splice` 到第一个数组字段 override 之前
- `length > 1`(数组字段,如 `["_items", 0]`):`push` 到末尾
2. 已有的单字段 override 之间相对顺序不变(每次插入都在最后一个单字段之后、第一个数组字段之前)
3. 历史脏数据需手工迁移(Python `pop(idx) + insert(0, ref)` 或者 `splice(arrayBoundary, 0, ref)`
诊断方法:用 `query --selector tree` 拿 prefab 整体结构,然后看 root `cc.PrefabInfo.targetOverrides` 数组里第一个 `propertyPath.length > 1` 条目的位置——之前的所有条目(包括目标单字段)能正常加载,之后的单字段都会被跳过。
---
## 9. 验证 targetOverrides 已写入
```bash
python3 -c "
import json
data = json.load(open('assets/packages/common/setting/ui/SettingUI.prefab'))
for el in data:
if isinstance(el, dict) and el.get('__type__') == 'cc.PrefabInfo' and el.get('rootUuid'):
print('targetOverrides count:', len(el.get('targetOverrides', [])))
break
"
```
或 jq
```bash
jq '[.[] | select(.__type__ == "cc.TargetOverrideInfo")] | length' \
assets/packages/common/setting/ui/SettingUI.prefab
```
---
## 10. 协议参考
- `cc.TargetOverrideInfo` 协议细节:[`nested-prefab-protocol.md`](./nested-prefab-protocol.md)
- prefab JSON 结构速查:[`prefab-schema.md`](./prefab-schema.md)
- offline vs 编辑器路径决策:[`prefab-direct-edit.md`](./prefab-direct-edit.md)
- `.anim` 文件结构:[`anim-schema.md`](./anim-schema.md)
多层嵌套 localID 链的 tools pipeline 实现:`tools/step-3-script/bind-prefab-components.ts``resolveLocalIdChain`
---
## 11. 源码导航
```
extensions/cc-3-8-x-mcp/cli/
├── bin/cocos-mcp-cli.js # CLI 入口(require src/cli/main.js
└── src/
├── index.js # 公开 API re-exportparsePrefab / writePrefab / editPrefab / queryPrefab / ...
├── parse.js / write.js # JSON 数组 + __id__ 引用格式的读写
├── id.js # deterministic fileId / classId 压缩
├── primitives.js # cc.Node / cc.PrefabInfo / cc.UITransform 等节点对象构造原语
├── overrides.js # cc.PrefabInstance.propertyOverrides 读写
├── classid-resolver.js # @ccclass 名 → 压缩 classId(扫 assets/scripts/*.ts.meta
├── uuid-resolver.js # uuid → 磁盘路径(扫 assets/**/*.prefab
├── anim-primitives.js # .anim 文件构造原语
├── editor/ # editPrefab 主入口 + op handler
│ ├── index.js # editPrefab + OP_HANDLERS 注册表
│ ├── helpers.js # resolveNodename/id/path/ findComponent / isStub / normalizeComponentType
│ ├── nested.js # stub / 嵌套 prefab fileId 协议(多层支持)
│ ├── id-utils.js # fileId 分配 / 子树断开 / __id__ 重映射
│ ├── diff.js # 字段级 diffdry-run + diff 子命令共用)
│ ├── op-schema.js # ops 跑前 schema 校验
│ └── ops/ # 26 个 op handler,一文件一个
├── query/ # 只读查询
│ ├── index.js / tree.js / node.js / find.js / field.js
│ └── comp-fields.js
└── cli/ # 命令行子命令分发
├── main.js / flags.js / help.js
├── query-cmd.js / set-cmd.js
├── batch-cmd.js # 含 --glob 实现
├── anim-cmd.js
└── diff-cmd.js
```
### 加新 op 流程
1. `src/editor/ops/<new-op>.js` 写 handler,导出 `execXxx`
2. `src/editor/index.js``OP_HANDLERS` 加一行 + import
3. `src/editor/op-schema.js``SCHEMAS` 登记必填 / 可选字段
4. 本文档 §6(op 全表)+ §6.8(按场景速查)补一行
5. `test/api.test.js` 加测试用例
### 测试
```bash
# 6 个测试文件 116 个用例
for f in extensions/cc-3-8-x-mcp/cli/test/*.test.js; do
node --test "$f" 2>&1 | grep -E "^# (pass|fail)"
done
```
+216
View File
@@ -0,0 +1,216 @@
# CC3 Nested Prefab 组件引用协议
> 针对"主 prefab 里的脚本组件 @property 字段要引用嵌套 prefabstub 代理)内部节点/组件"这一场景,cc3 有专门的 `cc.TargetOverrideInfo` 协议。本文档给出离线工具直接写 prefab JSON 时必须遵循的格式,附 Cocos 引擎运行时的展开走法与踩过的坑。
>
> 样本来源:`packages/module/game/merge/base/prefab/BottomView.prefab`27 条 targetOverrides)、tools/step-3-script/bind-prefab-components.ts 的 `resolveLocalIdChain` + `addTargetOverride`。
---
## 1. 问题场景
fgui 转 cc3 后,父 prefab`BottomView.prefab`)里的 `btnStore` / `content` 节点是**嵌套 prefab 代理**stub),真正的 `cc.Button` / `cc.Label` 等组件在孙 prefab 里:
```
BottomView.prefab
├── touchArea (普通节点)
├── btnStore [stub, _prefab.instance ≠ null] → StoreBtn.prefab (内部有 cc.Button)
├── btnHome [stub] → HomeBtn.prefab
└── content [stub] → BottomViewContent.prefab
├── txtName (cc.Label)
├── btnSell (cc.Button)
└── btnSpeed [stub] → GemBtn.prefab (内部 cc.Button,两层 nested)
```
BottomView 根节点挂 BottomView.ts 脚本,声明:
```typescript
@property({ type: cc.Button }) private _btnStore: cc.Button; // 1 层
@property({ type: cc.Label }) private _txtName: cc.Label; // 1 层(在 content stub 内部)
@property({ type: cc.Button }) private _btnSpeed: cc.Button; // 2 层(content > btnSpeed
```
cc3 **不允许**在主 prefab JSON 里直接写 `comp._btnStore = { __id__: stubNodeId }`——运行时 stub 展开后,主 prefab 里的 stub 子节点全部被替换为子 prefab 内容,按 `__id__` 引用作废。必须通过 **`cc.TargetOverrideInfo`**,用 fileId 链定位目标。
---
## 2. 协议对象
三个关键 `__type__`
| 类型 | 位置 | 字段 |
|---|---|---|
| `cc.TargetOverrideInfo` | 宿主 prefab root `cc.PrefabInfo.targetOverrides[]` | `source``sourceInfo``propertyPath``target``targetInfo` |
| `cc.TargetInfo` | 被 `targetInfo` 引用 | `localID: string[]` |
| `cc.CompPrefabInfo` | 子 prefab 内每个组件的 `__prefab` 指向 | `fileId: string` |
### 2.1 `cc.TargetOverrideInfo` 字段
- **`source`**: `{ __id__: 主 prefab 里源组件的数组下标 }`,如 `BottomView` 脚本组件本身
- **`sourceInfo`**: 源组件也在 stub 内部时填 `cc.TargetInfo`;源在主 prefab 根组件时填 `null`
- **`propertyPath`**: `[fieldName]`,如 `["_btnStore"]`
- **`target`**: `{ __id__: 主 prefab 里 stub 代理节点的下标 }`
- **`targetInfo`**: `{ __id__: 关联的 cc.TargetInfo 下标 }`
### 2.2 `cc.TargetInfo.localID` — 多层 fileId 链
`localID` 是**字符串数组**,每一段对应 Cocos 运行时展开 stub 后的一层查找 key:
| 层数 | 场景 | localID 元素 |
|---|---|---|
| 1 | stub 展开后根组件匹配 | `[组件 cc.CompPrefabInfo.fileId]`(子 prefab 里该组件的 fileId |
| 1 | stub 展开后根节点引用 | `[节点 cc.PrefabInfo.fileId]` |
| 2 | stub 里还有孙 stub | `[孙 stub instance.fileId, 孙 prefab 内组件的 CompPrefabInfo.fileId]` |
| N | N-1 层孙代理 | 每过一层 PrefabInstance 边界压一段 fileId |
**每过一层 PrefabInstance 边界取的是 `instance.fileId`**(从 `cc.PrefabInstance.fileId` 读),不是代理节点自身 `PrefabInfo.fileId`,也不是子 prefab root 的 `PrefabInfo.fileId`
---
## 3. 引擎运行时走法(Cocos 3.8.x
### 3.1 `generateTargetMap`(为代理节点生成 targetMap
```js
function generateTargetMap(node, targetMap, isRoot) {
let curTargetMap = targetMap;
const prefabInstance = node.prefab?.instance;
if (!isRoot && prefabInstance) {
// 过 PrefabInstance 边界 → 新开子 mapkey = instance.fileId
targetMap[prefabInstance.fileId] = {};
curTargetMap = targetMap[prefabInstance.fileId];
}
const prefabInfo = node.prefab;
if (prefabInfo) curTargetMap[prefabInfo.fileId] = node;
for (const comp of node.components) {
if (comp.__prefab) curTargetMap[comp.__prefab.fileId] = comp;
}
for (const child of node.children) generateTargetMap(child, curTargetMap, false);
}
```
### 3.2 `getTarget`(按 localID 逐层查)
```js
function getTarget(localID, targetMap) {
let targetIter = targetMap;
for (let i = 0; i < localID.length; i++) {
targetIter = targetIter[localID[i]];
}
return targetIter;
}
```
### 3.3 `applyTargetOverrides`
```js
const targetAsNode = targetOverride.target; // 主 prefab 里 stub 节点
const targetInstance = targetAsNode.prefab.instance; // 该 stub 的 PrefabInstance
const target = getTarget(targetOverride.targetInfo.localID, targetInstance.targetMap);
// targetPropOwner 就是 source(或按 propertyPath 走到最末段的 owner
targetPropOwner[propertyName] = target; // 把真正的目标对象挂上去
```
引擎源文件(仅参考):`/Applications/Cocos/Creator/3.8.5/CocosCreator.app/.../3d/engine/bin/.editor/bundled/index.js``prefabUtils``generateTargetMap` / `applyTargetOverrides` / `expandPrefabInstanceNode`)。
---
## 4. 产出正确格式的最小例子
主 prefab`BottomView.prefab`)根节点下挂 `BottomView` 脚本,字段 `_btnStore` 要指向 `btnStore` stub 里的 `cc.Button`
离线写入(简化伪代码,见 `cli/src/api.js``_execSetComponentRef` 实际实现):
```js
// 1. 找子 prefab (StoreBtn.prefab) 里 cc.Button 的 __prefab.fileId
const btnFileId = findCompFileId(subPrefabJson, 'cc.Button'); // e.g. 'jJyfx2p9Mlc7QuNfNGOGMg'
// 2. push TargetInfo
const tiIdx = elements.length;
elements.push({ __type__: 'cc.TargetInfo', localID: [btnFileId] });
// 3. push TargetOverrideInfo
const toIdx = elements.length;
elements.push({
__type__: 'cc.TargetOverrideInfo',
source: { __id__: sourceCompIdx }, // BottomView 脚本组件
sourceInfo: null, // 源在主 prefab 根,不需要 sourceInfo
propertyPath: ['_btnStore'],
target: { __id__: stubNodeIdx }, // 主 prefab 里 btnStore 节点
targetInfo: { __id__: tiIdx },
});
// 4. 挂到主 prefab root cc.PrefabInfo.targetOverrides
rootPrefabInfo.targetOverrides = rootPrefabInfo.targetOverrides || [];
rootPrefabInfo.targetOverrides.push({ __id__: toIdx });
```
### 两层 nested 的 localID 示例
BottomView `_btnSpeed``content` stub → `btnSpeed`(在 content 子 prefab 里又是 stub)→ GemBtn.prefab.cc.Button
```json
{
"__type__": "cc.TargetInfo",
"localID": [
"gFhDEJwJ9nzsN6Ckb087BQ", // content 子 prefab (BottomViewContent) 里 btnSpeed stub 的 PrefabInstance.fileId
"njVtqkHLesyiqmBwtic1eg" // GemBtn.prefab 里 cc.Button 的 CompPrefabInfo.fileId
]
}
```
`target` 仍指向**主 prefab 里最外层 stub**`content` 节点)。运行时从 content PrefabInstance 的 targetMap 开始,`targetMap[btnSpeed_instance.fileId][cc.Button_comp.fileId]``cc.Button`
---
## 5. 踩坑
### 5.1 stub 代理节点 `_name` 为 `undefined`,不要按 `_name` 遍历
fgui 转 cc3 的 nested stub 节点 JSON 里没有 `_name` 字段——名字靠 `cc.PrefabInstance.propertyOverrides` 运行时填(`CCPropertyOverrideInfo.propertyPath=['_name']`)。按 `_name` 遍历 `_children` 会对 stub 节点返回 `undefined`,走不下去。
**正确做法**:用 tools 侧 stage 2 产出的 `prefab-node-paths.json` cache`PrefabBuilder` 按 fgui 原始对象名建的 path → idx 映射)。cli 场景没有该 cache 时,按 "stub 节点 `_prefab.instance` 非空" 识别 stub,然后 `parsePrefab` 加载子 prefab 按需查子结构。
### 5.2 localID 段的取法不一致
- 过 PrefabInstance 边界 → 读 `cc.PrefabInstance.fileId`(不是代理节点 `PrefabInfo.fileId`
- 到组件 → 读 `cc.CompPrefabInfo.fileId`
- 到节点 → 读 `cc.PrefabInfo.fileId`(即嵌套 prefab 根节点 `_parent === null` 对应 PrefabInfo 的 fileId
混用会让 `getTarget` 返回 undefined。
**cc.Node 型绑定(localID 取法)**:当 @property 字段声明是 `Node` 而非某个组件时,`localID = [嵌套 prefab 根节点的 cc.PrefabInfo.fileId]`。cli 的 `set-component-ref``refType: "cc.Node"` 即自动走这条路径(`_getNestedNodeFileId`),加载子 prefab JSON,找 `_parent === null` 的节点,读其 PrefabInfo.fileId。实际案例:`SettingUI._role: Node``refNode: {"id": 33}, refType: "cc.Node"`2026-05-07 验证)。
### 5.3 `sourceInfo` 什么时候必填
`cc.TargetOverrideInfo.source` 如果是主 prefab 根节点上直接挂的组件 → `sourceInfo: null`
源组件也在某个 stub 内部(典型:脚本组件本身被 mountedComponents 方式挂到 stub 上)→ `sourceInfo` 要填 `cc.TargetInfo``localID` 用跟 `targetInfo` 同样规则逐层查到源组件。cli 当前 `_execSetComponentRef` **暂不支持**源在 stub 的场景(抛错提示)。
### 5.4 fgui→cc3 的 "Empty 占位 + 运行时绑定组件"
fgui 里 `@inject(SpineView, "path", { res: xxx })` 这种运行时加载组件的字段,fgui 源文件里用通用 `Empty.xml` 占位。直接转 cc3 nested stub 会让目标节点是 Empty.prefab 代理,里面**没有** sp.Skeleton 组件,bind 挂不上。
tools 侧的修法:`step-2-prefab/src/converter/PrefabBuilder.ts` 检测到 `nodeTypeMap` 要求挂内建组件 + 孙 prefab 是 `Empty` → 退化为 inline 普通节点,sp.Skeleton 直接挂节点。
### 5.5 localID 数组只有 1 段 vs 多段
单层 nested(stub 展开后的根组件匹配)→ 1 段 fileId。多层 nested → N 段。cli 当前 `_resolveLocalIdChain` 实现只支持 1 层;多层由 tools/step-3-script/bind-prefab-components 的 `resolveLocalIdChain` 递归展开(见该文件的 fileId 链构造)。cli 要支持多层场景需要扩展,暂未实现。
---
## 6. 相关工具
| 操作 | 入口 |
|---|---|
| 给 @property 字段挂跨 nested 引用 | `set-component-ref` oprefNode 是 stub 自动走 targetOverrides |
| 改 stub 内部组件字段(cc.Label._string 等) | `set-nested-component-field` op |
| 批量跨 nested 挂载(数百字段) | tools/step-3-script/bind-prefab-components.tsstage 3 pipeline |
| 手工修单个 prefab 快速验证 | `set-component-ref` + dry-run 查 targetOverrides 数组 |
---
## 7. 调试
- 查当前 prefab 的 targetOverrides`jq '.[1]._prefab | { id: .__id__ }' *.prefab` → 找 PrefabInfo,看 `targetOverrides` 数组长度
- 验 localID 是否正确:在子 prefab JSON 里 grep fileId,确认对应组件 `__type__` 匹配
- Cocos 编辑器看到 "Missing Component" 或字段空 → 多半是 localID 错,或 target 指向的 stub 节点 `_prefab.instance` 为 null(漏写 PrefabInstance
+114
View File
@@ -0,0 +1,114 @@
# Prefab 直改流程指南
> 目标读者:使用 Claude Code / MCP agent 操作 CC3.8.x prefab 的开发者。
> 本文聚焦**决策和流程**:什么时候用哪条路径、改完要做什么、有哪些已知坑。
> prefab 文件结构细节见 [prefab-schema.md](./prefab-schema.md),不在此重复。
---
## 为什么绕过 Cocos 编辑器直改 prefab 文件
Cocos Creator 编辑器将 prefab 序列化为 JSON 文件存储在磁盘上。传统工作流需要打开 GUI → 在场景树里手动操作 → 保存,这在以下场景无法工作:
- **无头环境**CI / MCP agent / 自动化脚本,没有显示器或不方便启动编辑器。
- **批量修改**:几十个 prefab 同步改文字、布局参数,手动操作成本极高。
- **可重复性**:工具链幂等产物(A/B 变体、多语言注入),需要确定性结果而非 GUI 点击。
直改文件可以做到零 GUI 依赖、秒级完成、可 diff 审计。代价是需要精确理解 prefab 格式,错误写入可能让编辑器报解析错误或静默破坏数据。
---
## 整体架构
```
Claude Code / MCP agent
router.jscc-3-8-x-mcp
├── offline toolsvia cli 模块,纯文件 I/O
│ ├── prefab_query → cli/src/parse.js + query.js
│ ├── prefab_edit → cli/src/write.js + overrides.js
│ └── prefab_batch → cli/src/batch.js
└── editor toolsvia HTTP → Cocos 扩展进程)
├── asset_reimport / asset_refresh
├── scene_query / scene_set_property
├── preview_screenshot
└── editor_eval
```
**offline tools**:读写本地 `.prefab` 文件,不需要编辑器在线。适合批量、自动化场景。
**editor tools**:通过 HTTP 与运行中的 Cocos Creator 编辑器通信(扩展监听固定端口)。可以操作运行时场景、触发资源重新导入、截图等。需要编辑器已启动且扩展已加载。
---
## 决策表:用 offline 还是 editor
| 操作 | 推荐路径 | 原因 |
|---|---|---|
| 改普通节点 position / active | **offline**prefab_edit) | 直接改文件字段,秒级完成 |
| 改普通节点 Label 文字 | **offline**prefab_edit | 同上 |
| 改普通节点 SpriteFrame | **offline**prefab_batchop: set-sprite-frame | `set` 子命令不支持,必须走 batch |
| 改 stub 节点普通字段(position / active | **offline**prefab_edit | 工具自动路由到 propertyOverrides |
| 改 stub 节点组件字段(Label._string / Sprite._spriteFrame | **当前不支持** | 需跨 prefab 读取组件 fileId,未实现;必须在编辑器里手改 |
| 查节点树结构 | **offline**prefab_query)更快 | 纯文件解析,无需编辑器在线;但看不到运行时动态状态 |
| 查运行时节点状态(动画、数据绑定后) | **editor**scene_query | 运行时才有真实数据 |
| 触发资源重新导入 | **editor**asset_reimport | 必须让编辑器重建 UUID 索引 |
| 预览截图 | **editor**preview_screenshot | 只有编辑器能渲染 |
| 在预览中执行 JS | **editor**editor_eval | 需要运行时上下文 |
| 改运行时场景节点(非 prefab 文件) | **editor**scene_set_property | 场景节点不存在离线文件可直改 |
---
## offline 改完后的操作流程
直接改 `.prefab` 文件后,编辑器需要重新加载才能感知变化。有两种方式:
### 方式 A:仅重新导入资产
```
editor tool: asset_reimport
参数: { "path": "assets/packages/game/home/ui/HomeUI.prefab" }
```
让编辑器重建该 prefab 的内部索引(UUID 映射、子资产列表)。适合只改了 prefab 内容、不涉及其他资产变动的情况。
### 方式 B:整链路刷新(改动较大时)
```
editor tool: preview_refresh_and_reload
```
触发编辑器重新加载所有修改过的资产并刷新预览。改动多个文件时用此方式,避免缓存不一致。
**重要**offline 改完不做 reimport,编辑器下次保存 prefab 时可能用旧的序列化数据覆盖你的修改。
---
## 踩坑清单
### 坑 1:stub 节点直改字段永远无效
嵌套 prefab 的 stub 节点在父 prefab 中通常只有五个字段(`__type__``_objFlags``_parent``_prefab``__editorExtras__`),其余属性全在 `cc.PrefabInstance.propertyOverrides` 里。直接改 stub 节点的 `_lpos``_active` 字段,编辑器加载时会用 propertyOverrides 覆盖回去,修改静默丢失。
→ offline 工具已自动处理:检测到 stub 节点时自动路由到 propertyOverrides 写入。
→ 手写 ops.json 绕过高层 API 时,必须自己判断 `instance !== null` 再决定写哪里。
→ 详见 [prefab-schema.md § 4](./prefab-schema.md)。
### 坑 2nestedPrefabInstanceRoots 不同步导致静默数据损坏
根节点 `cc.PrefabInfo.nestedPrefabInstanceRoots` 是 CC3 编辑器识别所有嵌套 prefab stub 的总索引。新增嵌套实例时如果忘记把 stub 节点引用加进这个数组,编辑器不会报错,但在下次保存 prefab 时会把嵌套引用关系覆盖掉,造成子组件引用丢失。
→ offline 工具的 `editPrefab` 路径已自动维护该列表。
→ 手动拼 op 绕过高层 API 时必须自己维护。
→ 详见 [prefab-schema.md § 4 坑二](./prefab-schema.md) 和 cli/README.md 地雷二。
### 坑 3:stub 节点组件字段当前不支持离线写入
覆写 stub 节点的组件字段(如 `cc.Label._string``cc.Sprite._spriteFrame`)需要跨 prefab 文件读取该组件的 `cc.CompPrefabInfo.fileId`,当前 offline 工具未实现该能力。调用时会抛 `unsupported` 错误并不落盘。
**当前 workaround**:在 Cocos 编辑器里手动改这类属性,或通过 `editor tool: scene_set_property` 在运行时改(重启后失效)。后续版本将在 overrides.js 中实现跨 prefab fileId 查找。
→ 详见 cli/README.md 地雷一。
+358
View File
@@ -0,0 +1,358 @@
# CC 3.8.x Prefab 文件结构速查表
> 目标读者:写 cocos-mcp-cli 直接读写 .prefab 文件的 AI / 工具开发者。
> 不是 Cocos 官方文档的复读;只讲这个仓库里 prefab 实际长什么样、改它要注意什么。
> 样本来源:HomeUI.prefab / MergeUI.prefab / PassBar2.prefab / LoadingUI.prefab。
---
## 1. 文件总体结构:顶层数组 + `__id__` 交叉引用
### 例子
```json
[
{ "__type__": "cc.Prefab", "_name": "PassBar2", "data": { "__id__": 1 } },
{ "__type__": "cc.Node", "_name": "PassBar2", "_children": [{"__id__": 2}], "_components": [{"__id__": 16}], "_prefab": {"__id__": 22} },
{ "__type__": "cc.Node", "_name": "bar", "_parent": {"__id__": 1}, ... },
...
{ "__type__": "cc.UITransform", "node": {"__id__": 2}, ... },
{ "__type__": "cc.CompPrefabInfo", "fileId": "xGXA9SOZ9EEId1SlOOZ8ww" },
...
{ "__type__": "cc.PrefabInfo", "root": {"__id__": 1}, "asset": {"__id__": 0}, "fileId": "VvRjmVLAOqp/Xn7PjxOSlg", "instance": null, "nestedPrefabInstanceRoots": null }
]
```
### 说明
整个文件是一个 **JSON 数组**,每个元素称为一个"对象(object)"。对象之间的引用全部用 `{ "__id__": N }` 表达,N 是该数组下标(0-based)。没有任何嵌套层级——所有节点、组件、Prefab 元信息都平铺在同一层数组里。
- 下标 0:永远是 `cc.Prefab` 资产描述对象,`data` 字段指向根 Node 的 `__id__`
- 下标 1:通常是根 `cc.Node``_parent: null`)。
- 其余下标:子 Node、组件、PrefabInfo、CompPrefabInfo、CCPropertyOverrideInfo 等,顺序大致遵循"先父后子、先节点后组件"但并非强约束。
### 坑
**读一个 `__id__` 就等于对数组做随机访问,绝对不要假设某类型在固定下标。** 必须先从 `cc.Prefab.data.__id__` 找根节点,再沿 `_children``_components``_prefab` 递归遍历。
---
## 2. 常见 `__type__` 清单与关键字段
| `__type__` | 职责 | 关键字段 |
|---|---|---|
| `cc.Prefab` | 文件根描述 | `_name`, `data.__id__`(→ 根 Node, `optimizationPolicy`, `persistent` |
| `cc.Node` | 场景节点 | `_name`, `_parent`, `_children[]`, `_components[]`, `_prefab`, `_lpos`, `_lrot`, `_lscale`, `_euler`, `_layer`, `_active`, `_objFlags` |
| `cc.PrefabInfo` | 节点的 prefab 元信息 | `root`, `asset`, `fileId`, `instance`(→ PrefabInstance 或 null, `targetOverrides`, `nestedPrefabInstanceRoots` |
| `cc.CompPrefabInfo` | 组件的 prefab 元信息 | `fileId`(该组件在 prefab 内的唯一 ID) |
| `cc.PrefabInstance` | 嵌套 prefab 实例描述 | `fileId`, `prefabRootNode.__id__`(恒为 1,即被嵌套 prefab 的根), `mountedChildren[]`, `mountedComponents[]`, `propertyOverrides[]`, `removedComponents[]` |
| `cc.TargetInfo` | 覆写目标定位 | `localID: [fileId字符串]`(对应被嵌套节点/组件的 fileId) |
| `CCPropertyOverrideInfo` | 单条属性覆写 | `targetInfo.__id__`, `propertyPath: string[]`, `value` |
| `cc.UITransform` | 尺寸/锚点 | `node.__id__`, `_contentSize`, `_anchorPoint`, `__prefab.__id__`(→ CompPrefabInfo |
| `cc.Sprite` | 图片渲染 | `node.__id__`, `_spriteFrame.__uuid__`(格式 `uuid@f9941`, `_type`0=SIMPLE/3=FILLED, `_color`, `_isTrimmedMode` |
| `cc.Label` | 文字 | `node.__id__`, `_string`, `_fontSize`, `_horizontalAlign`, `_overflow`, `_font.__uuid__`, `_enableOutline`, `_outlineColor`, `_outlineWidth` |
| `cc.Widget` | 布局对齐 | `node.__id__`, `_alignFlags`(位掩码), `_left/_right/_top/_bottom`, `_isAbsLeft` 等, `_alignMode` |
| `cc.Button` | 按钮 | `node.__id__`, `_interactable`, `_transition`0=NONE/1=COLOR/2=SPRITE/3=SCALE, `_normalColor`, `_zoomScale`, `_target`, `clickEvents[]` |
| `cc.ProgressBar` | 进度条 | `node.__id__`, `_barSprite.__id__`(→ 子节点上的 cc.Sprite 组件), `_mode`0=H/1=V/2=FILLED, `_totalLength`, `_progress` |
| `cc.ScrollView` | 滚动视图 | `node.__id__`, `horizontal`, `vertical`, `elastic`, `inertia`, `_content`(→ 内容节点) |
| `cc.Layout` | 自动布局 | `node.__id__`, `_layoutType`0=NONE/1=H/2=V/3=GRID, `_resizeMode`, `_paddingLeft/_Right/_Top/_Bottom`, `_spacingX/_Y` |
| `cc.Mask` | 遮罩 | `node.__id__`, `_type`0=RECT/1=ELLIPSE/2=GRAPHICS_STENCIL |
| `cc.ProgressBar` | 进度条 | 同上 |
| `cc.RichText` | 富文本 | `node.__id__`, `_string`(支持 BBCode, `_fontSize`, `_maxWidth` |
自定义组件(如 `fca1cfQuOlKb5w9Ll8YoEt8`):`__type__` 是 UUID 压缩串而非 `cc.xxx`,见第 6 节。
---
## 3. Node 的核心字段详解
### 例子(PassBar2.prefab 根节点)
```json
{
"__type__": "cc.Node",
"_name": "PassBar2",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": null,
"_children": [{"__id__": 2}, {"__id__": 10}],
"_active": true,
"_components": [{"__id__": 16}, {"__id__": 18}, {"__id__": 20}],
"_prefab": {"__id__": 22},
"_lpos": {"__type__": "cc.Vec3", "x": 0, "y": 0, "z": 0},
"_lrot": {"__type__": "cc.Quat", "x": 0, "y": 0, "z": 0, "w": 1},
"_lscale": {"__type__": "cc.Vec3", "x": 1, "y": 1, "z": 1},
"_mobility": 0,
"_layer": 33554432,
"_euler": {"__type__": "cc.Vec3", "x": 0, "y": 0, "z": 0},
"_id": ""
}
```
### 说明
| 字段 | 含义 |
|---|---|
| `_parent` | 父节点引用(`{__id__: N}``null` |
| `_children` | 子节点引用数组(顺序即渲染顺序,后面的在上层) |
| `_components` | 该节点挂载的组件引用数组 |
| `_prefab` | 指向 `cc.PrefabInfo` 对象(每个节点都有) |
| `_lpos` | 本地位置,cc.Vec3x/y/z |
| `_lrot` | 本地旋转,四元数 cc.Quatx/y/z/w),w=1 表示无旋转 |
| `_lscale` | 本地缩放,cc.Vec3,默认 (1,1,1) |
| `_euler` | 对应 `_lrot` 的欧拉角(单位°),改旋转时两者必须同步 |
| `_layer` | 渲染层,UI 节点固定为 `33554432`= 1 << 25 |
| `_active` | 是否显示(false = 隐藏,不等于 opacity=0 |
| `_mobility` | 0=STATIC1=STATIONARY2=MOBILEUI 通常 0 |
| `_id` | 运行时 id(prefab 内节点均为 `""`,场景里才有值) |
### 坑
**`_lrot``_euler` 必须同时改**,否则编辑器打开后会用 `_lrot` 覆盖 `_euler` 或反之,产生意外旋转。即使只需要改 2D 旋转,也要同步写两处:
```json
"_lrot": {"__type__": "cc.Quat", "x": 0, "y": 0, "z": 0.7071, "w": 0.7071},
"_euler": {"__type__": "cc.Vec3", "x": 0, "y": 0, "z": 90}
```
---
## 4. PrefabInstance 和 propertyOverrides:嵌套 prefab 改属性必须走这里
这是整个 prefab 格式中最反直觉的部分。当一个 prefab 引用另一个 prefab(嵌套)时,**引用方的节点树里没有子组件的字段**,改哪里都没用——属性全在 `cc.PrefabInstance.propertyOverrides` 里。
### 例子(HomeUI.prefab 引用 taskEntry 子 prefab
```json
// 嵌套 prefab 的 stub 节点(几乎空的)
{
"__type__": "cc.Node",
"_objFlags": 0,
"_parent": {"__id__": 1},
"_prefab": {"__id__": 11},
"__editorExtras__": {}
}
// 该 stub 节点的 PrefabInfo(标识它是哪个 prefab 的实例)
{
"__type__": "cc.PrefabInfo",
"root": {"__id__": 10},
"asset": {"__uuid__": "36cca336-1f01-4c37-8ff4-9effb9279c44", "__expectedType__": "cc.Prefab"},
"fileId": "as0LdMaKxSWSLxrZB9u9KA",
"instance": {"__id__": 12},
"targetOverrides": null
}
// PrefabInstancepropertyOverrides 在这里)
{
"__type__": "cc.PrefabInstance",
"fileId": "yNEx5g/jVxdN7FE/+ittNA",
"prefabRootNode": {"__id__": 1},
"mountedChildren": [],
"mountedComponents": [],
"propertyOverrides": [{"__id__": 14}, {"__id__": 15}, ...],
"removedComponents": []
}
// TargetInfo:定位要改哪个节点/组件(用 localID 匹配 fileId
{
"__type__": "cc.TargetInfo",
"localID": ["as0LdMaKxSWSLxrZB9u9KA"]
}
// CCPropertyOverrideInfo:实际的属性值
{
"__type__": "CCPropertyOverrideInfo",
"targetInfo": {"__id__": 13},
"propertyPath": ["_lpos"],
"value": {"__type__": "cc.Vec3", "x": -272, "y": 53, "z": 0}
}
```
### 说明
引用链:stub Node → PrefabInfo(含 fileId + instance 引用)→ PrefabInstance → propertyOverrides[] → CCPropertyOverrideInfo(含 targetInfo)→ TargetInfolocalID = 被嵌套 prefab 内目标节点的 PrefabInfo.fileId)。
**localID 的值**来自被嵌套 prefab 文件内对应节点的 `cc.PrefabInfo.fileId`,两边必须完全一致。根节点的 localID 即 stub 节点自身 `cc.PrefabInfo.fileId`
覆写组件属性时,TargetInfo.localID 填的是**该组件的 cc.CompPrefabInfo.fileId**(不是节点的 PrefabInfo.fileId)。
### 坑
**直接修改 stub 节点的字段(如 `_lpos`)对嵌套 prefab 完全无效**。编辑器加载时会用 propertyOverrides 覆盖回去。必须找到对应的 CCPropertyOverrideInfo 修改 value,或新增一条覆写记录。
---
## 5. MountedChildren / MountedComponents:动态挂载节点
`cc.PrefabInstance``mountedChildren``mountedComponents` 用于在嵌套 prefab 实例上**额外追加**节点或组件(原 prefab 定义里没有的)。
本仓库实际样本中这两个字段均为 `[]`(空数组)。如需 AI 工具新增动态挂载,需要:
1. 把新节点对象 push 进 prefab 数组,分配新的下标。
2.`mountedChildren` 里加入 `{__id__: 新节点下标}`
3. 新节点的 `_parent` 必须指向该 stub 节点的下标。
4. 新节点也需要一个 `cc.PrefabInfo``instance: null``asset` 指向当前 prefab `{__id__: 0}`)。
该特性复杂且编辑器兼容性未经充分验证,**非必要不要动**。
---
## 5.5 TargetOverrides:主 prefab 脚本 @property 跨嵌套 prefab 挂载
`cc.PrefabInfo.targetOverrides`(注意是主 prefab **根**节点的 PrefabInfo,不是 stub 节点的)用于:主 prefab 里的某个脚本组件有 `@property` 字段要引用**嵌套 prefab 内部**的节点/组件。
最常见场景:fgui 转 cc3 后,父 prefab 里的按钮 / 容器节点都是 nested stub,真正的 `cc.Button` / `cc.Label` 在子 prefab 里,`BottomView.ts` 等脚本的 `@property` 要指向这些目标时,直接 `comp._btnStore = ref(stubId)` 会让 Cocos 加载时拿到 stub 代理而非真实组件。必须走 `cc.TargetOverrideInfo` + `cc.TargetInfo.localID`fileId 数组),引擎 `applyTargetOverrides` 从代理节点的 targetInstance.targetMap 逐层查目标对象再挂回源字段。
协议细节、localID 多层语义、踩坑与离线工具接入见:[`nested-prefab-protocol.md`](./nested-prefab-protocol.md)。
cli 入口:`set-component-ref` oprefNode 是 stub 自动走 targetOverrides);批量场景由 `tools/step-3-script/bind-prefab-components.ts` 覆盖。
---
## 6. fileId 和 UUID 的生成规律
### fileId(节点/组件在 prefab 内的唯一 ID
格式:**base64 编码的 16 字节随机数**,去掉末尾 `=` 号,22~24 个字符,例如 `xGXA9SOZ9EEId1SlOOZ8ww`
工具链的确定性生成方式(见 `tools/fgui2cc3/src/utils/DeterministicId.ts`):
```typescript
export function deterministicFileId(seed: string): string {
const hash = createHash('sha256').update(seed).digest();
return hash.subarray(0, 16).toString('base64').replace(/=+$/, '');
}
```
种子格式:`${baseSeed}#fid#${counter++}`,相同种子产生相同 fileId,保证转换产物幂等。
**手工写 prefab 时**,可以用任意不重复的 base64 字符串,只要 prefab 内不冲突。重复 fileId 会导致编辑器覆写时定位错误。
### UUID(资产引用)
引用外部资产(图片/字体/其他 prefab)时用标准 UUID v4,例如 `36cca336-1f01-4c37-8ff4-9effb9279c44`
SpriteFrame 的 uuid 格式是 `资产uuid@f9941``@f9941` 是固定后缀,指向 SpriteFrame 子资产)。
自定义组件脚本的 `__type__` 不是类名,而是**压缩 UUID**PrefabBuilder.compressUuid):取 UUID hex 的前 5 字符 + 后 27 字符 base64 编码的前 18 字符,共 23 字符,例如 `fca1cfQuOlKb5w9Ll8YoEt8`。反过来查类名需要在 `.meta` 文件里找。
---
## 7. .meta 文件结构
### 例子(HomeUI.prefab.meta
```json
{
"ver": "1.1.50",
"importer": "prefab",
"imported": true,
"uuid": "f3bd038c-fb1d-4abc-9a13-dcbbb422458c",
"files": [".json"],
"subMetas": {},
"userData": {
"syncNodeName": "HomeUI"
}
}
```
### 说明
| 字段 | 含义 |
|---|---|
| `uuid` | 该 prefab 的资产 UUID,其他 prefab 用 `__uuid__` 引用它时用此值 |
| `importer` | 固定 `"prefab"` |
| `ver` | 导入器版本,不要手改 |
| `subMetas` | prefab 没有子资产,永远是 `{}` |
| `userData.syncNodeName` | 编辑器同步节点名,改 prefab 根节点名后需同步更新 |
### 什么时候需要改 meta
通常**不需要动 meta**。以下是例外情况:
- 新建 prefab 文件(需要同时创建 .meta,uuid 必须唯一,否则编辑器报冲突)。
- 改了 prefab 根节点的 `_name``userData.syncNodeName` 跟着改,否则编辑器显示名不对,不影响功能)。
- **绝对不要改 uuid**——其他 prefab 通过 uuid 交叉引用,改了会让所有引用失效。
---
## 8. 写回时的陷阱
### 缩进
本仓库 prefab 统一使用 **2 空格缩进**。写回时:
```javascript
const newRaw = JSON.stringify(arr, null, 2) + '\n';
fs.writeFileSync(path, newRaw, 'utf8');
```
末尾必须有一个换行符(`\n`),否则 git diff 会多出"no newline at end of file"警告,且编辑器可能重新格式化。
### key 顺序
`JSON.stringify` 的 key 顺序是插入顺序。CC3 编辑器在保存时有自己的 key 顺序(如 `_name``_objFlags` 前,`__editorExtras__` 紧随其后),但**编辑器加载并不依赖 key 顺序**,所以顺序不同不影响运行,但会产生较大 diff 噪声。
如果用工具批量修改后想减少 diff,应在读取时保留原始 key 顺序(不要 `JSON.parse``JSON.stringify`,而是用精确字符串替换或保留对象引用顺序)。
### 数组空洞
prefab 数组的某些下标可能在构建过程中被"占位后回填"(工具链内部用 `push(null)` 占位,再 `set(idx, obj)` 回填)。写回前必须确认数组中**没有 `null` 元素**(除非是故意的空槽——本仓库中不存在这种情况)。`JSON.stringify` 会将 `null` 序列化为 `null`,编辑器加载时会报引用错误。
### 编码
prefab 文件是 UTF-8,无 BOM。行尾是 LF`\n`),不是 CRLF。在 Windows 环境写回时要特别注意(git 配置 `core.autocrlf` 可能干扰)。
---
## 9. 安全改 vs 危险改
### 安全改(可直接修改,无连锁影响)
| 改动 | 字段 | 注意 |
|---|---|---|
| 移动节点位置 | `cc.Node._lpos` | 非嵌套节点直接改;嵌套节点改对应 CCPropertyOverrideInfo.value |
| 改节点名称 | `cc.Node._name` | 非嵌套节点直接改;嵌套节点改对应 CCPropertyOverrideInfo |
| 显隐节点 | `cc.Node._active` | 同上 |
| 改文字内容 | `cc.Label._string``cc.RichText._string` | 直接改或通过 propertyOverride |
| 改颜色 | `cc.Sprite._color` / `cc.Label._color` | 直接改 |
| 改透明度 | `cc.UIOpacity._opacity` | 直接改(如有该组件) |
| 改尺寸 | `cc.UITransform._contentSize` | 注意 Widget 约束可能覆盖 |
| 改字号 | `cc.Label._fontSize` | 直接改 |
| 改进度 | `cc.ProgressBar._progress` | 直接改 |
| 改 SpriteFrame | `cc.Sprite._spriteFrame.__uuid__` | 必须用 `资产uuid@f9941` 格式 |
### 危险改(有连锁影响,必须同时处理多处)
| 改动 | 必须同步处理的地方 | 风险 |
|---|---|---|
| **新增普通节点** | 1. push Node 对象;2. 在父 Node._children 加引用;3. 新节点需要 cc.PrefabInfoinstance:nullasset:0fileId 唯一);4. 根节点 PrefabInfo.nestedPrefabInstanceRoots 不变(普通节点不加) | 漏掉任一步编辑器会报错 |
| **删除节点** | 1. 从父 _children 移除引用;2. 递归清理所有子节点及其组件;3. 检查是否有其他组件引用该节点(如 cc.Button._target| 悬空 __id__ 引用会导致加载崩溃 |
| **新增嵌套 prefab 实例** | 1. stub Node2. cc.PrefabInfo(含 instance 引用);3. cc.PrefabInstance4. cc.TargetInfo5. 若干 CCPropertyOverrideInfo6. 父节点 _children7. 根节点 PrefabInfo.nestedPrefabInstanceRoots 加入该 stub 节点引用 | 漏掉 nestedPrefabInstanceRoots 编辑器不识别为嵌套 prefab |
| **修改嵌套 prefab 属性** | 不改 stub 节点字段,改对应 CCPropertyOverrideInfo.value | 改 stub 节点本身的字段运行时完全无效 |
| **新增组件** | 1. push 组件对象;2. push 对应 cc.CompPrefabInfofileId 唯一);3. 组件对象含 `__prefab: {__id__: CompPrefabInfo下标}`4. 节点 _components 加引用 | 缺 CompPrefabInfo 编辑器加载报错 |
| **改根节点名** | 同步改 .meta 的 userData.syncNodeName | 功能正常但编辑器显示名混乱 |
| **数组覆写(如 editorPages** | 先写 `{propertyPath: ['editorPages', 'length'], value: N}` 再逐元素写 `{propertyPath: ['editorPages', '0'], value: {__id__: X}}` | 漏掉 length 覆写 CC3 不会截断原数组 |
---
## 10. 快速定位某节点的操作流程
1. `JSON.parse` 整个 prefab 数组为 `objects[]`
2.`objects[0]`cc.Prefab)的 `data.__id__` 取根节点下标(通常是 1)。
3. 递归 `_children` 找目标节点(按 `_name` 或路径匹配)。
4. 判断目标节点的 `_prefab.__id__` 对应的 PrefabInfo
- `instance === null` → 普通节点,直接改字段。
- `instance !== null` → 嵌套 prefab stub,改 `cc.PrefabInstance.propertyOverrides` 里对应的 CCPropertyOverrideInfo.value。
5. 序列化:`JSON.stringify(objects, null, 2) + '\n'`,写回文件。
---
## 附:本仓库 prefab 的两个最反直觉的坑
**坑一:嵌套 prefab 的 stub 节点没有 `_name`、`_lpos` 等字段,直接改无效。**
stub 节点通常只有 `__type__``_objFlags``_parent``_prefab``__editorExtras__` 五个字段。所有可见属性(名称、位置、缩放、可见性)全部在 propertyOverrides 里,改 stub 本身什么都不会发生。第一次踩这个坑的人通常会以为"写入成功了但编辑器没变"。
**坑二:`nestedPrefabInstanceRoots` 只在根节点的 PrefabInfo 上,且必须列出所有嵌套 stub 节点。**
这个字段是 CC3 编辑器识别"哪些节点是嵌套 prefab"的总索引。新增嵌套实例时如果忘记把 stub 节点加进这个数组,编辑器会把它当成普通节点处理,保存时会把原来的嵌套引用关系覆盖掉,造成静默数据损坏(没有报错,但再次打开 prefab 时子组件引用丢失)。
+871
View File
@@ -0,0 +1,871 @@
'use strict';
var fs = require('fs');
var path = require('path');
var { exec } = require('child_process');
var DEV_DIR = '.dev';
// 缓存预览地址
var _previewUrl = '';
// 定时刷新 dev-reload-info.json 的 interval handle
var _infoInterval = null;
// `.dev/refresh` 命令文件 watcher(唯一保留的 watcher
var _refreshWatcher = null;
// dev-reload-info.json 输出路径(被 listWorktrees / getStatus / panel 读取,必须保留)
var INFO_FILE = '.dev/dev-reload-info.json';
// 信号文件:外部脚本写命令到此文件,插件读后执行(每行一条命令,读完清空)
var REFRESH_FILE = '.dev/refresh';
// 自定义按钮配置(用户可在 .dev/cc-mcp-panel.json 或旧名 .dev/dev-reload-panel.json 里维护)
var PANEL_CONFIG_FILE = '.dev/dev-reload-panel.json';
// 最近命令日志环形缓冲(供面板展示)
var _commandLog = [];
var COMMAND_LOG_MAX = 30;
function pushCommandLog(source, cmd) {
_commandLog.push({ t: new Date().toISOString(), source: source, cmd: cmd });
if (_commandLog.length > COMMAND_LOG_MAX) _commandLog.shift();
}
/**
* 把当前预览状态写入 .dev/dev-reload-info.json。
* 外部脚本(playwright/designer)通过此文件反查"本 worktree 对应哪个预览端口"。
* 只在 previewUrl 已知时写入;previewUrl 为空则跳过,等待首次 getPreviewUrl 成功。
* @param {string} previewUrl 已知的预览 URL(非空)
*/
function writeDevReloadInfo(previewUrl) {
if (!previewUrl) return;
var portMatch = previewUrl.match(/:(\d+)/);
var previewPort = portMatch ? parseInt(portMatch[1], 10) : null;
// 读取项目名(package.json name 字段)
var projectName = '';
try {
var pkgPath = path.join(Editor.Project.path, 'package.json');
if (fs.existsSync(pkgPath)) {
var pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
projectName = pkg.name || '';
}
} catch (e) { /* ignore */ }
var info = {
projectPath: Editor.Project.path,
projectName: projectName,
editorPid: process.pid,
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
previewUrl: previewUrl,
previewPort: previewPort,
updatedAt: new Date().toISOString(),
};
try {
var devDir = path.join(Editor.Project.path, DEV_DIR);
if (!fs.existsSync(devDir)) {
fs.mkdirSync(devDir, { recursive: true });
}
fs.writeFileSync(
path.join(Editor.Project.path, INFO_FILE),
JSON.stringify(info, null, 2),
'utf-8'
);
log('dev-reload-info.json updated — port:' + (previewPort || 'null'));
} catch (e) {
console.warn('[dev-reload] writeDevReloadInfo failed:', e.message || e);
}
}
/** 启动 30s 定时刷新,保持 updatedAt 活跃供外部 stale 检测 */
function startInfoInterval() {
if (_infoInterval) clearInterval(_infoInterval);
_infoInterval = setInterval(function () {
if (_previewUrl) writeDevReloadInfo(_previewUrl);
// 顺便刷 registry,让 router 判活
if (typeof writeRegistry === 'function') writeRegistry();
}, 30000);
}
/** 停止定时刷新 */
function stopInfoInterval() {
if (_infoInterval) { clearInterval(_infoInterval); _infoInterval = null; }
}
function log(msg) {
console.log('[dev-reload] ' + msg);
}
function getFilePath(filename) {
return path.join(Editor.Project.path, filename);
}
/**
* 获取当前编辑器预览地址。
* 每次都重新向 preview 扩展查询,避免首次预览未启动时缓存旧值。
* 成功拿到 URL 后才更新 _previewUrl 缓存(供 writeDevReloadInfo 等用)。
*/
async function getPreviewUrl() {
try {
// CC3.8.x preview 扩展正式消息名:query-preview-url(见 builtin/preview/package.json contributions.messages
var url = await Editor.Message.request('preview', 'query-preview-url');
if (url && typeof url === 'string' && url.startsWith('http')) {
// host 规范化为 loopback:编辑器用 os.networkInterfaces() 挑的网卡 IP,在多网卡 /
// 切换网络(家↔公司)时会指向当前不通的网卡。本机访问统一走 127.0.0.1,预览 server
// 监听 0.0.0.0loopback 永远通且与网卡/环境无关。写信号文件、open、返回值都用它。
url = url.replace(/^(https?:\/\/)[^:\/]+/, '$1127.0.0.1');
if (url !== _previewUrl) {
log('preview url updated: ' + url);
}
_previewUrl = url;
return _previewUrl;
}
} catch (e) {
// Editor.Message.request 失败(消息不存在、preview 扩展未启动等)必须打印,不能静默
console.error('[dev-reload] getPreviewUrl: Editor.Message.request failed —', e && (e.stack || e.message) || e);
}
// 降级:如果已有缓存(上次成功查到的),直接复用
if (_previewUrl) return _previewUrl;
// 无缓存、无法从编辑器获取,返回未知标记而非错误的硬编码端口
log('preview url: unable to query from editor — preview may not be running');
return 'http://localhost:unknown-port';
}
/**
* 生成 AppleScript 匹配预览 tab 的条件:只按端口匹配、忽略 host。
* 同一预览实例在多网卡 / 切换网络时 host(IP) 会变,但端口稳定(per-project 编辑器分配)。
* 避免字面比完整 IP:port 在环境切换后失配(截图退化全屏、eval 报找不到 tab)。
*/
function tabMatchClause(url) {
var m = url && url.match(/:(\d+)(?:\/|$)/);
var port = m ? m[1] : '';
return port ? ('URL of t contains ":' + port + '/"') : ('URL of t starts with "' + url + '"');
}
function sleep(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
async function doReimport(url) {
log('reimporting: ' + url);
await Editor.Message.request('asset-db', 'reimport-asset', url);
log('reimported: ' + url);
}
async function doRefreshAssets() {
log('refreshing assets...');
await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets/');
log('assets refreshed.');
}
async function doReloadScene() {
log('reloading scene...');
await Editor.Message.request('scene', 'soft-reload');
log('scene reloaded.');
}
/**
* 在浏览器中打开预览
*/
async function doOpenPreview() {
var url = await getPreviewUrl();
// 先查有没有该端口的 tab,有就不重开——避免在已有(带 tid 的)预览 tab 之外再冒一个裸地址 tab
var checkScript = [
'tell application "Google Chrome"',
' repeat with w in windows',
' repeat with t in tabs of w',
' if ' + tabMatchClause(url) + ' then return "FOUND"',
' end repeat',
' end repeat',
' return "NONE"',
'end tell'
].join('\n');
return new Promise(function (resolve) {
exec('osascript -e \'' + checkScript.replace(/'/g, "'\\''") + '\'', function (err, stdout) {
if (!err && (stdout || '').trim() === 'FOUND') {
log('preview tab already open (port matched), skip open');
resolve();
} else {
log('opening preview: ' + url);
exec('open "' + url + '"', function () { resolve(); });
}
});
});
}
/**
* 刷新已打开的预览浏览器页面
*/
async function doRefreshPreview() {
var url = await getPreviewUrl();
log('refreshing preview browser...');
// 用 AppleScript 找到预览页面并刷新
var script = [
'tell application "Google Chrome"',
' set found to false',
' repeat with w in windows',
' repeat with t in tabs of w',
' if ' + tabMatchClause(url) + ' then',
' tell t to reload',
' set found to true',
' end if',
' end repeat',
' end repeat',
' if not found then',
' open location "' + url + '"',
' end if',
'end tell'
].join('\n');
return new Promise(function (resolve) {
exec('osascript -e \'' + script.replace(/'/g, "'\\''") + '\'', function (err) {
if (err) {
log('Chrome refresh failed, trying Safari...');
var safariScript = [
'tell application "Safari"',
' set found to false',
' repeat with w in windows',
' repeat with t in tabs of w',
' if ' + tabMatchClause(url) + ' then',
' tell t to do JavaScript "location.reload()"',
' set found to true',
' end if',
' end repeat',
' end repeat',
' if not found then',
' open location "' + url + '"',
' end if',
'end tell'
].join('\n');
exec('osascript -e \'' + safariScript.replace(/'/g, "'\\''") + '\'', function () {
resolve();
});
} else {
log('preview refreshed.');
resolve();
}
});
});
}
/**
* 截取预览浏览器页面的截图
*/
async function doScreenshot(outputPath) {
var url = await getPreviewUrl();
log('taking screenshot → ' + outputPath);
return new Promise(function (resolve) {
// 等待渲染
setTimeout(function () {
// 优先用 Chrome DevTools Protocol 截图(更精准)
var chromeScript = [
'tell application "Google Chrome"',
' repeat with w in windows',
' repeat with t in tabs of w',
' if ' + tabMatchClause(url) + ' then',
' set index of w to 1',
' set active tab index of w to (index of t)',
' delay 0.5',
' return id of w',
' end if',
' end repeat',
' end repeat',
' return ""',
'end tell'
].join('\n');
exec('osascript -e \'' + chromeScript.replace(/'/g, "'\\''") + '\'', function (err, stdout) {
var windowId = stdout ? stdout.trim() : '';
if (windowId) {
// 截取 Chrome 窗口
exec('screencapture -o -l ' + windowId + ' "' + outputPath + '"', function (err2) {
if (err2) {
log('window capture failed, fallback to full screen');
exec('screencapture -o "' + outputPath + '"', function () { resolve(); });
} else {
log('screenshot saved (browser window).');
resolve();
}
});
} else {
// 降级:截取整个屏幕
log('preview tab not found, capturing full screen');
exec('screencapture -o "' + outputPath + '"', function () { resolve(); });
}
});
}, 1000);
});
}
// ── 消息处理(支持从其他扩展或命令行调用) ──
exports.methods = {
async refreshAssets() {
await doRefreshAssets();
await doReloadScene();
},
async screenshot() {
var outputPath = path.join(Editor.Project.path, '.dev', 'screenshot.png');
await doScreenshot(outputPath);
return outputPath;
},
async queryPreviewUrl() {
var url = await getPreviewUrl();
// 顺便刷新 dev-reload-info.json,让外部脚本拿到最新端口
writeDevReloadInfo(url);
return url;
},
async openPanel() {
await Editor.Panel.open('cc-3-8-x-mcp');
},
async restartServer() {
await stopMcpServer();
await startMcpServer();
return { port: _mcpServer ? _mcpServer.port : null };
},
async getMcpConfig() {
if (!_mcpServer) return { running: false };
var url = 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp';
return {
running: _mcpServer.started,
url: url,
port: _mcpServer.port,
host: _mcpServer.host,
toolCount: _mcpServer.toolCount,
resourceCount: _mcpServer.resourceCount,
stats: _mcpServer.stats,
// Claude Code 的 mcp add 命令
cliAddCommand: 'claude mcp add cocos --transport http ' + url,
// JSON 配置片段
jsonConfig: {
mcpServers: {
cocos: { transport: 'http', url: url },
},
},
};
},
/** Panel 使用:刷新资源 + 重载场景 + 刷新预览 */
async triggerRefresh() {
await doRefreshAssets();
await doReloadScene();
await doRefreshPreview();
return true;
},
/** Panel 使用:重新导入指定 assetUrl */
async triggerReimport(url) {
if (!url) return false;
await doReimport(url);
return true;
},
/** Panel 使用:打开 .dev 目录 */
openDevDir() {
var devDir = path.join(Editor.Project.path, DEV_DIR);
if (!fs.existsSync(devDir)) fs.mkdirSync(devDir, { recursive: true });
exec('open "' + devDir + '"');
return devDir;
},
/** Panel 使用:只做场景软重载 */
async softReloadScene() {
pushCommandLog('panel', 'reload-scene');
await doReloadScene();
return true;
},
/** Panel 使用:在浏览器中打开预览 */
async openPreview() {
pushCommandLog('panel', 'open-preview');
await doOpenPreview();
return true;
},
/** Panel 使用:截图并把路径复制到剪贴板 */
async screenshotCopy() {
pushCommandLog('panel', 'screenshot');
var outputPath = path.join(Editor.Project.path, DEV_DIR, 'screenshot.png');
await doScreenshot(outputPath);
return new Promise(function (resolve) {
exec('printf %s "' + outputPath.replace(/"/g, '\\"') + '" | pbcopy', function () {
resolve(outputPath);
});
});
},
/** Panel 使用:清理 .dev 临时产物(保留 dev-reload-info.json / dev-reload-panel.json / cc-mcp-panel.json */
cleanDevDir() {
pushCommandLog('panel', 'clean-dev');
var devDir = path.join(Editor.Project.path, DEV_DIR);
var keep = { 'dev-reload-info.json': 1, 'dev-reload-panel.json': 1, 'cc-mcp-panel.json': 1 };
var removed = [];
try {
var entries = fs.readdirSync(devDir);
entries.forEach(function (name) {
if (keep[name]) return;
var full = path.join(devDir, name);
try {
var st = fs.statSync(full);
if (st.isFile()) { fs.unlinkSync(full); removed.push(name); }
} catch (e) { /* ignore */ }
});
} catch (e) { /* ignore */ }
return removed;
},
/** Panel 使用:向预览 Chrome 页面注入 JS 代码并返回执行结果 */
async evalInPreview(code) {
if (!code) return { ok: false, error: 'empty code' };
pushCommandLog('panel', 'eval:' + code.slice(0, 40));
var url = await getPreviewUrl();
// AppleScript 需要把 JS 代码里的双引号转义
// 包一层 window.app 校验:连到的 tab 不是游戏(编辑器内嵌预览 / 错 tab)就明确报错,不默默 eval 错上下文
var guarded = '(function(){ if(typeof window==="undefined"||!window.app){return "__NO_GAME_CONTEXT__";} return (' + code + '); })()';
var escaped = guarded.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
var script = [
'tell application "Google Chrome"',
' repeat with w in windows',
' repeat with t in tabs of w',
' if ' + tabMatchClause(url) + ' then',
' return (execute t javascript "' + escaped + '")',
' end if',
' end repeat',
' end repeat',
' return "__NO_PREVIEW_TAB__"',
'end tell'
].join('\n');
return new Promise(function (resolve) {
exec('osascript -e \'' + script.replace(/'/g, "'\\''") + '\'', function (err, stdout, stderr) {
if (err) {
resolve({ ok: false, error: (stderr || err.message || '').trim() });
} else {
var out = (stdout || '').trim();
if (out === '__NO_PREVIEW_TAB__') {
resolve({ ok: false, error: '未找到预览标签页(先在 Chrome 打开 ' + url + '' });
} else if (out === '__NO_GAME_CONTEXT__') {
resolve({ ok: false, error: '连到的 tab 没有游戏上下文(window.app undefined)——多半连的是编辑器内嵌预览或别的 tab。游戏要在浏览器跑且 app.ts 已加载;必要时用 playwright 直连游戏 tab' });
} else {
resolve({ ok: true, result: out });
}
}
});
});
},
/** Panel 使用:读取用户自定义的 debug 按钮配置 */
getDebugButtons() {
var cfgPath = path.join(Editor.Project.path, PANEL_CONFIG_FILE);
if (!fs.existsSync(cfgPath)) return [];
try {
var cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
if (Array.isArray(cfg.buttons)) return cfg.buttons;
return [];
} catch (e) {
return [];
}
},
/** Panel 使用:扫同机其他 worktree 的 dev-reload-info.json */
listWorktrees() {
var results = [];
// 扫当前项目同级目录里其它含 .dev/dev-reload-info.json 的项目实例(不假设目录命名)
var cur = Editor.Project.path;
var roots = [];
try {
var siblingDir = path.dirname(cur);
fs.readdirSync(siblingDir).forEach(function (name) {
var p = path.join(siblingDir, name);
if (p !== cur && fs.existsSync(path.join(p, '.dev', 'dev-reload-info.json'))) {
roots.push(p);
}
});
} catch (e) { /* ignore */ }
// 再扫 worktree`git worktree list` 输出的路径
try {
var wtOut = require('child_process').execSync('git -C "' + cur + '" worktree list --porcelain', { encoding: 'utf-8' });
wtOut.split('\n').forEach(function (line) {
if (line.indexOf('worktree ') === 0) {
var p = line.substring('worktree '.length).trim();
if (p && roots.indexOf(p) < 0) roots.push(p);
}
});
} catch (e) { /* ignore */ }
roots.forEach(function (root) {
var candidates = [
path.join(root, '.dev', 'dev-reload-info.json'),
];
candidates.forEach(function (infoPath) {
if (!fs.existsSync(infoPath)) return;
try {
var info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
var ageMs = Date.now() - new Date(info.updatedAt).getTime();
results.push({
projectPath: info.projectPath,
projectName: info.projectName,
previewPort: info.previewPort,
previewUrl: info.previewUrl,
editorPid: info.editorPid,
updatedAt: info.updatedAt,
staleSec: Math.floor(ageMs / 1000),
self: info.projectPath === Editor.Project.path,
});
} catch (e) { /* ignore */ }
});
});
return results;
},
/** Panel 使用:返回当前插件状态快照 */
async getStatus() {
var url = await getPreviewUrl();
writeDevReloadInfo(url);
var portMatch = url ? url.match(/:(\d+)/) : null;
var infoPath = path.join(Editor.Project.path, INFO_FILE);
var updatedAt = '';
try {
if (fs.existsSync(infoPath)) {
var info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
updatedAt = info.updatedAt || '';
}
} catch (e) { /* ignore */ }
// git 分支/commit
var gitBranch = '', gitHead = '';
try {
var execSync = require('child_process').execSync;
gitBranch = execSync('git -C "' + Editor.Project.path + '" rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
gitHead = execSync('git -C "' + Editor.Project.path + '" rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
} catch (e) { /* ignore */ }
return {
previewUrl: url,
previewPort: portMatch ? parseInt(portMatch[1], 10) : null,
editorPid: process.pid,
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
projectPath: Editor.Project.path,
updatedAt: updatedAt,
infoFile: INFO_FILE,
watchers: {
refresh: !!_refreshWatcher,
infoInterval: !!_infoInterval,
},
gitBranch: gitBranch,
gitHead: gitHead,
commandLog: _commandLog.slice().reverse(),
mcpServer: _mcpServer ? {
running: _mcpServer.started,
url: 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp',
port: _mcpServer.port,
toolCount: _mcpServer.toolCount,
resourceCount: _mcpServer.resourceCount,
stats: _mcpServer.stats,
} : { running: false },
};
}
};
// ── MCP Server ──
var _mcpServer = null;
var MCP_DEFAULT_PORT = 7523;
var REGISTRY_DIR = path.join(require('os').homedir(), '.cocos-mcp', 'editors');
var SDK_PATH = path.join(__dirname, '..', 'mcp-sdk', 'index.js');
/**
* 计算项目短名(MCP 工具名前缀,需能区分不同项目)。
* 禁 worktreecocos 不支持同一项目多 worktree 同开),故不再为 worktree 做 parent 启发式;
* base 是常见通用名(client/game/app/src)时取 parent 段区分不同项目,否则直接用 base。
*/
function getProjectShortName() {
var p = Editor.Project.path || '';
var parent = path.basename(path.dirname(p));
var base = path.basename(p);
if (base === 'client' || base === 'game' || base === 'app' || base === 'src') {
return parent || base;
}
return base;
}
/**
* 解析 Cocos 编辑器主进程可执行路径,写进注册文件供 router 的 editor_restart 拉起用。
* 优先 process.argv[0]Electron 主进程启动命令首段,即 .app 可执行),按 version 拼标准路径兜底。
* 排除 Helper(渲染/GPU 子进程路径),要外层主可执行。解析不到返回空串,router 端还有 ps / version 两级 fallback。
*/
function getEditorExecPath() {
var candidates = [];
try { if (process.argv && process.argv[0]) candidates.push(process.argv[0]); } catch (e) { /* ignore */ }
try { if (process.execPath) candidates.push(process.execPath); } catch (e) { /* ignore */ }
var ver = (Editor.App && Editor.App.version) ? Editor.App.version : '';
if (ver) candidates.push('/Applications/Cocos/Creator/' + ver + '/CocosCreator.app/Contents/MacOS/CocosCreator');
for (var i = 0; i < candidates.length; i++) {
var c = candidates[i];
if (c && /CocosCreator/.test(c) && c.indexOf('Helper') < 0) {
try { if (fs.existsSync(c)) return c; } catch (e) { /* ignore */ }
}
}
return '';
}
function writeRegistry() {
if (!_mcpServer || !_mcpServer.started) return;
try {
if (!fs.existsSync(REGISTRY_DIR)) fs.mkdirSync(REGISTRY_DIR, { recursive: true });
var entry = {
pid: process.pid,
projectPath: Editor.Project.path,
projectShortName: getProjectShortName(),
host: _mcpServer.host,
port: _mcpServer.port,
url: 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp',
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
execPath: getEditorExecPath(),
startedAt: _mcpServer.stats.startedAt,
updatedAt: new Date().toISOString(),
};
fs.writeFileSync(path.join(REGISTRY_DIR, process.pid + '.json'), JSON.stringify(entry, null, 2), 'utf-8');
} catch (e) {
console.warn('[cc-mcp] writeRegistry failed:', e.message || e);
}
}
function removeRegistry() {
try {
var f = path.join(REGISTRY_DIR, process.pid + '.json');
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch (e) { /* ignore */ }
}
/** 分配可用端口:先试 DEFAULT,如果被占用递增 */
function findFreePort(startPort) {
var net = require('net');
return new Promise(function (resolve) {
function tryPort(p) {
var tester = net.createServer()
.once('error', function () { tryPort(p + 1); })
.once('listening', function () {
tester.close(function () { resolve(p); });
})
.listen(p, '127.0.0.1');
}
tryPort(startPort);
});
}
async function startMcpServer() {
if (_mcpServer && _mcpServer.started) return;
var port = await findFreePort(MCP_DEFAULT_PORT);
var sdk;
try {
sdk = require(SDK_PATH);
} catch (e) {
// SDK not found, fall back to bundled mcp-server
var mcp = require('./server/mcp-server');
_mcpServer = mcp.createServer({ port: port, host: '127.0.0.1', logger: console });
var tdef = require('./server/tools');
var ctx = buildToolCtx();
tdef.defineTools(ctx).forEach(function (t) { _mcpServer.registerTool(t); });
tdef.defineResources(ctx).forEach(function (r) { _mcpServer.registerResource(r); });
await _mcpServer.start();
writeRegistry();
log('MCP server up (bundled) — http://127.0.0.1:' + port + '/mcp');
return;
}
// ── 使用 mcp-sdk ──────────────────────────────────────────────
var tdef = require('./server/tools');
var ctx = buildToolCtx();
var toolDefs = tdef.defineTools(ctx);
var resourceDefs = tdef.defineResources(ctx);
var server = sdk.createServer({
name: 'cc-3-8-x-mcp',
version: '2.0.0',
port: port,
tools: toolDefs.map(function (t) {
return {
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
handler: t.handler,
};
}),
resources: resourceDefs.map(function (r) {
return {
uri: r.uri,
name: r.name,
description: r.description,
mimeType: r.mimeType,
read: r.read,
};
}),
});
// 启动 HTTPcc-3-8-x-mcp 只跑 HTTP,不跑 stdio
await server.start('http');
_mcpServer = {
started: true,
host: '127.0.0.1',
port: port,
toolCount: toolDefs.length,
resourceCount: resourceDefs.length,
stats: { startedAt: new Date().toISOString(), requestCount: 0 },
stop: function () { server.stop(); },
};
writeRegistry();
log('MCP server up (SDK) — http://127.0.0.1:' + port + '/mcp (tools:' + toolDefs.length + ') shortName=' + getProjectShortName());
}
/**
* 构建 tool/resource 的 ctx(共享给 SDK 和 fallback
*/
function buildToolCtx() {
return {
msg: function (target, name /*, ...args */) {
var args = Array.prototype.slice.call(arguments, 2);
return Editor.Message.request.apply(Editor.Message, [target, name].concat(args));
},
local: {
getPreviewUrl: getPreviewUrl,
doReimport: doReimport,
doRefreshPreview: doRefreshPreview,
doOpenPreview: doOpenPreview,
doScreenshot: async function (outputPath) {
var p = outputPath || path.join(Editor.Project.path, DEV_DIR, 'screenshot.png');
await doScreenshot(p);
return p;
},
doRefreshAssets: doRefreshAssets,
doReloadScene: doReloadScene,
evalInPreview: function (code) { return exports.methods.evalInPreview(code); },
listWorktrees: function () { return exports.methods.listWorktrees(); },
openDevDir: function () { return exports.methods.openDevDir(); },
cleanDevDir: function () { return exports.methods.cleanDevDir(); },
getStatus: function () { return exports.methods.getStatus(); },
},
};
}
async function stopMcpServer() {
if (!_mcpServer) return;
removeRegistry();
if (_mcpServer.stop) {
_mcpServer.stop();
} else {
try { await _mcpServer.stop(); } catch (e) { /* ignore */ }
}
_mcpServer = null;
}
// ── .dev/refresh 文件命令协议 ──
/**
* 打开 prefab 到编辑器场景视图(等价于双击 prefab)。
* @param {string} urlOrPath db:// 路径 或 绝对路径
*/
async function doOpenPrefab(urlOrPath) {
var dbUrl = urlOrPath;
// 绝对路径 → 先通过 asset-db 反查 db:// url,再走统一路径
if (!urlOrPath.startsWith('db://')) {
var info = await Editor.Message.request('asset-db', 'query-asset-info', urlOrPath);
if (!info || !info.url) throw new Error('open-prefab: cannot find db:// url for ' + urlOrPath);
dbUrl = info.url;
}
var uuid = await Editor.Message.request('asset-db', 'query-uuid', dbUrl);
if (!uuid) throw new Error('open-prefab: cannot resolve uuid for ' + dbUrl);
await Editor.Message.request('asset-db', 'open-asset', uuid);
log('open-prefab: opened ' + dbUrl + ' (uuid=' + uuid + ')');
}
/** 重启整个插件(disable → enable)让 main.js / tools.js / server/* 的代码改动生效。
* 注意:本函数自身处于即将被卸载的 main.js 上下文,必须 fire-and-forget。
* Editor.Package.disable 是 host 进程 API,扩展沙箱卸载后仍然有效;
* enable 在 disable 完成后用 setTimeout 触发,给 unload 收尾留窗口。
*/
function doRestartSelf() {
var name = 'cc-3-8-x-mcp';
log('restart-package: scheduling disable → enable for ' + name);
setImmediate(function () {
Promise.resolve()
.then(function () { return Editor.Package.disable(name, true); })
// 200ms 给 unload 钩子(stopRefreshWatcher / stopMcpServer)跑完
.then(function () { return new Promise(function (r) { setTimeout(r, 200); }); })
.then(function () { return Editor.Package.enable(name, true); })
.catch(function (err) {
console.error('[restart-package] failed:', err && (err.stack || err.message) || err);
});
});
}
/**
* 分发单条 refresh 命令。
*
* 协议精简:只支持 `restart-package`disable→enable 整个扩展,让 JS 代码改动生效)。
* 资源刷新 / 场景重载 / 预览刷新 / 截图等走 MCP toolpreview_refresh_and_reload /
* asset_reimport / preview_screenshot 等)或面板按钮,不再通过文件协议触发。
*/
async function handleRefreshCommand(cmd) {
if (!cmd) return;
pushCommandLog('refresh', cmd);
if (cmd === 'restart-package') {
doRestartSelf();
return;
}
log('refresh: unknown command — ' + cmd + '(仅支持 restart-package');
}
/** 启动 .dev/refresh 文件 watcher(写入命令 → 读取 → 执行 → 清空) */
function startRefreshWatcher() {
if (_refreshWatcher) return;
var filePath = path.join(Editor.Project.path, REFRESH_FILE);
// 确保文件存在,供 fs.watch 注册
if (!fs.existsSync(filePath)) {
try { fs.writeFileSync(filePath, '', 'utf-8'); } catch (e) { /* ignore */ }
}
var _debounceTimer = null;
try {
_refreshWatcher = fs.watch(filePath, function (event) {
if (event !== 'change' && event !== 'rename') return;
// debouncemacOS 下单次 write 可能触发多次事件
clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(function () {
var content = '';
try { content = fs.readFileSync(filePath, 'utf-8').trim(); } catch (e) { return; }
if (!content) return;
// 清空信号文件,防止重复执行
try { fs.writeFileSync(filePath, '', 'utf-8'); } catch (e) { /* ignore */ }
var lines = content.split('\n');
// 逐条串行执行(前一条完成再执行下一条)
lines.reduce(function (chain, line) {
return chain.then(function () { return handleRefreshCommand(line.trim()); });
}, Promise.resolve());
}, 80);
});
log('refresh watcher started → ' + REFRESH_FILE);
} catch (e) {
console.error('[dev-reload] startRefreshWatcher failed:', e && (e.stack || e.message) || e);
}
}
/** 停止 .dev/refresh 文件 watcher */
function stopRefreshWatcher() {
if (_refreshWatcher) { try { _refreshWatcher.close(); } catch (e) { /* ignore */ } _refreshWatcher = null; }
}
// ── 插件生命周期 ──
exports.load = async function () {
// 确保 .dev 目录存在
var devDir = path.join(Editor.Project.path, DEV_DIR);
if (!fs.existsSync(devDir)) {
fs.mkdirSync(devDir, { recursive: true });
}
log('loaded');
// 启动 .dev/refresh 文件 watcher
startRefreshWatcher();
// 异步拿预览地址,写 dev-reload-info.json,启动定时刷新
getPreviewUrl().then(function(url) {
if (url) writeDevReloadInfo(url);
startInfoInterval();
}).catch(function(e) {
console.error('[dev-reload] load: getPreviewUrl failed —', e && (e.stack || e.message) || e);
startInfoInterval();
});
// 启动 MCP server(失败打完整栈,不阻断扩展 load)
startMcpServer().catch(function(e) {
console.error('[cc-mcp] MCP server failed to start:', e && (e.stack || e.message) || e);
});
};
exports.unload = async function () {
stopRefreshWatcher();
stopInfoInterval();
await stopMcpServer();
log('unloaded');
};
+57
View File
@@ -0,0 +1,57 @@
{
"package_version": 2,
"version": "2.0.0",
"name": "cc-3-8-x-mcp",
"title": "Cocos Creator 3.8.x MCP Server",
"description": "把 Cocos Creator 3.8.x 编辑器能力(scene/asset-db/preview/local)以 MCP 协议暴露给外部 AI 客户端;兼容原 dev-reload 信号文件通道",
"license": "Apache-2.0",
"author": "付饶",
"editor": ">=3.8.0",
"main": "./main.js",
"panels": {
"default": {
"title": "Cocos MCP",
"type": "dockable",
"main": "panel/index.js",
"size": {
"min-width": 340,
"min-height": 460,
"width": 400,
"height": 560
}
}
},
"contributions": {
"menu": [
{
"path": "i18n:menu.extension/Cocos MCP",
"label": "功能面板",
"message": "open-panel"
},
{
"path": "i18n:menu.extension/Cocos MCP",
"label": "重启 MCP Server",
"message": "restart-server"
}
],
"messages": {
"open-panel": { "methods": ["openPanel"] },
"restart-server": { "methods": ["restartServer"] },
"get-status": { "methods": ["getStatus"] },
"get-mcp-config": { "methods": ["getMcpConfig"] },
"refresh-assets": { "methods": ["refreshAssets"] },
"screenshot": { "methods": ["screenshot"] },
"query-preview-url": { "methods": ["queryPreviewUrl"] },
"trigger-refresh": { "methods": ["triggerRefresh"] },
"trigger-reimport": { "methods": ["triggerReimport"] },
"soft-reload-scene": { "methods": ["softReloadScene"] },
"open-preview": { "methods": ["openPreview"] },
"screenshot-copy": { "methods": ["screenshotCopy"] },
"clean-dev-dir": { "methods": ["cleanDevDir"] },
"eval-in-preview": { "methods": ["evalInPreview"] },
"get-debug-buttons": { "methods": ["getDebugButtons"] },
"list-worktrees": { "methods": ["listWorktrees"] },
"open-dev-dir": { "methods": ["openDevDir"] }
}
}
}
+379
View File
@@ -0,0 +1,379 @@
'use strict';
// Cocos MCP 功能面板
// MCP 状态 / 编辑器状态 / 快捷动作 / Debug 注入 / 命令日志 / 同机 worktree
exports.template = /* html */ `
<div class="wrap">
<section class="mcp">
<header>MCP Server <span id="mcpDot" class="dot gray"></span></header>
<div class="row"><label>状态</label><span id="mcpRunning">-</span></div>
<div class="row"><label>端点</label><span id="mcpUrl">-</span></div>
<div class="row"><label>Tools</label><span id="mcpTools">-</span></div>
<div class="row"><label>请求数</label><span id="mcpReqCount">0</span></div>
<div class="mcp-actions">
<ui-button id="btnCopyMcpUrl">复制端点</ui-button>
<ui-button id="btnCopyCli">复制 CLI 命令</ui-button>
<ui-button id="btnRestartMcp" class="secondary">重启</ui-button>
</div>
</section>
<section class="status">
<header>编辑器 <span id="probeDot" class="dot gray" title="预览连通性"></span></header>
<div class="row"><label>分支</label><span id="gitBranch">-</span></div>
<div class="row"><label>HEAD</label><span id="gitHead">-</span></div>
<div class="row"><label>预览地址</label><span id="previewUrl">-</span></div>
<div class="row"><label>预览端口</label><span id="previewPort">-</span></div>
<div class="row"><label>编辑器 PID</label><span id="editorPid">-</span></div>
<div class="row"><label>Watchers</label><span id="watchers">-</span></div>
<div class="row"><label>info 更新</label><span id="updatedAt">-</span></div>
</section>
<section class="actions">
<header>快捷动作</header>
<div class="btn-grid">
<ui-button id="btnRefresh">刷新(资源+场景+预览)</ui-button>
<ui-button id="btnSoftReload">仅软重载场景</ui-button>
<ui-button id="btnOpenPreview">打开预览浏览器</ui-button>
<ui-button id="btnQueryUrl">查询预览地址</ui-button>
<ui-button id="btnScreenshot">截图 → 复制路径</ui-button>
<ui-button id="btnOpenDev">打开 .dev 目录</ui-button>
<ui-button id="btnClean">清理 .dev 临时文件</ui-button>
<ui-button id="btnRefreshStatus" class="secondary">刷新状态</ui-button>
</div>
<div class="reimport-row">
<ui-input id="reimportInput" placeholder="db://assets/xxx 重新导入" class="grow"></ui-input>
<ui-button id="btnReimport">导入</ui-button>
</div>
</section>
<section class="debug">
<header>Debug 注入</header>
<div class="eval-row">
<ui-input id="evalInput" placeholder='console.log(app.userMod.getUserValue(0))' class="grow"></ui-input>
<ui-button id="btnEval">执行</ui-button>
</div>
<pre id="evalResult" class="eval-result"></pre>
<div id="debugButtons" class="debug-buttons"></div>
<div class="hint-small">自定义按钮配置:<code>.dev/cc-mcp-panel.json</code>(或旧名 <code>.dev/dev-reload-panel.json</code>)→ <code>{ "buttons": [{ "label": "...", "code": "..." }] }</code></div>
</section>
<section class="worktrees">
<header>同机 Worktree</header>
<div id="worktreeList" class="wt-list">-</div>
</section>
<section class="log">
<header>最近命令日志</header>
<div id="logList" class="log-list">-</div>
</section>
<div id="toast" class="toast"></div>
</div>
`;
exports.style = /* css */ `
:host { display: flex; flex: 1; }
.wrap { display: flex; flex-direction: column; padding: 12px; gap: 14px; font-size: 12px; flex: 1; overflow: auto; position: relative; }
section header { font-weight: bold; margin-bottom: 6px; opacity: 0.75; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; display: flex; align-items: center; gap: 6px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
.dot.gray { background: #888; }
.dot.green { background: #3ddc84; }
.dot.red { background: #e45; }
.status .row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px dashed rgba(255,255,255,0.08); }
.status .row label { opacity: 0.6; }
.status .row span { font-family: monospace; max-width: 65%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.btn-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.btn-grid ui-button { width: 100%; }
.btn-grid .secondary { opacity: 0.7; grid-column: span 2; }
.mcp-actions { display: flex; gap: 6px; margin-top: 8px; }
.mcp-actions ui-button { flex: 1; }
.mcp-actions .secondary { opacity: 0.75; }
.reimport-row, .eval-row { display: flex; gap: 6px; margin-top: 6px; }
.grow { flex: 1; }
.eval-result { max-height: 120px; overflow: auto; background: rgba(0,0,0,0.3); padding: 6px 8px; border-radius: 3px; font-family: monospace; font-size: 11px; white-space: pre-wrap; word-break: break-all; margin: 6px 0 0; min-height: 0; }
.eval-result:empty { display: none; }
.debug-buttons { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.debug-buttons ui-button { font-size: 11px; }
.hint-small { opacity: 0.55; font-size: 10px; margin-top: 4px; }
.hint-small code { background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; font-family: monospace; }
.wt-list, .log-list { font-family: monospace; font-size: 11px; max-height: 140px; overflow: auto; background: rgba(0,0,0,0.2); padding: 6px 8px; border-radius: 3px; line-height: 1.5; }
.wt-row { display: flex; justify-content: space-between; gap: 8px; padding: 2px 0; }
.wt-row.self { color: #3ddc84; }
.wt-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-row .port { opacity: 0.8; flex-shrink: 0; }
.log-row { display: flex; gap: 8px; padding: 1px 0; }
.log-row .time { opacity: 0.5; flex-shrink: 0; }
.log-row .cmd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toast { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); color: #fff; padding: 6px 12px; border-radius: 4px; font-size: 11px; opacity: 0; transition: opacity 0.2s; pointer-events: none; max-width: 80%; text-align: center; }
.toast.show { opacity: 1; }
`;
exports.$ = {
mcpDot: '#mcpDot',
mcpRunning: '#mcpRunning',
mcpUrl: '#mcpUrl',
mcpTools: '#mcpTools',
mcpReqCount: '#mcpReqCount',
btnCopyMcpUrl: '#btnCopyMcpUrl',
btnCopyCli: '#btnCopyCli',
btnRestartMcp: '#btnRestartMcp',
previewUrl: '#previewUrl',
previewPort: '#previewPort',
editorPid: '#editorPid',
watchers: '#watchers',
updatedAt: '#updatedAt',
gitBranch: '#gitBranch',
gitHead: '#gitHead',
probeDot: '#probeDot',
btnRefresh: '#btnRefresh',
btnSoftReload: '#btnSoftReload',
btnOpenPreview: '#btnOpenPreview',
btnQueryUrl: '#btnQueryUrl',
btnScreenshot: '#btnScreenshot',
btnOpenDev: '#btnOpenDev',
btnClean: '#btnClean',
btnRefreshStatus: '#btnRefreshStatus',
btnReimport: '#btnReimport',
reimportInput: '#reimportInput',
evalInput: '#evalInput',
btnEval: '#btnEval',
evalResult: '#evalResult',
debugButtons: '#debugButtons',
worktreeList: '#worktreeList',
logList: '#logList',
toast: '#toast',
};
let toastTimer = null;
function fmtTime(iso) {
if (!iso) return '-';
try { return iso.replace('T', ' ').replace(/\..+$/, '').split(' ')[1] || iso; } catch (e) { return iso; }
}
exports.methods = {
showToast(msg) {
this.$.toast.textContent = msg;
this.$.toast.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { this.$.toast.classList.remove('show'); }, 2200);
},
async refreshStatus() {
try {
const s = await Editor.Message.request('cc-3-8-x-mcp', 'get-status');
if (!s) return;
this.$.gitBranch.textContent = s.gitBranch || '-';
this.$.gitHead.textContent = s.gitHead || '-';
this.$.previewUrl.textContent = s.previewUrl || '-';
this.$.previewPort.textContent = s.previewPort != null ? String(s.previewPort) : '-';
this.$.editorPid.textContent = String(s.editorPid || '-');
const w = s.watchers || {};
this.$.watchers.textContent =
(w.refresh ? '●refresh ' : '○refresh ') +
(w.infoInterval ? '●info' : '○info');
this.$.updatedAt.textContent = s.updatedAt ? s.updatedAt.replace('T', ' ').replace(/\..+$/, '') : '-';
// MCP 区
const mcp = s.mcpServer || {};
if (mcp.running) {
this.$.mcpDot.className = 'dot green';
this.$.mcpRunning.textContent = 'running';
this.$.mcpUrl.textContent = mcp.url || '-';
this.$.mcpTools.textContent = (mcp.toolCount || 0) + ' tools / ' + (mcp.resourceCount || 0) + ' res';
this.$.mcpReqCount.textContent = String((mcp.stats && mcp.stats.requestCount) || 0);
} else {
this.$.mcpDot.className = 'dot red';
this.$.mcpRunning.textContent = 'stopped';
this.$.mcpUrl.textContent = '-';
}
// 命令日志
if (Array.isArray(s.commandLog)) {
this.$.logList.innerHTML = s.commandLog.length
? s.commandLog.map(e => `<div class="log-row"><span class="time">${fmtTime(e.t)}</span><span class="cmd">[${e.source}] ${escapeHtml(e.cmd)}</span></div>`).join('')
: '<div style="opacity:0.5">(暂无)</div>';
}
// 预览连通性探测
this.probePreview(s.previewUrl);
} catch (e) {
this.showToast('状态获取失败: ' + (e.message || e));
}
this.refreshWorktrees();
this.refreshDebugButtons();
},
async probePreview(url) {
if (!url) { this.$.probeDot.className = 'dot gray'; return; }
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 1500);
const resp = await fetch(url, { method: 'HEAD', signal: ctrl.signal });
clearTimeout(timer);
this.$.probeDot.className = resp.ok ? 'dot green' : 'dot red';
this.$.probeDot.title = '预览: HTTP ' + resp.status;
} catch (e) {
this.$.probeDot.className = 'dot red';
this.$.probeDot.title = '预览: ' + (e.message || 'unreachable');
}
},
async refreshWorktrees() {
try {
const list = await Editor.Message.request('cc-3-8-x-mcp', 'list-worktrees');
if (!Array.isArray(list) || !list.length) {
this.$.worktreeList.innerHTML = '<div style="opacity:0.5">(未发现其他 worktree</div>';
return;
}
this.$.worktreeList.innerHTML = list.map(w => {
const name = (w.projectName || w.projectPath || '').split('/').slice(-2).join('/');
const stale = w.staleSec > 90 ? `${w.staleSec}s` : '';
const selfCls = w.self ? 'wt-row self' : 'wt-row';
return `<div class="${selfCls}"><span class="name">${escapeHtml(name)}${w.self ? ' (本)' : ''}</span><span class="port">:${w.previewPort || '?'} pid${w.editorPid}${stale}</span></div>`;
}).join('');
} catch (e) { /* ignore */ }
},
async refreshDebugButtons() {
try {
const btns = await Editor.Message.request('cc-3-8-x-mcp', 'get-debug-buttons');
if (!Array.isArray(btns) || !btns.length) {
this.$.debugButtons.innerHTML = '';
return;
}
this.$.debugButtons.innerHTML = '';
btns.forEach(cfg => {
if (!cfg || !cfg.label || !cfg.code) return;
const btn = document.createElement('ui-button');
btn.textContent = cfg.label;
btn.addEventListener('confirm', () => this.runEval(cfg.code, cfg.label));
this.$.debugButtons.appendChild(btn);
});
} catch (e) { /* ignore */ }
},
async runEval(code, label) {
this.showToast('执行: ' + (label || code.slice(0, 30)));
try {
const r = await Editor.Message.request('cc-3-8-x-mcp', 'eval-in-preview', code);
if (r && r.ok) {
this.$.evalResult.textContent = '✓ ' + (r.result || '(no return)');
} else {
this.$.evalResult.textContent = '✗ ' + ((r && r.error) || 'unknown');
}
} catch (e) {
this.$.evalResult.textContent = '✗ ' + (e.message || e);
}
},
async onRefreshClick() {
this.showToast('刷新中…');
try {
await Editor.Message.request('cc-3-8-x-mcp', 'trigger-refresh');
this.showToast('已刷新资源+场景+预览');
this.refreshStatus();
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onSoftReloadClick() {
try { await Editor.Message.request('cc-3-8-x-mcp', 'soft-reload-scene'); this.showToast('场景已软重载'); }
catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onOpenPreviewClick() {
try { await Editor.Message.request('cc-3-8-x-mcp', 'open-preview'); this.showToast('已在浏览器打开预览'); }
catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onQueryUrlClick() {
try {
const url = await Editor.Message.request('cc-3-8-x-mcp', 'query-preview-url');
this.showToast('预览: ' + url);
this.refreshStatus();
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onScreenshotClick() {
this.showToast('截图中…');
try {
const p = await Editor.Message.request('cc-3-8-x-mcp', 'screenshot-copy');
this.showToast('截图路径已复制: ' + p);
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onOpenDevClick() {
try { await Editor.Message.request('cc-3-8-x-mcp', 'open-dev-dir'); this.showToast('已打开 .dev'); }
catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onCleanClick() {
try {
const removed = await Editor.Message.request('cc-3-8-x-mcp', 'clean-dev-dir');
this.showToast('已清理 ' + (removed ? removed.length : 0) + ' 个文件');
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onReimportClick() {
const url = (this.$.reimportInput.value || '').trim();
if (!url) { this.showToast('请输入 assetUrl'); return; }
try {
await Editor.Message.request('cc-3-8-x-mcp', 'trigger-reimport', url);
this.showToast('已重新导入: ' + url);
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onCopyMcpUrl() {
try {
const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-mcp-config');
if (!cfg || !cfg.url) { this.showToast('MCP 未运行'); return; }
await navigator.clipboard.writeText(cfg.url);
this.showToast('已复制: ' + cfg.url);
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onCopyCli() {
try {
const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-mcp-config');
if (!cfg || !cfg.cliAddCommand) { this.showToast('MCP 未运行'); return; }
await navigator.clipboard.writeText(cfg.cliAddCommand);
this.showToast('已复制 CLI 命令');
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onRestartMcp() {
this.showToast('重启 MCP…');
try {
await Editor.Message.request('cc-3-8-x-mcp', 'restart-server');
this.showToast('MCP 已重启');
this.refreshStatus();
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
},
async onEvalClick() {
const code = (this.$.evalInput.value || '').trim();
if (!code) { this.showToast('请输入 JS 代码'); return; }
await this.runEval(code);
},
};
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
exports.ready = function () {
this.$.btnRefresh.addEventListener('confirm', () => this.onRefreshClick());
this.$.btnSoftReload.addEventListener('confirm', () => this.onSoftReloadClick());
this.$.btnOpenPreview.addEventListener('confirm', () => this.onOpenPreviewClick());
this.$.btnQueryUrl.addEventListener('confirm', () => this.onQueryUrlClick());
this.$.btnScreenshot.addEventListener('confirm', () => this.onScreenshotClick());
this.$.btnOpenDev.addEventListener('confirm', () => this.onOpenDevClick());
this.$.btnClean.addEventListener('confirm', () => this.onCleanClick());
this.$.btnRefreshStatus.addEventListener('confirm', () => this.refreshStatus());
this.$.btnReimport.addEventListener('confirm', () => this.onReimportClick());
this.$.btnEval.addEventListener('confirm', () => this.onEvalClick());
this.$.btnCopyMcpUrl.addEventListener('confirm', () => this.onCopyMcpUrl());
this.$.btnCopyCli.addEventListener('confirm', () => this.onCopyCli());
this.$.btnRestartMcp.addEventListener('confirm', () => this.onRestartMcp());
this.refreshStatus();
// 每 10s 自动刷状态
this._statusTimer = setInterval(() => this.refreshStatus(), 10000);
};
exports.close = function () {
if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
if (this._statusTimer) { clearInterval(this._statusTimer); this._statusTimer = null; }
};
Executable
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env node
'use strict';
/**
* cocos-mcp-router
*
* stdio MCP server,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。
*
* 发现机制:
* 扫 ~/.cocos-mcp/editors/*.json,每个文件代表一个活跃扩展实例
* 过滤 mtime > 120s 的(视为已死)
* 对每个活跃编辑器调 HTTP POST /mcp initialize + tools/list,拿到其 tool 清单
*
* 命名:
* tool 名前缀化:<projectShortName>__<originalName>
* 例:my-project__scene_query_node_tree
*
* 转发:
* tools/call 收到前缀名 → 拆出 projectShortName → 查 editor URL → HTTP 转发
*
* 客户端接入:
* claude mcp add cocos -- node /path/to/forest/extensions/cc-3-8-x-mcp/router/bin.js
*/
var fs = require('fs');
var path = require('path');
var os = require('os');
var http = require('http');
var offlineTools = require('./src/offline-tools.js');
var editorControl = require('./src/editor-control.js');
var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
var STALE_MS = 120 * 1000; // 2 分钟没心跳视为死
var DISCOVERY_INTERVAL_MS = 15 * 1000;
var PROTOCOL_VERSION = '2024-11-05';
var ROUTER_INFO = { name: 'cocos-mcp-router', version: '0.1.0' };
function logErr() {
// router 走 stdio,不能往 stdout 写非 JSON-RPC 内容,日志只能走 stderr
var args = Array.prototype.slice.call(arguments);
process.stderr.write('[cocos-mcp-router] ' + args.join(' ') + '\n');
}
// ── 发现活跃编辑器 ──
/** @type {Map<string, {shortName, url, pid, tools: Array, lastProbed: number}>} */
var editors = new Map();
function scanRegistry() {
var entries = [];
try {
if (!fs.existsSync(REGISTRY_DIR)) return entries;
var files = fs.readdirSync(REGISTRY_DIR);
var now = Date.now();
files.forEach(function (name) {
if (!name.endsWith('.json')) return;
var full = path.join(REGISTRY_DIR, name);
try {
var st = fs.statSync(full);
if (now - st.mtimeMs > STALE_MS) {
// stale:编辑器已退出/崩溃超 STALE_MS 未更新心跳。直接删文件而非仅跳过——
// 崩溃/强杀不会调 removeRegistry,旧实现只 return 不删 → 死文件无限堆积(曾攒 1100+)。
try { fs.unlinkSync(full); } catch (e) { /* ignore */ }
return;
}
var info = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (!info || !info.url) return;
entries.push(info);
} catch (e) { /* ignore */ }
});
} catch (e) { logErr('scanRegistry:', e.message); }
return entries;
}
function httpJsonRpc(targetUrl, body) {
return new Promise(function (resolve, reject) {
try {
var u = new URL(targetUrl);
var data = JSON.stringify(body);
var req = http.request({
hostname: u.hostname,
port: u.port,
path: u.pathname,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
timeout: 8000,
}, function (res) {
var chunks = [];
res.on('data', function (c) { chunks.push(c); });
res.on('end', function () {
var raw = Buffer.concat(chunks).toString('utf-8');
try { resolve(JSON.parse(raw)); }
catch (e) { reject(new Error('invalid json from ' + targetUrl + ': ' + raw.slice(0, 120))); }
});
});
req.on('error', reject);
req.on('timeout', function () { req.destroy(new Error('timeout')); });
req.write(data);
req.end();
} catch (e) { reject(e); }
});
}
async function probeEditor(info) {
try {
var initRes = await httpJsonRpc(info.url, {
jsonrpc: '2.0', id: 1, method: 'initialize', params: {
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: 'cocos-mcp-router', version: ROUTER_INFO.version },
capabilities: {},
},
});
if (initRes.error) throw new Error(initRes.error.message);
var listRes = await httpJsonRpc(info.url, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
if (listRes.error) throw new Error(listRes.error.message);
return listRes.result.tools || [];
} catch (e) {
logErr('probe failed', info.projectShortName, info.url, e.message);
return null;
}
}
/** 去掉 shortName 里的非法字符,MCP tool 名只允许 [a-zA-Z0-9_-] */
function sanitizeShortName(name) {
return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
}
async function discover() {
var entries = scanRegistry();
var seen = new Set();
for (var i = 0; i < entries.length; i++) {
var info = entries[i];
var key = info.url;
seen.add(key);
if (editors.has(key)) continue; // 已知,不重复 probe
var tools = await probeEditor(info);
if (tools == null) continue;
editors.set(key, {
baseShortName: sanitizeShortName(info.projectShortName),
shortName: sanitizeShortName(info.projectShortName), // dedupeShortNames 会按冲突重设
projectPath: info.projectPath,
pid: info.pid,
url: info.url,
tools: tools,
lastProbed: Date.now(),
});
logErr('discovered editor', info.projectShortName, 'pid=' + info.pid, info.url, tools.length + ' tools');
}
// 清理已消失的
for (var key2 of Array.from(editors.keys())) {
if (!seen.has(key2)) {
var old = editors.get(key2);
logErr('lost editor', old.shortName, old.url);
editors.delete(key2);
}
}
// shortName 撞名去重(多编辑器 projectShortName 相同时,否则 tool 前缀冲突会把请求路由到错的编辑器)
dedupeShortNames();
}
/**
* shortName 撞名去重:多个编辑器 projectShortName 算出来相同时(如某仓库下 my-app/client 和
* my-app/server 两个项目都被 getProjectShortName 算成 my-app),
* tool 前缀 `<shortName>__xxx` 冲突 → findEditorByPrefixedTool 只命中第一个 → 请求串到错的编辑器。
* 冲突的用 projectPath 末段(client / server)加后缀区分,单实例保持原名不变。
*/
function dedupeShortNames() {
var counts = {};
for (var ed of editors.values()) {
counts[ed.baseShortName] = (counts[ed.baseShortName] || 0) + 1;
}
var used = {};
for (var ed2 of editors.values()) {
if (counts[ed2.baseShortName] > 1) {
var suffix = sanitizeShortName(path.basename(ed2.projectPath || 'unknown'));
var name = ed2.baseShortName + '-' + suffix;
// 极端情况末段也相同,再加序号兜底
var n = 2;
while (used[name] && used[name] !== ed2.url) { name = ed2.baseShortName + '-' + suffix + '-' + n; n++; }
ed2.shortName = name;
used[name] = ed2.url;
} else {
ed2.shortName = ed2.baseShortName;
}
}
}
function buildAggregatedToolList() {
var out = [];
for (var ed of editors.values()) {
ed.tools.forEach(function (t) {
out.push({
name: ed.shortName + '__' + t.name,
description: '[' + ed.shortName + '] ' + (t.description || ''),
inputSchema: t.inputSchema || { type: 'object', properties: {} },
});
});
}
// router 自身的 meta tool
out.push({
name: 'router_list_editors',
description: '列出当前 router 发现的所有活跃 Cocos 编辑器(shortName / pid / url / tool 数)',
inputSchema: { type: 'object', properties: {} },
});
// offline prefab tools(不需要编辑器运行)
offlineTools.OFFLINE_TOOLS.forEach(function (t) { out.push(t); });
// 编辑器进程管理 toolsspawn/kill/restart/wait_ready,不需要编辑器运行)
editorControl.EDITOR_TOOLS.forEach(function (t) { out.push(t); });
return out;
}
function findEditorByPrefixedTool(prefixedName) {
for (var ed of editors.values()) {
var pfx = ed.shortName + '__';
if (prefixedName.indexOf(pfx) === 0) {
return { editor: ed, originalName: prefixedName.substring(pfx.length) };
}
}
return null;
}
// ── stdio JSON-RPC ──
var stdinBuf = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', function (chunk) {
stdinBuf += chunk;
// MCP stdio 协议:按行切分 JSON-RPC 消息(每条独立 JSON
var lines = stdinBuf.split('\n');
stdinBuf = lines.pop();
lines.forEach(function (line) {
line = line.trim();
if (!line) return;
var msg;
try { msg = JSON.parse(line); }
catch (e) { logErr('parse error:', line.slice(0, 120)); return; }
handleMessage(msg);
});
});
function send(obj) {
if (obj == null) return;
process.stdout.write(JSON.stringify(obj) + '\n');
}
async function handleMessage(msg) {
if (msg.jsonrpc !== '2.0') return;
var id = msg.id;
var method = msg.method;
var params = msg.params || {};
try {
var result;
switch (method) {
case 'initialize':
await discover();
result = {
protocolVersion: PROTOCOL_VERSION,
serverInfo: ROUTER_INFO,
capabilities: {
tools: { listChanged: true },
logging: {},
},
};
break;
case 'initialized':
case 'notifications/initialized':
if (id == null) return;
result = {};
break;
case 'ping':
result = {};
break;
case 'tools/list':
await discover();
result = { tools: buildAggregatedToolList() };
break;
case 'tools/call':
result = await handleToolCall(params.name, params.arguments || {});
break;
default:
throw Object.assign(new Error('method not found: ' + method), { code: -32601 });
}
if (id == null) return;
send({ jsonrpc: '2.0', id: id, result: result });
} catch (e) {
if (id == null) return;
send({ jsonrpc: '2.0', id: id, error: { code: e.code || -32603, message: e.message || 'internal error' } });
}
}
async function handleToolCall(name, args) {
// Router 自身的 meta tool
if (name === 'router_list_editors') {
await discover();
var list = Array.from(editors.values()).map(function (ed) {
return { shortName: ed.shortName, pid: ed.pid, url: ed.url, projectPath: ed.projectPath, toolCount: ed.tools.length };
});
return { content: [{ type: 'text', text: JSON.stringify(list, null, 2) }] };
}
// Offline prefab tools(不需要编辑器运行,同进程调用 cli)
if (offlineTools.isOfflineTool(name)) {
return await offlineTools.handleOfflineToolCall(name, args);
}
// 编辑器进程管理 toolsspawn/kill/restart/wait_readyrouter 本地执行,不走转发)
if (editorControl.isEditorTool(name)) {
return await editorControl.handleEditorToolCall(name, args);
}
var hit = findEditorByPrefixedTool(name);
if (!hit) {
// 可能是新增编辑器,re-discover 再试一次
await discover();
hit = findEditorByPrefixedTool(name);
}
if (!hit) {
return { content: [{ type: 'text', text: 'unknown tool: ' + name + '\n可用编辑器: ' + Array.from(editors.values()).map(function(e){return e.shortName;}).join(', ') }], isError: true };
}
try {
var forward = await httpJsonRpc(hit.editor.url, {
jsonrpc: '2.0', id: Date.now(), method: 'tools/call',
params: { name: hit.originalName, arguments: args },
});
if (forward.error) {
return { content: [{ type: 'text', text: 'editor error: ' + forward.error.message }], isError: true };
}
return forward.result;
} catch (e) {
// 编辑器可能关闭了,移除缓存
editors.delete(hit.editor.url);
return { content: [{ type: 'text', text: 'forward failed: ' + e.message }], isError: true };
}
}
// ── 周期性重扫 ──
setInterval(function () { discover().catch(function () {}); }, DISCOVERY_INTERVAL_MS);
// 启动首次发现
discover().catch(function (e) { logErr('initial discover failed', e.message); });
logErr('cocos-mcp-router started (stdio)');
+562
View File
@@ -0,0 +1,562 @@
'use strict';
/**
* router/src/editor-control.js
*
* Router 级编辑器进程管理 tool。spawn / kill / wait_ready / restart Cocos 编辑器进程。
*
* 为什么挂在 router(而不是编辑器内 server):
* 编辑器内的 MCP server 寄生在编辑器进程里,kill 编辑器 = kill server 自己,自杀后没法
* 再把自己拉起来。router 是进程外的常驻 stdio 进程,编辑器死了它还活着,所以「关 / 重启 /
* 等就绪」这类要跨越编辑器进程生死的能力只能放这里,跟 offline prefab tools 同类,不走转发。
*
* 定位机制:直接读注册目录 ~/.cocos-mcp/editors/<pid>.json(与 bin.js scanRegistry 同源),
* 不依赖 bin.js 的 editors Map —— 因为要管理「还没就绪」和「已被 kill」的实例,那些不在 Map 里。
*
* [editor] tool 命名不加 shortName 前缀(router 全局工具)。
*
* 仅支持 macOSexecPath 解析按 /Applications/Cocos/Creator/<version>/ 规律)。
*/
var fs = require('fs');
var path = require('path');
var os = require('os');
var http = require('http');
var cp = require('child_process');
var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
var STALE_MS = 120 * 1000; // 与 bin.js 对齐:2 分钟没心跳视为死
var PROTOCOL_VERSION = '2024-11-05';
// ── 通用小工具 ──────────────────────────────────────────────────
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
/** MCP tool 名只允许 [a-zA-Z0-9_-],与 bin.js sanitizeShortName 保持一致 */
function sanitize(name) {
return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
}
function jsonContent(obj, isError) {
var r = { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
if (isError) r.isError = true;
return r;
}
/** 进程是否存活:kill(pid, 0) 不抛 = 活;EPERM = 存在但无权限(仍算活) */
function isAlive(pid) {
if (!pid) return false;
try { process.kill(pid, 0); return true; }
catch (e) { return e.code === 'EPERM'; }
}
// ── 注册表读取 ──────────────────────────────────────────────────
/**
* 读注册目录全部 entry,附带 stale 标记和 mtime。
* 不删 stale 文件(删由 bin.js scanRegistry 负责,这里只读,避免和 bin.js 抢删竞态)。
*/
function readRegistryEntries() {
var out = [];
try {
if (!fs.existsSync(REGISTRY_DIR)) return out;
var now = Date.now();
fs.readdirSync(REGISTRY_DIR).forEach(function (name) {
if (!name.endsWith('.json')) return;
var full = path.join(REGISTRY_DIR, name);
try {
var st = fs.statSync(full);
var info = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (!info) return;
info.stale = (now - st.mtimeMs > STALE_MS);
info.mtimeMs = st.mtimeMs;
out.push(info);
} catch (e) { /* 单个坏文件跳过 */ }
});
} catch (e) { /* ignore */ }
return out;
}
/**
* 真正可用的编辑器实例。除 stale / url 外,必须 isAlive(pid) —— 关键:
* 进程崩溃 / 被重启但没走优雅退出时,注册文件会残留到 120s stale 才被清,这段窗口内
* 仅按 mtime 会把「已死实例」误判为活跃,污染 resolveTarget 的多实例判断(曾误拦无参 restart)。
*/
function activeEditors() {
return readRegistryEntries().filter(function (e) { return !e.stale && e.url && isAlive(e.pid); });
}
function removeRegistryFile(pid) {
try {
var f = path.join(REGISTRY_DIR, pid + '.json');
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch (e) { /* ignore */ }
}
// ── 目标解析 ────────────────────────────────────────────────────
/** 把活跃 entry 列成简短描述,给报错用 */
function describeActive() {
var list = activeEditors().map(function (e) {
return sanitize(e.projectShortName) + '(pid=' + e.pid + ')';
});
return list.length ? list.join(', ') : '无';
}
/**
* 在「活跃」实例里按 shortName / projectPath / pid 定位唯一目标。
* 无任何指定且只有一个活跃实例时默认它;零个或多个都报错要求显式指定。
* 用于 kill / restart —— 它们都作用于「当前还活着的编辑器」。
*/
function resolveTarget(args) {
args = args || {};
var entries = activeEditors();
if (args.pid) {
var byPid = entries.filter(function (e) { return e.pid === args.pid; });
if (byPid.length) return byPid[0];
throw new Error('editor-control: 没有 pid=' + args.pid + ' 的活跃编辑器。当前活跃:' + describeActive());
}
if (args.projectPath) {
var byPath = entries.filter(function (e) { return e.projectPath === args.projectPath; });
if (byPath.length) return byPath[0];
throw new Error('editor-control: 没有 projectPath=' + args.projectPath + ' 的活跃编辑器。当前活跃:' + describeActive());
}
if (args.shortName) {
var want = sanitize(args.shortName);
var byName = entries.filter(function (e) { return sanitize(e.projectShortName) === want; });
if (byName.length) return byName[0];
throw new Error('editor-control: 没有 shortName=' + args.shortName + ' 的活跃编辑器。当前活跃:' + describeActive());
}
// 无指定
if (entries.length === 1) return entries[0];
if (entries.length === 0) {
throw new Error('editor-control: 没有活跃的 Cocos 编辑器。若编辑器未运行,请用 editor_restart 并显式传 projectPath(无法从空注册表推断项目路径)。');
}
throw new Error('editor-control: 有多个活跃编辑器,请用 shortName / projectPath / pid 指定。当前活跃:' + describeActive());
}
// ── execPath 解析(三级 fallback)───────────────────────────────
/** 从运行中进程的命令行抓可执行路径(编辑器启动命令首段,--project 之前) */
function execPathFromPs(pid) {
try {
var out = cp.execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }).trim();
if (!out) return '';
// 形如:/Applications/Cocos/Creator/3.8.8/CocosCreator.app/Contents/MacOS/CocosCreator --project /x ...
// 可执行路径本身不含 " --",按它切分安全
var idx = out.indexOf(' --');
return (idx >= 0 ? out.slice(0, idx) : out.split(/\s+/)[0]).trim();
} catch (e) { return ''; }
}
/**
* 解析编辑器可执行路径,三级 fallback:
* 1. 注册文件 execPath 字段(main.js 写入,最准)
* 2. 从活进程 ps 命令行抓(编辑器还活着时)—— restart 会在 kill 前调用,此时旧进程还在
* 3. 按 editorVersion 拼标准安装路径
* 全部失败抛错,提示带上尝试过的路径。
*/
function resolveExecPath(entry) {
if (entry.execPath && fs.existsSync(entry.execPath)) return entry.execPath;
if (entry.pid && isAlive(entry.pid)) {
var fromPs = execPathFromPs(entry.pid);
if (fromPs && fs.existsSync(fromPs)) return fromPs;
}
if (entry.editorVersion) {
var guess = '/Applications/Cocos/Creator/' + entry.editorVersion +
'/CocosCreator.app/Contents/MacOS/CocosCreator';
if (fs.existsSync(guess)) return guess;
}
throw new Error(
'editor-control: 无法解析 Cocos 编辑器可执行路径。\n' +
' 注册文件 execPath: ' + (entry.execPath || '(无)') + '\n' +
' editorVersion: ' + (entry.editorVersion || '(无)') + '\n' +
'请确认 Cocos Creator 装在标准路径 /Applications/Cocos/Creator/<version>/' +
'或重启编辑器让扩展写入 execPath 字段后再试。'
);
}
// ── 进程操作 ────────────────────────────────────────────────────
/**
* kill 指定编辑器:先 SIGTERM 优雅退,graceMs 内没退再 SIGKILL,最后主动删注册文件。
* 主动删的原因:强杀 / 崩溃不会走 main.js removeRegistry,靠 router 120s stale 清理太慢,
* 会导致 wait_ready 误匹配到「已死但文件还新鲜」的旧 entry。
*/
async function killEditor(pid, opts) {
opts = opts || {};
var graceMs = opts.graceMs || 6000;
if (!isAlive(pid)) {
removeRegistryFile(pid);
return { killed: false, reason: 'not running', pid: pid };
}
var signal = opts.hard ? 'SIGKILL' : 'SIGTERM';
try { process.kill(pid, signal); } catch (e) { /* 可能刚好退了 */ }
var start = Date.now();
while (Date.now() - start < graceMs) {
await sleep(150);
if (!isAlive(pid)) break;
}
var escalated = false;
if (isAlive(pid)) {
escalated = true;
try { process.kill(pid, 'SIGKILL'); } catch (e) { /* ignore */ }
await sleep(400);
}
removeRegistryFile(pid);
return {
killed: !isAlive(pid),
pid: pid,
signal: signal,
escalatedToSigkill: escalated,
waitedMs: Date.now() - start,
};
}
/**
* 冷启动场景解析 execPath:进程已不在,没有活进程可 ps 抓,靠四级 fallback
* 1. args.execPath 显式
* 2. args.version 拼标准路径
* 3. 借任意一条注册 entry 的 execPath —— execPath 是机器级安装路径,跨项目通用,
* 哪怕那条 entry 是别的项目 / 已 stale 也能用
* 4. 扫 /Applications/Cocos/Creator 下唯一安装版本
*/
function resolveExecPathForSpawn(args, projectPath) {
if (args.execPath && fs.existsSync(args.execPath)) return args.execPath;
if (args.version) {
var byVer = '/Applications/Cocos/Creator/' + args.version + '/CocosCreator.app/Contents/MacOS/CocosCreator';
if (fs.existsSync(byVer)) return byVer;
}
var borrowed = readRegistryEntries().filter(function (e) { return e.execPath && fs.existsSync(e.execPath); })[0];
if (borrowed) return borrowed.execPath;
var base = '/Applications/Cocos/Creator';
try {
var vers = fs.readdirSync(base).filter(function (v) {
return fs.existsSync(base + '/' + v + '/CocosCreator.app/Contents/MacOS/CocosCreator');
});
if (vers.length === 1) return base + '/' + vers[0] + '/CocosCreator.app/Contents/MacOS/CocosCreator';
if (vers.length > 1) {
throw new Error('editor_spawn: ' + base + ' 下有多个版本 [' + vers.join(', ') + '],请用 version 指定要启动哪个。');
}
} catch (e) {
if (/多个版本/.test(e.message)) throw e;
}
throw new Error('editor_spawn: 无法解析 Cocos 可执行路径(execPath/version 都没给,注册表也无可借项)。请传 execPath 或 version。');
}
/**
* detached 拉起编辑器,立即与 router 解耦(router 退出不带走编辑器)。
* 返回的是 launcher pid,不一定等于编辑器主进程最终 pid —— 真实 pid 以 wait_ready
* 扫注册表拿到的为准,这里的 pid 仅供日志参考。
*/
function spawnEditor(execPath, projectPath) {
var child = cp.spawn(execPath, ['--project', projectPath], {
detached: true,
stdio: 'ignore',
});
child.unref();
return child.pid;
}
// ── 就绪探测 ────────────────────────────────────────────────────
/** 通用 HTTP MCP 调用,返回完整 JSON-RPC 响应(失败返回 null)。probeReady/probeProjectReady 共用。 */
function httpMcp(url, method, params, timeoutMs) {
return new Promise(function (resolve) {
try {
var u = new URL(url);
var body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: method, params: params || {} });
var req = http.request({
hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
timeout: timeoutMs || 4000,
}, function (res) {
var chunks = [];
res.on('data', function (c) { chunks.push(c); });
res.on('end', function () {
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); }
catch (e) { resolve(null); }
});
});
req.on('error', function () { resolve(null); });
req.on('timeout', function () { req.destroy(); resolve(null); });
req.write(body);
req.end();
} catch (e) { resolve(null); }
});
}
/** MCP initialize 探活:能 initialize = MCP server 起来了(但不代表进了项目,登录页态也能起)。 */
function probeReady(url) {
return httpMcp(url, 'initialize', {
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: 'editor-control', version: '0' },
capabilities: {},
}).then(function (r) { return !!(r && !r.error); });
}
/**
* 项目就绪探测:MCP initialize 成功 ≠ 进了项目 —— 实测激进清登录态后 initialize 仍 ready
* 但编辑器 UI 卡在登录页。用 asset_query_assets 探 asset-db 是否就绪:项目真打开才加载
* asset-db、返回非空资源;登录页 / 项目加载中则空或失败。
* 正向(进项目非空)已实测;负向(登录页态返回啥)按逻辑推断,未在登录页态实测。
*/
function probeProjectReady(url) {
return httpMcp(url, 'tools/call', {
name: 'asset_query_assets', arguments: { pattern: 'db://assets/**', type: 'scene' },
}, 6000).then(function (r) {
if (!r || r.error || !r.result || r.result.isError) return false;
var txt = r.result.content && r.result.content[0] && r.result.content[0].text;
if (!txt) return false;
try { var arr = JSON.parse(txt); return Array.isArray(arr) && arr.length > 0; }
catch (e) { return false; }
});
}
/**
* 轮询等指定项目的编辑器就绪。
* 就绪判定:注册表有 projectPath 匹配、非 stale、pid≠excludePid 的 entry,且 probeReady 成功。
* excludePidrestart 时传被 kill 的旧 pid,避免匹配到尚未删净的旧注册。
* 返回 { ready, entry?, reason?, waitedMs }。
*/
async function waitReady(projectPath, opts) {
opts = opts || {};
var timeoutMs = opts.timeoutMs || 90000; // 大项目冷启动慢,默认 90s
var excludePid = opts.excludePid || 0;
var requireProject = opts.requireProject !== false; // 默认要求项目就绪(区分登录页/加载中),传 false 退回只看 MCP
var start = Date.now();
var lastReason = 'still waiting';
var sawMcp = false;
while (Date.now() - start < timeoutMs) {
var hit = activeEditors().filter(function (e) {
return e.projectPath === projectPath && e.pid !== excludePid;
})[0];
if (hit) {
var mcpOk = await probeReady(hit.url);
if (mcpOk) {
sawMcp = true;
var projOk = requireProject ? await probeProjectReady(hit.url) : true;
if (projOk) {
return {
ready: true, mcpReady: true, projectReady: projOk,
entry: {
shortName: sanitize(hit.projectShortName),
pid: hit.pid, url: hit.url, port: hit.port,
projectPath: hit.projectPath, editorVersion: hit.editorVersion,
},
waitedMs: Date.now() - start,
};
}
lastReason = 'MCP up (pid=' + hit.pid + ') 但 asset-db 未就绪 — 疑似卡登录页或项目加载中';
} else {
lastReason = 'registered (pid=' + hit.pid + ') but MCP server not responding yet';
}
} else {
lastReason = 'no fresh registry entry for project yet (editor still booting)';
}
await sleep(1000);
}
var res = { ready: false, mcpReady: sawMcp, projectReady: false, reason: lastReason, waitedMs: Date.now() - start };
if (sawMcp) res.hint = '⚠️ MCP server 起来了但项目没就绪 — 很可能卡在登录页,请手动点 Sign In→skip 进项目后重试';
return res;
}
// ── Tool 定义 ───────────────────────────────────────────────────
var COMMON_TARGET_PROPS = {
shortName: { type: 'string', description: '编辑器短名(工具前缀名,如 my-project)。只有一个编辑器时可省略。' },
projectPath: { type: 'string', description: '项目绝对路径,定位最精确。编辑器未运行时(restart/wait_ready)必须用它。' },
};
var EDITOR_TOOLS = [
{
name: 'editor_restart',
description: '[editor] 重启 Cocos 编辑器进程(kill 旧实例 → 重新拉起 → 等就绪)。' +
'不需要编辑器在运行也能调(挂 router 进程)。仅 macOS。' +
'返回 oldPid / launchedPid / kill 结果 / ready 状态(含新 pid·port·url)。',
inputSchema: {
type: 'object',
properties: {
shortName: COMMON_TARGET_PROPS.shortName,
projectPath: COMMON_TARGET_PROPS.projectPath,
pid: { type: 'number', description: '直接按 pid 定位要重启的编辑器。' },
hard: { type: 'boolean', description: 'true=直接 SIGKILL,不给优雅退出窗口。默认 false(先 SIGTERM)。' },
timeoutMs: { type: 'number', description: '等新实例就绪的超时(毫秒),默认 90000。' },
},
},
},
{
name: 'editor_wait_ready',
description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 MCP server 能 initialize)。' +
'用于「拉起编辑器后等它起来再操作」。已就绪则立即返回。' +
'编辑器尚未运行时必须传 projectPath(空注册表无法从 shortName 反推路径)。',
inputSchema: {
type: 'object',
properties: {
shortName: COMMON_TARGET_PROPS.shortName,
projectPath: COMMON_TARGET_PROPS.projectPath,
timeoutMs: { type: 'number', description: '超时(毫秒),默认 90000。' },
excludePid: { type: 'number', description: '排除某个 pid(如刚被 kill 的旧实例),避免误判旧注册为就绪。' },
},
},
},
{
name: 'editor_kill',
description: '[editor] 关闭 Cocos 编辑器进程(SIGTERM,超时升级 SIGKILL,并清理注册文件)。' +
'不需要编辑器内 server 配合(挂 router 进程)。',
inputSchema: {
type: 'object',
properties: {
shortName: COMMON_TARGET_PROPS.shortName,
projectPath: COMMON_TARGET_PROPS.projectPath,
pid: { type: 'number', description: '直接按 pid 定位。' },
hard: { type: 'boolean', description: 'true=直接 SIGKILL。默认 false。' },
graceMs: { type: 'number', description: 'SIGTERM 后等待优雅退出的时长(毫秒),默认 6000,超时强杀。' },
},
},
},
{
name: 'editor_spawn',
description: '[editor] 从零启动一个 Cocos 编辑器(进程完全不在时用,如崩溃后恢复)。' +
'同项目已有活跃实例则直接返回不重复开(Cocos 不支持同项目多开)。仅 macOS。',
inputSchema: {
type: 'object',
properties: {
projectPath: { type: 'string', description: '项目绝对路径(必填)。' },
version: { type: 'string', description: 'Cocos 版本号(如 3.8.8),用于拼可执行路径。不传则从注册表借或扫唯一安装。' },
execPath: { type: 'string', description: '直接指定可执行路径,优先级最高。' },
timeoutMs: { type: 'number', description: '等就绪超时(毫秒),默认 90000。' },
},
required: ['projectPath'],
},
},
];
var EDITOR_TOOL_NAMES = new Set(EDITOR_TOOLS.map(function (t) { return t.name; }));
function isEditorTool(name) {
return EDITOR_TOOL_NAMES.has(name);
}
// ── Tool 调用处理 ───────────────────────────────────────────────
async function handleEditorToolCall(name, args) {
args = args || {};
if (name === 'editor_restart') {
var target;
try {
target = resolveTarget(args);
} catch (e) {
// 无活跃实例但给了 projectPath → 进程崩溃/消失了,降级为冷启动 spawn(崩溃恢复闭环)
if (args.projectPath) return await handleEditorToolCall('editor_spawn', args);
throw e;
}
var execPath = resolveExecPath(target); // kill 前解析,此时旧进程还活着,ps 兜底有效
var projectPath = target.projectPath;
var oldPid = target.pid;
var killRes = await killEditor(oldPid, { hard: args.hard });
var launchedPid = spawnEditor(execPath, projectPath);
var ready = await waitReady(projectPath, { timeoutMs: args.timeoutMs, excludePid: oldPid });
return jsonContent({
action: 'restart',
shortName: sanitize(target.projectShortName),
projectPath: projectPath,
execPath: execPath,
oldPid: oldPid,
launchedPid: launchedPid,
kill: killRes,
ready: ready,
}, !ready.ready);
}
if (name === 'editor_wait_ready') {
var pp = args.projectPath;
if (!pp) {
// 没给 projectPath:尝试从活跃实例(可选 shortName 过滤)推断
var act = activeEditors();
if (args.shortName) {
var want = sanitize(args.shortName);
act = act.filter(function (e) { return sanitize(e.projectShortName) === want; });
}
if (act.length === 1) pp = act[0].projectPath;
else if (act.length === 0) {
throw new Error('editor_wait_ready: 没有活跃编辑器可推断 projectPath。编辑器尚未就绪时必须显式传 projectPath(指定等待哪个项目)。');
} else {
throw new Error('editor_wait_ready: 有多个活跃编辑器,请传 projectPath 或 shortName 指定。当前活跃:' + describeActive());
}
}
var r = await waitReady(pp, { timeoutMs: args.timeoutMs, excludePid: args.excludePid });
return jsonContent({ action: 'wait_ready', projectPath: pp, result: r }, !r.ready);
}
if (name === 'editor_kill') {
var t = resolveTarget(args);
var res = await killEditor(t.pid, { hard: args.hard, graceMs: args.graceMs });
return jsonContent({
action: 'kill',
shortName: sanitize(t.projectShortName),
projectPath: t.projectPath,
result: res,
}, !res.killed);
}
if (name === 'editor_spawn') {
var spProject = args.projectPath;
if (!spProject || !path.isAbsolute(spProject)) {
throw new Error('editor_spawn: projectPath 必填且必须是绝对路径,收到 ' + JSON.stringify(spProject));
}
// 幂等:同项目已有活跃实例直接返回(Cocos 不支持同项目多开)
var spRunning = activeEditors().filter(function (e) { return e.projectPath === spProject; })[0];
if (spRunning) {
return jsonContent({
action: 'spawn', alreadyRunning: true,
entry: {
shortName: sanitize(spRunning.projectShortName), pid: spRunning.pid,
url: spRunning.url, port: spRunning.port, projectPath: spRunning.projectPath,
},
});
}
var spExec = resolveExecPathForSpawn(args, spProject);
var spPid = spawnEditor(spExec, spProject);
var spReady = await waitReady(spProject, { timeoutMs: args.timeoutMs });
return jsonContent({
action: 'spawn', execPath: spExec, launchedPid: spPid, ready: spReady,
}, !spReady.ready);
}
throw new Error('editor-control: 未知 tool "' + name + '"');
}
module.exports = {
EDITOR_TOOLS: EDITOR_TOOLS,
isEditorTool: isEditorTool,
handleEditorToolCall: handleEditorToolCall,
// 导出内部函数供测试 / bin.js 复用
readRegistryEntries: readRegistryEntries,
activeEditors: activeEditors,
resolveTarget: resolveTarget,
resolveExecPath: resolveExecPath,
resolveExecPathForSpawn: resolveExecPathForSpawn,
waitReady: waitReady,
probeReady: probeReady,
probeProjectReady: probeProjectReady,
isAlive: isAlive,
};
+197
View File
@@ -0,0 +1,197 @@
'use strict';
/**
* router/src/offline-tools.js
*
* Router offline prefab tool 定义
* 这些 tool 直接调用 cli/src/index.js editPrefab / queryPrefab
* 同进程执行无需 Cocos 编辑器运行
*
* [offline] tool 命名不加 shortName 前缀router 全局工具
*/
var fs = require('fs');
var path = require('path');
// 延迟 require,避免 bin.js 加载时 cli 路径不对
// __dirname = router/src/cli 相对路径为 ../../cli/src/index.js
var CLI_INDEX = path.resolve(__dirname, '../../cli/src/index.js');
function getCli() {
return require(CLI_INDEX);
}
// ── 工具:绝对路径校验 ──────────────────────────────────────────
/**
* 校验 filePath 是否为绝对路径否则抛错
* 原因router stdio 方式运行cwd 不确定相对路径有歧义
*
* @param {string} filePath
* @param {string} toolName
*/
function requireAbsolutePath(filePath, toolName) {
if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
throw new Error(
'[' + toolName + '] filePath 必须是绝对路径,收到: ' + JSON.stringify(filePath) +
'\n原因:router 以 stdio 模式运行,cwd 不确定,相对路径会产生歧义。'
);
}
}
// ── Tool 定义 ───────────────────────────────────────────────────
/**
* offline tool 定义列表 router 自身 buildAggregatedToolList 合并
* 格式与编辑器 tool 相同 handleOfflineToolCall 分发
*/
var OFFLINE_TOOLS = [
{
name: 'prefab_query',
description: '[offline] 不需要 Cocos 编辑器运行。查询 prefab 文件节点树或单节点详情。\n' +
'selector.type 可选:\n' +
' tree(默认)→ 精简节点树\n' +
' node → { name } 按名称查单节点详情\n' +
' find → { nodeType } 返回所有匹配 __type__ 的元素 id 列表',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'prefab 文件的绝对路径,如 /path/to/HomeUI.prefab',
},
selector: {
type: 'object',
description: '查询选择器,不传时默认 type="tree"',
properties: {
type: { type: 'string', enum: ['tree', 'node', 'find'] },
name: { type: 'string', description: 'selector.type="node" 时必填' },
nodeType: { type: 'string', description: 'selector.type="find" 时必填,如 "cc.Label"' },
},
},
},
required: ['filePath'],
},
},
{
name: 'prefab_edit',
description: '[offline] 不需要 Cocos 编辑器运行。声明式批量编辑 prefab 文件,全部 op 成功后一次性落盘。\n' +
'支持的 op.op 类型:set-position / set-label-text / set-sprite-frame / set-active / add-node / remove-node / clone-node / add-component / set-component-ref\n' +
'op.node / op.parent / op.refNode 可以是节点名称字符串,或 { id: N } 按 __id__ 定位。',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'prefab 文件的绝对路径',
},
ops: {
type: 'array',
description: 'op 描述数组,参考 cli editPrefab 文档',
items: {
type: 'object',
properties: {
op: { type: 'string' },
node: {},
},
required: ['op', 'node'],
},
},
},
required: ['filePath', 'ops'],
},
},
{
name: 'prefab_batch',
description: '[offline] 不需要 Cocos 编辑器运行。从 JSON 文件读取 ops 后批量编辑 prefab。\n' +
'opsJsonPath 指向一个 JSON 文件,内容为 op 数组(与 prefab_edit 的 ops 格式相同)。\n' +
'两个路径均必须为绝对路径。',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'prefab 文件的绝对路径',
},
opsJsonPath: {
type: 'string',
description: 'ops JSON 文件的绝对路径',
},
},
required: ['filePath', 'opsJsonPath'],
},
},
];
// ── Tool 名称集合(用于快速判断是否是 offline tool)──────────────
var OFFLINE_TOOL_NAMES = new Set(OFFLINE_TOOLS.map(function (t) { return t.name; }));
/**
* 判断 name 是否是 offline tool
* @param {string} name
* @returns {boolean}
*/
function isOfflineTool(name) {
return OFFLINE_TOOL_NAMES.has(name);
}
// ── Tool 调用处理 ───────────────────────────────────────────────
/**
* 处理 offline tool 调用返回 MCP content 对象
*
* @param {string} name tool 名称
* @param {object} args tool 参数
* @returns {{ content: Array }}
*/
async function handleOfflineToolCall(name, args) {
var cli = getCli();
if (name === 'prefab_query') {
requireAbsolutePath(args.filePath, 'prefab_query');
var result = cli.queryPrefab(args.filePath, args.selector);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
if (name === 'prefab_edit') {
requireAbsolutePath(args.filePath, 'prefab_edit');
if (!Array.isArray(args.ops) || args.ops.length === 0) {
throw new Error('[prefab_edit] ops 必须是非空数组');
}
var editResult = cli.editPrefab(args.filePath, args.ops);
return {
content: [{ type: 'text', text: JSON.stringify(editResult, null, 2) }],
};
}
if (name === 'prefab_batch') {
requireAbsolutePath(args.filePath, 'prefab_batch');
requireAbsolutePath(args.opsJsonPath, 'prefab_batch');
var opsRaw = fs.readFileSync(args.opsJsonPath, 'utf-8');
var ops;
try {
ops = JSON.parse(opsRaw);
} catch (e) {
throw new Error('[prefab_batch] opsJsonPath 解析失败: ' + e.message);
}
if (!Array.isArray(ops) || ops.length === 0) {
throw new Error('[prefab_batch] opsJsonPath 文件内容必须是非空数组');
}
var batchResult = cli.editPrefab(args.filePath, ops);
return {
content: [{ type: 'text', text: JSON.stringify(batchResult, null, 2) }],
};
}
throw new Error('offline-tools: 未知 tool "' + name + '"');
}
module.exports = {
OFFLINE_TOOLS: OFFLINE_TOOLS,
isOfflineTool: isOfflineTool,
handleOfflineToolCall: handleOfflineToolCall,
requireAbsolutePath: requireAbsolutePath,
};
+218
View File
@@ -0,0 +1,218 @@
'use strict';
// ============================================================
// router/test/offline-tools.test.js
// T13 offline tool 测试
//
// 直接 require router/src/offline-tools.js,测:
// 1. prefab_query happy pathtree / node / find
// 2. prefab_edit happy pathset-active 写 tmp 文件)
// 3. prefab_batch happy pathopsJson 文件 → editPrefab
// 4. 相对路径 filePath 报错
// 5. prefab_batch opsJsonPath 相对路径报错
// ============================================================
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
isOfflineTool,
handleOfflineToolCall,
requireAbsolutePath,
OFFLINE_TOOLS,
} = require('../src/offline-tools.js');
// fixture: HomeUI.prefab(只读,在 cli/test/fixtures/
const FIXTURE_PATH = path.resolve(
__dirname,
'../../cli/test/fixtures/HomeUI.prefab'
);
// 复制 fixture 到 tmp 用于写操作
function makeTmp(tag) {
var dst = path.join(os.tmpdir(), 'HomeUI-router-' + tag + '-' + Date.now() + '.prefab');
fs.copyFileSync(FIXTURE_PATH, dst);
return dst;
}
// ── OFFLINE_TOOLS 定义完整性 ────────────────────────────────────
test('OFFLINE_TOOLS 导出 3 个 tool,名称正确', () => {
assert.equal(OFFLINE_TOOLS.length, 3);
var names = OFFLINE_TOOLS.map(function (t) { return t.name; });
assert.ok(names.includes('prefab_query'));
assert.ok(names.includes('prefab_edit'));
assert.ok(names.includes('prefab_batch'));
});
test('isOfflineTool 对已知 name 返回 true,未知 name 返回 false', () => {
assert.equal(isOfflineTool('prefab_query'), true);
assert.equal(isOfflineTool('prefab_edit'), true);
assert.equal(isOfflineTool('prefab_batch'), true);
assert.equal(isOfflineTool('router_list_editors'), false);
assert.equal(isOfflineTool('scene_set_property'), false);
assert.equal(isOfflineTool(''), false);
});
test('每个 offline tool description 包含 "[offline]" 标注', () => {
for (var t of OFFLINE_TOOLS) {
assert.ok(
t.description.includes('[offline]'),
'tool ' + t.name + ' description 应包含 "[offline]"'
);
}
});
// ── requireAbsolutePath ────────────────────────────────────────
test('requireAbsolutePath 相对路径抛错', () => {
assert.throws(
function () { requireAbsolutePath('relative/path.prefab', 'test'); },
/必须是绝对路径/
);
});
test('requireAbsolutePath 绝对路径不抛错', () => {
assert.doesNotThrow(function () {
requireAbsolutePath('/absolute/path.prefab', 'test');
});
});
// ── prefab_query happy path ─────────────────────────────────────
test('prefab_query type=tree 返回 MCP content,根节点名称为 HomeUI', async () => {
var result = await handleOfflineToolCall('prefab_query', {
filePath: FIXTURE_PATH,
selector: { type: 'tree' },
});
assert.ok(Array.isArray(result.content), 'result.content 应是数组');
assert.equal(result.content[0].type, 'text');
var data = JSON.parse(result.content[0].text);
assert.equal(data.name, 'HomeUI', '根节点 name 应为 HomeUI');
assert.ok(Array.isArray(data.children), 'children 应是数组');
});
test('prefab_query 无 selector 默认返回 tree', async () => {
var result = await handleOfflineToolCall('prefab_query', {
filePath: FIXTURE_PATH,
});
var data = JSON.parse(result.content[0].text);
assert.equal(data.name, 'HomeUI');
});
test('prefab_query type=find 返回 cc.Label id 列表', async () => {
var result = await handleOfflineToolCall('prefab_query', {
filePath: FIXTURE_PATH,
selector: { type: 'find', nodeType: 'cc.Label' },
});
var ids = JSON.parse(result.content[0].text);
assert.ok(Array.isArray(ids), 'find 结果应是数组');
assert.ok(ids.length > 0, '应找到至少一个 cc.Label');
ids.forEach(function (id) { assert.equal(typeof id, 'number'); });
});
// ── prefab_query 相对路径报错 ────────────────────────────────────
test('prefab_query 相对路径 filePath 抛错', async () => {
await assert.rejects(
function () {
return handleOfflineToolCall('prefab_query', {
filePath: 'relative/HomeUI.prefab',
});
},
/必须是绝对路径/
);
});
// ── prefab_edit happy path ──────────────────────────────────────
test('prefab_edit set-active 成功,返回 changed=true + opsApplied=1', async () => {
var tmp = makeTmp('edit');
try {
var result = await handleOfflineToolCall('prefab_edit', {
filePath: tmp,
ops: [
{ op: 'set-active', node: 'HomeUI', active: false },
],
});
var data = JSON.parse(result.content[0].text);
assert.equal(data.changed, true, 'changed 应为 true');
assert.equal(data.opsApplied, 1, 'opsApplied 应为 1');
assert.ok(Array.isArray(data.nodesAffected), 'nodesAffected 应是数组');
} finally {
try { fs.unlinkSync(tmp); } catch (_) {}
}
});
// ── prefab_edit 相对路径报错 ─────────────────────────────────────
test('prefab_edit 相对路径 filePath 抛错', async () => {
await assert.rejects(
function () {
return handleOfflineToolCall('prefab_edit', {
filePath: './relative.prefab',
ops: [{ op: 'set-active', node: 'HomeUI', active: false }],
});
},
/必须是绝对路径/
);
});
// ── prefab_batch happy path ─────────────────────────────────────
test('prefab_batch 从 JSON 文件读取 ops,成功写回', async () => {
var tmp = makeTmp('batch');
var opsJson = path.join(os.tmpdir(), 'router-batch-ops-' + Date.now() + '.json');
var ops = [
{ op: 'set-active', node: 'HomeUI', active: true },
];
fs.writeFileSync(opsJson, JSON.stringify(ops), 'utf-8');
try {
var result = await handleOfflineToolCall('prefab_batch', {
filePath: tmp,
opsJsonPath: opsJson,
});
var data = JSON.parse(result.content[0].text);
assert.equal(data.changed, true);
assert.equal(data.opsApplied, 1);
} finally {
try { fs.unlinkSync(tmp); } catch (_) {}
try { fs.unlinkSync(opsJson); } catch (_) {}
}
});
// ── prefab_batch 相对路径报错 ────────────────────────────────────
test('prefab_batch 相对路径 filePath 抛错', async () => {
await assert.rejects(
function () {
return handleOfflineToolCall('prefab_batch', {
filePath: 'relative.prefab',
opsJsonPath: '/absolute/ops.json',
});
},
/必须是绝对路径/
);
});
test('prefab_batch 相对路径 opsJsonPath 抛错', async () => {
await assert.rejects(
function () {
return handleOfflineToolCall('prefab_batch', {
filePath: FIXTURE_PATH,
opsJsonPath: 'relative/ops.json',
});
},
/必须是绝对路径/
);
});
+302
View File
@@ -0,0 +1,302 @@
'use strict';
/**
* 最小 MCP Server 实现JSON-RPC 2.0 over HTTP
*
* 协议参考https://modelcontextprotocol.io/
* 只实现 MCP 客户端常用的方法 Claude Code / Cursor 调用即可
* - initialize
* - tools/list
* - tools/call
* - resources/list
* - resources/read
* - ping
*
* 传输streamable HTTP 子集
* - 唯一端点 POST /mcpbody JSON-RPC 请求response JSON-RPC 响应
* - 不处理 SSE / session resumption第一版足够
* - 额外暴露 GET /status 用于面板自检
*/
var http = require('http');
var url = require('url');
var SERVER_INFO = {
name: 'cc-3-8-x-mcp',
version: '2.0.0',
};
var PROTOCOL_VERSION = '2024-11-05';
/**
* @typedef {Object} ToolDef
* @property {string} name
* @property {string} description
* @property {object} inputSchema JSON Schema
* @property {(args: object) => Promise<any>} handler
*/
function createServer(options) {
options = options || {};
var port = options.port || 7523;
var host = options.host || '127.0.0.1';
var logger = options.logger || console;
/** @type {Map<string, ToolDef>} */
var tools = new Map();
/** @type {Map<string, {uri:string, name:string, description:string, mimeType:string, read:()=>Promise<any>}>} */
var resources = new Map();
var httpServer = null;
var started = false;
var stats = {
startedAt: null,
requestCount: 0,
lastRequest: null,
lastError: null,
};
function registerTool(def) {
if (!def || !def.name || typeof def.handler !== 'function') {
throw new Error('invalid tool def');
}
tools.set(def.name, def);
}
function registerResource(def) {
if (!def || !def.uri || typeof def.read !== 'function') {
throw new Error('invalid resource def');
}
resources.set(def.uri, def);
}
/** 把 tool handler 抛错统一转成 JSON-RPC error payload */
async function callTool(name, args) {
var tool = tools.get(name);
if (!tool) {
var err = new Error('unknown tool: ' + name);
err.code = -32601;
throw err;
}
try {
var result = await tool.handler(args || {});
// MCP tools/call result shape: { content: [{type:'text', text:...}], isError?: boolean }
if (result && result.content) return result;
return {
content: [{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
}],
};
} catch (e) {
return {
content: [{
type: 'text',
text: 'Error: ' + (e && (e.stack || e.message) || String(e)),
}],
isError: true,
};
}
}
async function handleJsonRpc(req) {
// 批量请求
if (Array.isArray(req)) {
var out = [];
for (var i = 0; i < req.length; i++) {
var r = await handleJsonRpc(req[i]);
if (r) out.push(r);
}
return out;
}
if (!req || req.jsonrpc !== '2.0') {
return { jsonrpc: '2.0', id: (req && req.id) || null, error: { code: -32600, message: 'invalid request' } };
}
var id = req.id;
var method = req.method;
var params = req.params || {};
try {
var result;
switch (method) {
case 'initialize':
result = {
protocolVersion: PROTOCOL_VERSION,
serverInfo: SERVER_INFO,
capabilities: {
tools: { listChanged: false },
resources: { subscribe: false, listChanged: false },
logging: {},
},
};
break;
case 'initialized':
case 'notifications/initialized':
// notification, no response
if (id == null) return null;
result = {};
break;
case 'ping':
result = {};
break;
case 'tools/list':
result = {
tools: Array.from(tools.values()).map(function (t) {
return {
name: t.name,
description: t.description || '',
inputSchema: t.inputSchema || { type: 'object', properties: {} },
};
}),
};
break;
case 'tools/call':
result = await callTool(params.name, params.arguments);
break;
case 'resources/list':
result = {
resources: Array.from(resources.values()).map(function (r) {
return {
uri: r.uri,
name: r.name || r.uri,
description: r.description || '',
mimeType: r.mimeType || 'application/json',
};
}),
};
break;
case 'resources/read':
var uri = params.uri;
var res = resources.get(uri);
if (!res) {
throw Object.assign(new Error('unknown resource: ' + uri), { code: -32602 });
}
var content = await res.read();
result = {
contents: [{
uri: res.uri,
mimeType: res.mimeType || 'application/json',
text: typeof content === 'string' ? content : JSON.stringify(content, null, 2),
}],
};
break;
default:
throw Object.assign(new Error('method not found: ' + method), { code: -32601 });
}
// notification: id 缺失,不返回
if (id == null) return null;
return { jsonrpc: '2.0', id: id, result: result };
} catch (e) {
stats.lastError = { at: new Date().toISOString(), method: method, message: e.message };
if (id == null) return null;
return {
jsonrpc: '2.0',
id: id,
error: {
code: e.code || -32603,
message: e.message || 'internal error',
},
};
}
}
function handleHttp(httpReq, httpRes) {
var parsed = url.parse(httpReq.url, true);
var pathname = parsed.pathname || '/';
// CORS / 允许任意来源本机调试
httpRes.setHeader('Access-Control-Allow-Origin', '*');
httpRes.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
httpRes.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
if (httpReq.method === 'OPTIONS') {
httpRes.writeHead(204);
httpRes.end();
return;
}
if (pathname === '/status' && httpReq.method === 'GET') {
httpRes.writeHead(200, { 'Content-Type': 'application/json' });
httpRes.end(JSON.stringify({
server: SERVER_INFO,
protocolVersion: PROTOCOL_VERSION,
toolCount: tools.size,
resourceCount: resources.size,
stats: stats,
}));
return;
}
if (pathname === '/mcp' && httpReq.method === 'POST') {
var chunks = [];
httpReq.on('data', function (c) { chunks.push(c); });
httpReq.on('end', async function () {
var raw = Buffer.concat(chunks).toString('utf-8');
var body;
try { body = JSON.parse(raw); }
catch (e) {
httpRes.writeHead(400, { 'Content-Type': 'application/json' });
httpRes.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
return;
}
stats.requestCount++;
stats.lastRequest = {
at: new Date().toISOString(),
method: body && body.method,
};
var response = await handleJsonRpc(body);
httpRes.writeHead(response == null ? 204 : 200, { 'Content-Type': 'application/json' });
httpRes.end(response == null ? '' : JSON.stringify(response));
});
return;
}
httpRes.writeHead(404, { 'Content-Type': 'text/plain' });
httpRes.end('not found');
}
function start() {
if (started) return Promise.resolve({ port: port, host: host });
return new Promise(function (resolve, reject) {
httpServer = http.createServer(handleHttp);
httpServer.on('error', function (e) {
if (!started) reject(e);
else logger.warn('[cc-mcp] server error:', e.message);
});
httpServer.listen(port, host, function () {
started = true;
stats.startedAt = new Date().toISOString();
logger.log('[cc-mcp] MCP server listening http://' + host + ':' + port + '/mcp');
resolve({ port: port, host: host });
});
});
}
function stop() {
if (!started || !httpServer) return Promise.resolve();
return new Promise(function (resolve) {
httpServer.close(function () {
started = false;
httpServer = null;
logger.log('[cc-mcp] MCP server stopped');
resolve();
});
});
}
return {
registerTool: registerTool,
registerResource: registerResource,
start: start,
stop: stop,
get started() { return started; },
get port() { return port; },
get host() { return host; },
get toolCount() { return tools.size; },
get resourceCount() { return resources.size; },
get stats() { return stats; },
};
}
module.exports = { createServer: createServer, PROTOCOL_VERSION: PROTOCOL_VERSION };
+378
View File
@@ -0,0 +1,378 @@
'use strict';
/**
* MCP Tools 注册表
* 每个 tool 透传到对应的 Editor.Message.request 或本地 helper
* ctx 提供editor message 封装本地辅助函数
*/
function defineTools(ctx) {
var msg = ctx.msg; // async (target, name, ...args) => result
var local = ctx.local; // { getPreviewUrl, doReimport, doRefreshPreview, doOpenPreview, doScreenshot, doRefreshAssets, doReloadScene, evalInPreview, listWorktrees, openDevDir, cleanDevDir, getStatus, getPanelConfig }
return [
// ── scene 域 ──
{
name: 'scene_query_node_tree',
description: '查询当前场景节点树。传 uuid 查子树,不传查根。',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string', description: '可选:节点 uuid' } },
},
handler: async function (args) {
return await msg('scene', 'query-node-tree', args.uuid);
},
},
{
name: 'scene_query_node',
description: '查询单个节点完整 dump(含所有组件属性)',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string' } },
required: ['uuid'],
},
handler: async function (args) {
return await msg('scene', 'query-node', args.uuid);
},
},
{
name: 'scene_set_property',
description: '设置节点/组件属性。path 是 dump path(如 "position" 或 "__comps__.0.string"),dump 是 {type,value}。\n⚠️ 注意:若目的是修改 prefab 资源文件的属性,建议改用 prefab_edit(offline,不需要编辑器运行,直接读写 .prefab 文件,支持嵌套 prefab override);scene_set_property 仅适用于修改运行时场景节点或需要编辑器上下文的场景。',
inputSchema: {
type: 'object',
properties: {
uuid: { type: 'string' },
path: { type: 'string' },
dump: { type: 'object' },
},
required: ['uuid', 'path', 'dump'],
},
handler: async function (args) {
return await msg('scene', 'set-property', {
uuid: args.uuid,
path: args.path,
dump: args.dump,
});
},
},
{
name: 'scene_open_scene',
description: '打开场景',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string', description: '场景资源 uuid' } },
required: ['uuid'],
},
handler: async function (args) {
return await msg('scene', 'open-scene', args.uuid);
},
},
{
name: 'scene_save_scene',
description: '保存当前场景',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return await msg('scene', 'save-scene');
},
},
{
name: 'scene_soft_reload',
description: '软重载场景(不清编辑器状态)',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return await msg('scene', 'soft-reload');
},
},
{
name: 'scene_execute_component_method',
description: '调用指定节点组件上的方法',
inputSchema: {
type: 'object',
properties: {
uuid: { type: 'string' },
name: { type: 'string', description: '方法名' },
args: { type: 'array', items: {} },
},
required: ['uuid', 'name'],
},
handler: async function (args) {
return await msg('scene', 'execute-component-method', {
uuid: args.uuid,
name: args.name,
args: args.args || [],
});
},
},
// ── asset-db 域 ──
{
name: 'asset_query_assets',
description: '按 pattern 列出资源(glob,如 db://assets/**/*.prefab',
inputSchema: {
type: 'object',
properties: {
pattern: { type: 'string' },
ccType: { type: 'string', description: '可选:cc 类型过滤,如 cc.Prefab' },
},
},
handler: async function (args) {
return await msg('asset-db', 'query-assets', {
pattern: args.pattern,
ccType: args.ccType,
});
},
},
{
name: 'asset_query_info',
description: '按 url 或 uuid 查询资源元数据',
inputSchema: {
type: 'object',
properties: {
urlOrUUID: { type: 'string' },
},
required: ['urlOrUUID'],
},
handler: async function (args) {
return await msg('asset-db', 'query-asset-info', args.urlOrUUID);
},
},
{
name: 'asset_query_url',
description: '由 uuid 反查 url',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string' } },
required: ['uuid'],
},
handler: async function (args) {
return await msg('asset-db', 'query-url', args.uuid);
},
},
{
name: 'asset_query_uuid',
description: '由 url 反查 uuid',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
handler: async function (args) {
return await msg('asset-db', 'query-uuid', args.url);
},
},
{
name: 'asset_refresh',
description: '刷新资源(可指定子目录)',
inputSchema: {
type: 'object',
properties: { url: { type: 'string', description: '默认 db://assets/' } },
},
handler: async function (args) {
return await msg('asset-db', 'refresh-asset', args.url || 'db://assets/');
},
},
{
name: 'asset_reimport',
description: '重新导入指定资源',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
handler: async function (args) {
return await msg('asset-db', 'reimport-asset', args.url);
},
},
{
name: 'asset_create',
description: '创建资源(文本内容)',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
content: { type: 'string' },
overwrite: { type: 'boolean' },
},
required: ['url', 'content'],
},
handler: async function (args) {
return await msg('asset-db', 'create-asset', args.url, args.content, { overwrite: !!args.overwrite });
},
},
{
name: 'asset_save',
description: '保存已有资源内容',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
content: { type: 'string' },
},
required: ['url', 'content'],
},
handler: async function (args) {
return await msg('asset-db', 'save-asset', args.url, args.content);
},
},
{
name: 'asset_delete',
description: '删除资源',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
handler: async function (args) {
return await msg('asset-db', 'delete-asset', args.url);
},
},
{
name: 'asset_move',
description: '移动资源',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string' },
target: { type: 'string' },
},
required: ['source', 'target'],
},
handler: async function (args) {
return await msg('asset-db', 'move-asset', args.source, args.target);
},
},
// ── preview 域 ──
{
name: 'preview_query_url',
description: '查询当前预览地址',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return { url: await local.getPreviewUrl() };
},
},
{
name: 'preview_open_browser',
description: '在系统默认浏览器打开预览',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
await local.doOpenPreview();
return 'ok';
},
},
{
name: 'preview_refresh_browser',
description: '刷新已打开的预览浏览器页面(AppleScript 驱动 Chrome/Safari',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
await local.doRefreshPreview();
return 'ok';
},
},
{
name: 'preview_screenshot',
description: '截图预览页面到指定路径(默认 .dev/screenshot.png),返回路径',
inputSchema: {
type: 'object',
properties: { outputPath: { type: 'string' } },
},
handler: async function (args) {
var p = args.outputPath || null;
return await local.doScreenshot(p);
},
},
{
name: 'preview_eval_js',
description: '向预览 Chrome 页面注入 JS 代码并返回执行结果',
inputSchema: {
type: 'object',
properties: { code: { type: 'string' } },
required: ['code'],
},
handler: async function (args) {
return await local.evalInPreview(args.code);
},
},
{
name: 'preview_refresh_and_reload',
description: '一键:刷新资源 + 软重载场景 + 刷新预览浏览器',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
await local.doRefreshAssets();
await local.doReloadScene();
await local.doRefreshPreview();
return 'ok';
},
},
// ── local 域 ──
{
name: 'local_get_status',
description: '获取插件本地状态(git 分支/HEAD、watchers、预览、命令日志)',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return await local.getStatus();
},
},
{
name: 'local_list_worktrees',
description: '扫描同机其他 worktree 的 dev-reload-info.json',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return local.listWorktrees();
},
},
{
name: 'local_open_dev_dir',
description: '在 Finder 打开 .dev 目录',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return local.openDevDir();
},
},
{
name: 'local_clean_dev_dir',
description: '清理 .dev 临时产物',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return local.cleanDevDir();
},
},
];
}
function defineResources(ctx) {
var msg = ctx.msg;
var local = ctx.local;
return [
{
uri: 'cocos://project/info',
name: 'Project Info',
description: '项目路径、名称、引擎版本',
mimeType: 'application/json',
read: async function () {
return await local.getStatus();
},
},
{
uri: 'cocos://scene/tree',
name: 'Current Scene Tree',
description: '当前场景节点树 dump',
mimeType: 'application/json',
read: async function () {
return await msg('scene', 'query-node-tree');
},
},
{
uri: 'cocos://preview/url',
name: 'Preview URL',
description: '当前预览地址',
mimeType: 'text/plain',
read: async function () {
return await local.getPreviewUrl();
},
},
];
}
module.exports = { defineTools: defineTools, defineResources: defineResources };