mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-06-13 19:26:48 +00:00
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:
+14
@@ -0,0 +1,14 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
|
||||
# 临时与本机产物
|
||||
.dev/
|
||||
.DS_Store
|
||||
*.log
|
||||
*.bak
|
||||
*.bak.*
|
||||
|
||||
# 开发过程文档 / 实验(不对外开源)
|
||||
REVIEW_*.md
|
||||
CLI_ARRAY_REF_REPORT.md
|
||||
poc/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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` |
|
||||
| 改 _color(r/g/b/a,0-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 锚点(含自动补偿 lpos,stub 也支持) | `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 prefab(root + 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` 为 null(query tree 输出里看到) | stub 节点正常现象,名字在 PrefabInstance.propertyOverrides,运行时填 |
|
||||
| `set-component-ref` 报"未挂 XXX 组件",但 add-component 刚刚加上 | `componentType` 格式不一致:add-component 传了原始 UUID,set-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` 已在写入前自动 normalize(2026-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 拿真实根 fileId(2026-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` 文件结构
|
||||
@@ -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 绕过。
|
||||
Executable
+7
@@ -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));
|
||||
@@ -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": "付饶"
|
||||
}
|
||||
@@ -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.Channel:Track → 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.ColorTrack(4 通道 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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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]
|
||||
//
|
||||
// 生成最小 prefab(root 节点 + 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×100(sp.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 的)
|
||||
// 带 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 的)
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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 importer(ver 4.0.24)
|
||||
// .json → json importer(ver 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 };
|
||||
@@ -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] = srcNode(root),其余按原 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) 修正根节点 _prefab(PrefabInfo):root 指向新根 idx 1,asset 指向 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 };
|
||||
@@ -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 };
|
||||
@@ -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 创建空白 prefab(root + 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 # 按 selector(byComponent/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 创建 .meta(v4 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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. 压缩 classId(23 字符,已规范化格式,如 '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 内每个节点都有自己的 PrefabInfo(root/__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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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.Button,btnStore 节点在主 prefab 里是 stub 代理
|
||||
// (PrefabInstance),真正的 cc.Button 组件在子 prefab StoreBtn.prefab 里。
|
||||
// 正确协议:在主 prefab root PrefabInfo.targetOverrides 里写一条
|
||||
// cc.TargetOverrideInfo,target 指向 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 PrefabInfo(rootId=${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 = [];
|
||||
}
|
||||
// 插入策略:
|
||||
// - 单字段 override(propertyPath.length === 1,如 ["_btnClose"]):插到所有
|
||||
// 数组字段 override 之前。
|
||||
// - 数组字段 override(propertyPath.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,
|
||||
};
|
||||
@@ -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 文件创建 .meta(v4 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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,149 @@
|
||||
// add-nested-prefab: 在指定父节点下嵌入一个外部 prefab 实例(stub)。
|
||||
//
|
||||
// 等效于在 Cocos 编辑器把某个 prefab 文件拖入当前 prefab 树。生成三个对象:
|
||||
// - 一个 stub cc.Node(_name/_active 留空,由子 prefab 默认或 override 决定)
|
||||
// - 一个 cc.PrefabInfo(asset.__uuid__ = prefabUuid,instance 指向 PrefabInstance)
|
||||
// - 一个 cc.PrefabInstance(prefabRootNode 指向外层 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);
|
||||
|
||||
// 分配 id:stubNode → 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.nestedPrefabInstanceRoots(cocos 加载嵌套实例的入口列表)。
|
||||
// 缺这一步运行时 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,150 @@
|
||||
// clone-node: 深拷贝 source 及其整棵子树,挂到 parent 下
|
||||
// op: { op: 'clone-node', source: string|{id:N}, parent: string|{id:N}, name: string }
|
||||
//
|
||||
// - 为每个新节点/组件分配新 __id__(push 到数组末尾)
|
||||
// - 为每个新节点和组件生成新 fileId(deterministic,种子基于 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 };
|
||||
@@ -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 的非空字段合并进 keeper(keeper 为 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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}" 是 stub,stub 子节点重排暂不支持`
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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 自身 fileId,propertyPath = [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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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?: number(transition=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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,64 @@
|
||||
// set-label: 批量设置节点上 cc.Label 的常用字段
|
||||
// op: {
|
||||
// op: 'set-label',
|
||||
// node,
|
||||
// text?: string(_string)
|
||||
// fontSize?: number
|
||||
// lineHeight?: number(0 = 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 };
|
||||
@@ -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=VERTICAL(GRID 模式)
|
||||
// constraint?: 0=NONE 1=FIXED_ROW 2=FIXED_COL(GRID 模式)
|
||||
// constraintNum?: number(constraint 对应的行数/列数)
|
||||
// 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 };
|
||||
@@ -0,0 +1,42 @@
|
||||
// set-node-color: 设置节点的 _color(cc.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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,52 @@
|
||||
// set-richtext: 批量设置节点上 cc.RichText 的常用字段
|
||||
// op: {
|
||||
// op: 'set-richtext',
|
||||
// node,
|
||||
// text?: string(_string,支持 BBCode 标签)
|
||||
// maxWidth?: number(0 = 不限制)
|
||||
// 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 编解码
|
||||
// 标准 base64(A-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 id(23 字符)。
|
||||
*
|
||||
* @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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
// ============================================================
|
||||
// CC3 Prefab PrefabInstance Override 读写(纯 CJS,零三方依赖)
|
||||
//
|
||||
// 三个已知地雷:
|
||||
// 地雷 1:stub 节点(嵌套 prefab 根节点)本身的字段写入无效。
|
||||
// 必须走 PrefabInstance.propertyOverrides,以 CCPropertyOverrideInfo
|
||||
// + TargetInfo 结构写入,才能被 Cocos 编辑器识别。
|
||||
// 地雷 2:新增嵌套 stub 节点后,宿主 prefab 根节点的 cc.PrefabInfo
|
||||
// 的 nestedPrefabInstanceRoots 必须同步追加该 stub 节点的 __id__,
|
||||
// 否则 Cocos 加载时会忽略该嵌套实例的 override。
|
||||
// 地雷 3:propertyOverride.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] === targetLocalId,propertyPath[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;
|
||||
|
||||
// 找根节点 PrefabInfo(root 指向自己的那个)
|
||||
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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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] - 渲染层,默认 33554432(UI 层)
|
||||
* @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] - 字体资产 UUID,null 使用系统字体
|
||||
* @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 资产 UUID(cc.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 内的唯一 ID(base64 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,158 @@
|
||||
// query/overrides.js — 列出 stub 节点当前所有 propertyOverrides + 关联的 root targetOverrides
|
||||
//
|
||||
// 输出:每条 override 标注落点(stub 自身节点字段 / 嵌套内某组件字段 / 嵌套内某节点字段),
|
||||
// 配合 reset-overrides op 调试/回滚。
|
||||
//
|
||||
// args:
|
||||
// - node: 节点 selector(name / 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 targetOverrides(cc.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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
# smoke test fixtures(只读副本,不提交)
|
||||
*.prefab
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 应抛出错误'
|
||||
);
|
||||
});
|
||||
@@ -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 10(PrefabInfo 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 总数应不变');
|
||||
});
|
||||
|
||||
@@ -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. EmbeddedPlayer(movieclip 子动画触发)
|
||||
|
||||
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 3(0.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
@@ -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 同格式)
|
||||
|
||||
**不适用 / 已知限制**:
|
||||
|
||||
- 多层嵌套 stub(stub 内还有 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.js(src/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 改嵌套 UITransform;compensate 时 `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 uuid,stub 走 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.Node;parent 是 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 到嵌套 prefab(stub)内部的组件
|
||||
|
||||
```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. 已知坑
|
||||
|
||||
### 坑 1:stub 节点 `_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()` 但不自调用)
|
||||
|
||||
### 坑 3:localID 链多层嵌套
|
||||
|
||||
CLI 的 `resolveLocalIdChain`(`src/editor/nested.js`)支持 `refSubNode` 字符串数组路径走多层。但每层都假定能按节点名定位 stub;不支持靠组件类型在中间层定位。极端深嵌套场景仍建议走 tools pipeline(`step-3-script/bind-prefab-components.ts`)。
|
||||
|
||||
### 坑 4:root 节点检测依赖 `_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 当前不支持,会抛错
|
||||
|
||||
### 坑 6:set-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` 字段。
|
||||
|
||||
### 坑 7:cc.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__`,无需自己处理。
|
||||
|
||||
### 坑 8:bulk-set 跳过 stub 节点
|
||||
|
||||
`bulk-set` 实现不处理 stub。要批量改 stub 内字段,先 `query --selector find` 拿 stub id 列表,再逐个 `set-nested-component-field`。
|
||||
|
||||
### 坑 9:reorder-children.order 必须含全部子节点
|
||||
|
||||
不允许只列要前置的几个、剩下的自动补尾。order 长度 ≠ _children 长度直接抛错。避免「你以为剩下的会按原序,但 CLI 默认丢掉了」这种隐式行为。
|
||||
|
||||
### 坑 10:path 选择器同名段必须消歧
|
||||
|
||||
`{path: "A/B/C"}` 走每段时如果 `A._children` 下有 ≥2 个同名 B,CLI 直接抛错并列出候选 `__id__`,**不静默取首个**。同名场景请用 `{id:N}`,或组合"父用 path、当前层用 id"。
|
||||
|
||||
### 坑 11:schema 类型校验(已加)
|
||||
|
||||
schema 校验既检查"字段拼写 + 必填存在"也检查字段类型。`width: "100"` 这种类型错跑前直接报,不会进 handler 才崩。复杂值(`value` / `props` / `selector` / `refSubNode`)走 `any`,仍由 handler 报场景错。
|
||||
|
||||
### 坑 12:className → 压缩 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
|
||||
|
||||
### 坑 13:stub-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 报告)。
|
||||
|
||||
### 坑 14:rootTargetOverrides 单字段 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-export(parsePrefab / 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 # resolveNode(name/id/path)/ findComponent / isStub / normalizeComponentType
|
||||
│ ├── nested.js # stub / 嵌套 prefab fileId 协议(多层支持)
|
||||
│ ├── id-utils.js # fileId 分配 / 子树断开 / __id__ 重映射
|
||||
│ ├── diff.js # 字段级 diff(dry-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
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# CC3 Nested Prefab 组件引用协议
|
||||
|
||||
> 针对"主 prefab 里的脚本组件 @property 字段要引用嵌套 prefab(stub 代理)内部节点/组件"这一场景,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 边界 → 新开子 map,key = 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` op(refNode 是 stub 自动走 targetOverrides) |
|
||||
| 改 stub 内部组件字段(cc.Label._string 等) | `set-nested-component-field` op |
|
||||
| 批量跨 nested 挂载(数百字段) | tools/step-3-script/bind-prefab-components.ts(stage 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)
|
||||
@@ -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.js(cc-3-8-x-mcp)
|
||||
│
|
||||
├── offline tools(via 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 tools(via 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_batch,op: 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)。
|
||||
|
||||
### 坑 2:nestedPrefabInstanceRoots 不同步导致静默数据损坏
|
||||
|
||||
根节点 `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 地雷一。
|
||||
@@ -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.Vec3(x/y/z) |
|
||||
| `_lrot` | 本地旋转,四元数 cc.Quat(x/y/z/w),w=1 表示无旋转 |
|
||||
| `_lscale` | 本地缩放,cc.Vec3,默认 (1,1,1) |
|
||||
| `_euler` | 对应 `_lrot` 的欧拉角(单位°),改旋转时两者必须同步 |
|
||||
| `_layer` | 渲染层,UI 节点固定为 `33554432`(= 1 << 25) |
|
||||
| `_active` | 是否显示(false = 隐藏,不等于 opacity=0) |
|
||||
| `_mobility` | 0=STATIC,1=STATIONARY,2=MOBILE,UI 通常 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
|
||||
}
|
||||
|
||||
// PrefabInstance(propertyOverrides 在这里)
|
||||
{
|
||||
"__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)→ TargetInfo(localID = 被嵌套 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` op(refNode 是 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.PrefabInfo(instance:null,asset:0,fileId 唯一);4. 根节点 PrefabInfo.nestedPrefabInstanceRoots 不变(普通节点不加) | 漏掉任一步编辑器会报错 |
|
||||
| **删除节点** | 1. 从父 _children 移除引用;2. 递归清理所有子节点及其组件;3. 检查是否有其他组件引用该节点(如 cc.Button._target)| 悬空 __id__ 引用会导致加载崩溃 |
|
||||
| **新增嵌套 prefab 实例** | 1. stub Node;2. cc.PrefabInfo(含 instance 引用);3. cc.PrefabInstance;4. cc.TargetInfo;5. 若干 CCPropertyOverrideInfo;6. 父节点 _children;7. 根节点 PrefabInfo.nestedPrefabInstanceRoots 加入该 stub 节点引用 | 漏掉 nestedPrefabInstanceRoots 编辑器不识别为嵌套 prefab |
|
||||
| **修改嵌套 prefab 属性** | 不改 stub 节点字段,改对应 CCPropertyOverrideInfo.value | 改 stub 节点本身的字段运行时完全无效 |
|
||||
| **新增组件** | 1. push 组件对象;2. push 对应 cc.CompPrefabInfo(fileId 唯一);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 时子组件引用丢失)。
|
||||
@@ -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.0,loopback 永远通且与网卡/环境无关。写信号文件、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 工具名前缀,需能区分不同项目)。
|
||||
* 禁 worktree(cocos 不支持同一项目多 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
// 启动 HTTP(cc-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 tool(preview_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;
|
||||
// debounce:macOS 下单次 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');
|
||||
};
|
||||
@@ -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
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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
@@ -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); });
|
||||
// 编辑器进程管理 tools(spawn/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);
|
||||
}
|
||||
|
||||
// 编辑器进程管理 tools(spawn/kill/restart/wait_ready,router 本地执行,不走转发)
|
||||
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)');
|
||||
@@ -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 全局工具)。
|
||||
*
|
||||
* 仅支持 macOS(execPath 解析按 /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 成功。
|
||||
* excludePid:restart 时传被 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 path(tree / node / find)
|
||||
// 2. prefab_edit happy path(set-active 写 tmp 文件)
|
||||
// 3. prefab_batch happy path(opsJson 文件 → 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',
|
||||
});
|
||||
},
|
||||
/必须是绝对路径/
|
||||
);
|
||||
});
|
||||
@@ -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 /mcp,body 是 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
@@ -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 };
|
||||
Reference in New Issue
Block a user