diff --git a/QUICK-REF.md b/QUICK-REF.md index c1523c6..dd220b4 100644 --- a/QUICK-REF.md +++ b/QUICK-REF.md @@ -72,7 +72,7 @@ node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js | 加嵌套 prefab 实例(stub) | `add-nested-prefab`(parent + prefabUuid + name? + lpos?) | | 给 Spine 增加/更新 socket 绑定 | `add-spine-socket`(node + path + target;同 path 幂等更新 target) | | 替换嵌套 prefab 的 asset uuid(保留 stub 结构) | `replace-nested-prefab`(target + prefabUuid + clearOverrides?) | -| 删节点 | `remove-node` | +| 删节点 | `remove-node`(`target` / 旧写法 `node` 都支持;软删保留孤儿元素以稳定 `__id__`) | | 清悬空嵌套实例根(删了一半的 prefab 残留:父引用没了但根 PrefabInfo 登记还在,残留 asset 仍被加载 → 404)| `sync-nested-roots`(无参,重建根 nestedPrefabInstanceRoots) | | 复制节点 | `clone-node` | | 加组件 | `add-component` | @@ -81,7 +81,8 @@ node extensions/cc-3-8-x-mcp/cli/bin/cocos-mcp-cli.js | 给脚本 @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 挂嵌套 prefab 内子节点组件 | `set-component-ref`(refSubNode 可用节点名 `"title"` 或普通路径数组 `["content","title"]`) | +| 给脚本 @property 挂多层嵌套 stub 内组件 | `set-component-ref`(普通路径找不到时,refSubNode 字符串数组继续按多层 stub 链 `["A","B"]` 解析) | | 给脚本 @property **数组字段** 按索引挂载(`_items[0]`/`_items[1]`…) | `set-component-ref`(property 写 `"_items.0"` 或 `"_items[0]"`,多次调用各索引共存) | | 合并同节点重复组件(cli 字符串版 + 编辑器压缩版) | `dedupe-component` | diff --git a/cli/src/classid-resolver.js b/cli/src/classid-resolver.js index eb6e323..0754a67 100644 --- a/cli/src/classid-resolver.js +++ b/cli/src/classid-resolver.js @@ -50,11 +50,14 @@ function _extractCcClassNames(src) { return names; } -/** 在 assetsDir 下找所有 .ts(跳过 .d.ts),返回绝对路径数组。 */ -function _listTsFiles(assetsDir) { +/** 在 scanDirs 下找所有 .ts(跳过 .d.ts),返回绝对路径数组。 */ +function _listTsFiles(scanDirs) { + const existingDirs = scanDirs.filter((dir) => fs.existsSync(dir)); + if (existingDirs.length === 0) return []; + const roots = existingDirs.map((dir) => `"${dir.replace(/"/g, '\\"')}"`).join(' '); try { const raw = execSync( - `find "${assetsDir}" -name "*.ts" -not -name "*.d.ts" -type f`, + `find ${roots} -name "*.ts" -not -name "*.d.ts" -type f`, { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 } ); return raw.trim().split('\n').filter(Boolean); @@ -66,9 +69,11 @@ function _listTsFiles(assetsDir) { /** 建 projectRoot 下的 className -> entry 索引。 */ function _buildIndex(projectRoot) { const scriptsDir = path.join(projectRoot, 'assets', 'scripts'); - const scanDir = fs.existsSync(scriptsDir) ? scriptsDir : path.join(projectRoot, 'assets'); + const scanDirs = fs.existsSync(scriptsDir) + ? [scriptsDir, path.join(projectRoot, 'extensions')] + : [path.join(projectRoot, 'assets'), path.join(projectRoot, 'extensions')]; - const tsFiles = _listTsFiles(scanDir); + const tsFiles = _listTsFiles(scanDirs); const index = new Map(); for (const tsPath of tsFiles) { diff --git a/cli/src/editor/nested.js b/cli/src/editor/nested.js index 9049bf7..d752cbb 100644 --- a/cli/src/editor/nested.js +++ b/cli/src/editor/nested.js @@ -82,6 +82,89 @@ function getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, nod ); } +function _findNestedNodeByPath(nEls, pathParts) { + if (!Array.isArray(pathParts) || pathParts.length === 0) return null; + let currentId = null; + for (let i = 0; i < nEls.length; i++) { + const el = nEls[i]; + if (!el || el.__type__ !== 'cc.Node') continue; + if (el._parent !== null && el._parent !== undefined) continue; + if (el._name === pathParts[0]) { + currentId = i; + break; + } + } + if (currentId === null) { + for (let i = 0; i < nEls.length; i++) { + const el = nEls[i]; + if (el && el.__type__ === 'cc.Node' && el._name === pathParts[0]) { + currentId = i; + break; + } + } + } + if (currentId === null) return null; + + for (let partIdx = 1; partIdx < pathParts.length; partIdx++) { + const current = nEls[currentId]; + const children = Array.isArray(current._children) ? current._children : []; + let nextId = null; + for (const childRef of children) { + if (!childRef || typeof childRef.__id__ !== 'number') continue; + const child = nEls[childRef.__id__]; + if (child && child.__type__ === 'cc.Node' && child._name === pathParts[partIdx]) { + nextId = childRef.__id__; + break; + } + } + if (nextId === null) return null; + currentId = nextId; + } + return { node: nEls[currentId], nodeId: currentId }; +} + +function _getNestedNodeFileIdByPath(hostPrefabPath, elements, stubNodeId, pathParts) { + const { nestedPath, nestedData } = _loadNestedPrefab(hostPrefabPath, elements, stubNodeId); + const nEls = nestedData.elements; + const found = _findNestedNodeByPath(nEls, pathParts); + if (!found) { + throw new Error(`getNestedNodeFileIdByPath: 在嵌套 prefab "${nestedPath}" 中找不到路径 "${pathParts.join('/')}"`); + } + const node = found.node; + if (!node._prefab || typeof node._prefab.__id__ !== 'number') { + throw new Error(`getNestedNodeFileIdByPath: 路径 "${pathParts.join('/')}" 的节点没有 PrefabInfo`); + } + const pi = nEls[node._prefab.__id__]; + if (!pi || pi.__type__ !== 'cc.PrefabInfo' || typeof pi.fileId !== 'string' || pi.fileId.length === 0) { + throw new Error(`getNestedNodeFileIdByPath: 路径 "${pathParts.join('/')}" 的节点没有有效 fileId`); + } + return pi.fileId; +} + +function _getNestedCompFileIdByPath(hostPrefabPath, elements, stubNodeId, compType, pathParts) { + const { nestedPath, nestedData } = _loadNestedPrefab(hostPrefabPath, elements, stubNodeId); + const nEls = nestedData.elements; + const found = _findNestedNodeByPath(nEls, pathParts); + if (!found) { + throw new Error(`getNestedCompFileIdByPath: 在嵌套 prefab "${nestedPath}" 中找不到路径 "${pathParts.join('/')}"`); + } + const comps = Array.isArray(found.node._components) ? found.node._components : []; + for (const compRef of comps) { + if (!compRef || typeof compRef.__id__ !== 'number') continue; + const comp = nEls[compRef.__id__]; + if (!comp || comp.__type__ !== compType) continue; + if (!comp.__prefab || typeof comp.__prefab.__id__ !== 'number') continue; + const cpi = nEls[comp.__prefab.__id__]; + if (!cpi || cpi.__type__ !== 'cc.CompPrefabInfo') continue; + if (typeof cpi.fileId !== 'string' || cpi.fileId.length === 0) continue; + return cpi.fileId; + } + throw new Error( + `getNestedCompFileIdByPath: 嵌套 prefab "${nestedPath}" 的路径 "${pathParts.join('/')}" ` + + `找不到 ${compType} 组件,或该组件没有 cc.CompPrefabInfo.fileId。` + ); +} + // ─── 嵌套 prefab:找目标节点的 PrefabInfo.fileId ────────────── /** @@ -267,6 +350,15 @@ function resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, sub return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode[0]); } + try { + if (compType === 'cc.Node') { + return [_getNestedNodeFileIdByPath(hostPrefabPath, elements, stubNodeId, subNode)]; + } + return [_getNestedCompFileIdByPath(hostPrefabPath, elements, stubNodeId, compType, subNode)]; + } catch (_) { + // 不是普通子节点路径时,继续沿用旧语义:数组表示多层 nested stub 链。 + } + // 多层:从当前 stub 进入第一层嵌套,找名字 = subNode[0] 的内嵌 stub, // 拿到它在嵌套 prefab 内的 fileId,递归走剩下的路径 const [firstSeg, ...restPath] = subNode; @@ -382,6 +474,8 @@ function addRootTargetOverride(prefabData, rootId, sourceCompId, propertyPath, t if (!ti || !Array.isArray(ti.localID)) continue; if (ti.localID.length !== localIdChain.length) continue; if (ti.localID.every((v, i) => v === localIdChain[i])) return; + ti.localID = localIdChain.slice(); + return; } const targetInfoId = elements.length; diff --git a/cli/src/editor/op-schema.js b/cli/src/editor/op-schema.js index 5f872fa..5f63b06 100644 --- a/cli/src/editor/op-schema.js +++ b/cli/src/editor/op-schema.js @@ -112,7 +112,7 @@ const SCHEMAS = { 'reorder-children': { required: ['node', 'order'], optional: [] }, // add-node 的 node 是「新节点描述对象」而非 selector 'add-node': { required: ['parent', 'node'], optional: [], typeOverrides: { node: 'object' } }, - 'remove-node': { required: ['target'], optional: [] }, + 'remove-node': { required: [], optional: ['target', 'node'] }, 'clone-node': { required: ['source', 'parent', 'name'], optional: [] }, 'add-component': { required: ['node', 'componentType'], optional: ['props'] }, 'remove-component': { required: ['node', 'componentType'], optional: [] }, diff --git a/cli/src/editor/ops/remove-node.js b/cli/src/editor/ops/remove-node.js index e371870..588a30c 100644 --- a/cli/src/editor/ops/remove-node.js +++ b/cli/src/editor/ops/remove-node.js @@ -2,6 +2,7 @@ // 并递归断开整棵子树所有节点/组件的 _parent 引用。 // 节点元素本身保留在数组(保持其他 __id__ 稳定)。 // op: { op: 'remove-node', target: string|{id:N} } +// 兼容旧文档写法:{ op: 'remove-node', node: string|{id:N} } 'use strict'; @@ -15,7 +16,10 @@ const { function execRemoveNode(prefabData, op) { const { elements, rootId } = prefabData; - const { target: targetSelector } = op; + const targetSelector = op.target !== undefined ? op.target : op.node; + if (targetSelector === undefined) { + throw new Error(`editPrefab [remove-node]: 缺少 target(兼容旧写法 node)`); + } const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'remove-node'); diff --git a/cli/test/api.test.js b/cli/test/api.test.js index c2c546f..6fb6213 100644 --- a/cli/test/api.test.js +++ b/cli/test/api.test.js @@ -17,6 +17,7 @@ const crypto = require('crypto'); const { editPrefab } = require('../src/editor/index.js'); const { parsePrefab } = require('../src/parse.js'); const { listOverrides } = require('../src/overrides.js'); +const { addRootTargetOverride, resolveLocalIdChain } = require('../src/editor/nested.js'); const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/HomeUI.prefab'); @@ -431,6 +432,27 @@ test('remove-node: 普通节点 → 父 _children 不再含引用', () => { assert.equal(orphan._parent, null, '孤儿节点 _parent 应为 null'); }); +test('remove-node: 兼容旧文档 node 字段', () => { + const tmp = path.join(os.tmpdir(), `remove-node-alias-${crypto.randomBytes(4).toString('hex')}.prefab`); + tmpFiles.push(tmp); + const data = [ + { __type__: 'cc.Prefab', data: { __id__: 1 } }, + { __type__: 'cc.Node', _name: 'Root', _parent: null, _children: [{ __id__: 2 }], _components: [], _prefab: { __id__: 3 } }, + { __type__: 'cc.Node', _name: 'toDeleteByNodeAlias', _parent: { __id__: 1 }, _children: [], _components: [], _prefab: { __id__: 4 } }, + { __type__: 'cc.PrefabInfo', root: { __id__: 1 }, asset: { __id__: 0 }, fileId: 'rootFileId', instance: null, targetOverrides: null, nestedPrefabInstanceRoots: null }, + { __type__: 'cc.PrefabInfo', root: { __id__: 2 }, asset: { __id__: 0 }, fileId: 'childFileId', instance: null, targetOverrides: null, nestedPrefabInstanceRoots: null }, + ]; + fs.writeFileSync(tmp, JSON.stringify(data)); + + editPrefab(tmp, [ + { op: 'remove-node', node: 'toDeleteByNodeAlias' }, + ]); + + const reparsed = parsePrefab(tmp); + const orphan = reparsed.elements[2]; + assert.equal(orphan._parent, null, '旧 node 字段写法也应正确删除节点'); +}); + test('remove-node: stub 子节点(mountedChildren 内) → mountedChildren 移除', () => { const tmp = cloneFixture('remove-stub-child'); tmpFiles.push(tmp); @@ -517,6 +539,67 @@ test('remove-node: 删嵌套 stub → 清根 targetOverrides 中指向它的悬 ); }); +test('set-component-ref: refSubNode 数组可定位嵌套 prefab 内普通子节点路径', () => { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'nested-path-project-')); + tmpFiles.push(path.join(project, 'assets', 'host.prefab')); + fs.writeFileSync(path.join(project, 'package.json'), '{}'); + fs.mkdirSync(path.join(project, 'assets', 'nested'), { recursive: true }); + + const nestedUuid = '11111111-2222-4333-8444-555555555555'; + const nestedPath = path.join(project, 'assets', 'nested', 'Reddot.prefab'); + const nestedData = [ + { __type__: 'cc.Prefab', data: { __id__: 1 } }, + { __type__: 'cc.Node', _name: 'Reddot', _parent: null, _children: [{ __id__: 2 }], _components: [], _prefab: { __id__: 6 } }, + { __type__: 'cc.Node', _name: 'content', _parent: { __id__: 1 }, _children: [{ __id__: 3 }], _components: [], _prefab: { __id__: 7 } }, + { __type__: 'cc.Node', _name: 'title', _parent: { __id__: 2 }, _children: [], _components: [{ __id__: 4 }], _prefab: { __id__: 8 } }, + { __type__: 'cc.Label', node: { __id__: 3 }, __prefab: { __id__: 5 } }, + { __type__: 'cc.CompPrefabInfo', fileId: 'labelTitleFileId' }, + { __type__: 'cc.PrefabInfo', root: { __id__: 1 }, asset: { __id__: 0 }, fileId: 'rootFileId', instance: null }, + { __type__: 'cc.PrefabInfo', root: { __id__: 2 }, asset: { __id__: 0 }, fileId: 'contentFileId', instance: null }, + { __type__: 'cc.PrefabInfo', root: { __id__: 3 }, asset: { __id__: 0 }, fileId: 'titleFileId', instance: null }, + ]; + fs.writeFileSync(nestedPath, JSON.stringify(nestedData)); + fs.writeFileSync(nestedPath + '.meta', JSON.stringify({ uuid: nestedUuid })); + + const hostPath = path.join(project, 'assets', 'host.prefab'); + const hostData = [ + { __type__: 'cc.Prefab', data: { __id__: 1 } }, + { __type__: 'cc.Node', _name: 'Host', _parent: null, _children: [{ __id__: 2 }], _components: [], _prefab: { __id__: 5 } }, + { __type__: 'cc.Node', _parent: { __id__: 1 }, _prefab: { __id__: 3 } }, + { __type__: 'cc.PrefabInfo', root: { __id__: 2 }, asset: { __uuid__: nestedUuid, __expectedType__: 'cc.Prefab' }, fileId: 'stubFileId', instance: { __id__: 4 } }, + { __type__: 'cc.PrefabInstance', fileId: 'instFileId', prefabRootNode: { __id__: 1 }, mountedChildren: [], mountedComponents: [], propertyOverrides: [], removedComponents: [] }, + { __type__: 'cc.PrefabInfo', root: { __id__: 1 }, asset: { __id__: 0 }, fileId: 'hostRootFileId', instance: null, targetOverrides: null, nestedPrefabInstanceRoots: [{ __id__: 2 }] }, + ]; + fs.writeFileSync(hostPath, JSON.stringify(hostData)); + + const chain = resolveLocalIdChain(hostPath, hostData, 2, 'cc.Label', ['content', 'title']); + assert.deepEqual(chain, ['labelTitleFileId'], '普通子节点路径应解析到 title 的 Label fileId'); +}); + +test('set-component-ref: 同 source/property/target 不同 localID 时覆盖旧 targetInfo', () => { + const data = [ + { __type__: 'cc.Prefab', data: { __id__: 1 } }, + { __type__: 'cc.Node', _name: 'Root', _parent: null, _children: [{ __id__: 2 }], _components: [{ __id__: 6 }], _prefab: { __id__: 3 } }, + { __type__: 'cc.Node', _name: null, _parent: { __id__: 1 }, _prefab: { __id__: 4 } }, + { __type__: 'cc.PrefabInfo', root: { __id__: 1 }, asset: { __id__: 0 }, fileId: 'rootFileId', instance: null, targetOverrides: [{ __id__: 8 }], nestedPrefabInstanceRoots: [{ __id__: 2 }] }, + { __type__: 'cc.PrefabInfo', root: { __id__: 2 }, asset: { __uuid__: 'nested' }, fileId: 'stubFileId', instance: { __id__: 5 } }, + { __type__: 'cc.PrefabInstance', fileId: 'instFileId', prefabRootNode: { __id__: 1 }, mountedChildren: [], mountedComponents: [], propertyOverrides: [], removedComponents: [] }, + { __type__: 'SomeComp', node: { __id__: 1 }, __prefab: { __id__: 7 } }, + { __type__: 'cc.CompPrefabInfo', fileId: 'sourceCompFileId' }, + { __type__: 'cc.TargetOverrideInfo', source: { __id__: 6 }, sourceInfo: null, propertyPath: ['_reddot'], target: { __id__: 2 }, targetInfo: { __id__: 9 } }, + { __type__: 'cc.TargetInfo', localID: ['oldNodeFileId'] }, + ]; + const prefabData = { elements: data }; + + addRootTargetOverride(prefabData, 1, 6, ['_reddot'], 2, ['newComponentFileId']); + + const rootPi = data[3]; + assert.equal(rootPi.targetOverrides.length, 1, '同字段覆盖不应新增重复 override'); + const override = data[rootPi.targetOverrides[0].__id__]; + const targetInfo = data[override.targetInfo.__id__]; + assert.deepEqual(targetInfo.localID, ['newComponentFileId'], 'localID 应被覆盖为新的组件 fileId'); +}); + // ─── clone-node ─────────────────────────────────────────────── test('clone-node: 整棵子树复制、所有 _parent 正确、新 fileId 不与原冲突', () => { diff --git a/cli/test/classid-resolver.test.js b/cli/test/classid-resolver.test.js new file mode 100644 index 0000000..3d47574 --- /dev/null +++ b/cli/test/classid-resolver.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const { test, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { compressUuid } = require('../src/id.js'); +const { clearCache, resolveClassIdByName } = require('../src/classid-resolver.js'); + +const tmpRoots = []; + +after(() => { + clearCache(); + for (const root of tmpRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) {} + } +}); + +test('ClassIdResolver: 扫描 extensions 下的 ccclass 脚本', () => { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'classid-ext-project-')); + tmpRoots.push(project); + + fs.writeFileSync(path.join(project, 'package.json'), '{}'); + fs.mkdirSync(path.join(project, 'assets', 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(project, 'extensions', 'cc-state-controller', 'lib'), { recursive: true }); + + const uuid = 'fca1c7d0-b8e9-4a6f-9c3d-2e5f18a04b7c'; + const tsPath = path.join(project, 'extensions', 'cc-state-controller', 'lib', 'StateController.ts'); + fs.writeFileSync(tsPath, "import { _decorator } from 'cc';\nconst { ccclass } = _decorator;\n@ccclass('StateController')\nexport class StateController {}\n"); + fs.writeFileSync(tsPath + '.meta', JSON.stringify({ uuid })); + + assert.equal(resolveClassIdByName('StateController', project), compressUuid(uuid)); +}); diff --git a/doc/cli.md b/doc/cli.md index c2bd941..cdcc05d 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -361,7 +361,7 @@ stub 节点(嵌套 prefab 实例)在 prefab JSON 里 `_name = ""`(空字 |---|---|---| | `add-node` | `parent`, `node: {name, lpos?, active?, components?, width?, height?, anchor?}` | 新增 cc.Node;parent 是 stub 时走 mountedChildren。`components: ["UITransform"]` 自动建 UITransform | | `add-spine-socket` | `node`, `path`, `target` | 给普通节点上的 `sp.Skeleton` 新增 / 更新 `sp.Skeleton.SpineSocket`。`path` 是 Spine socket path(如 `root/zk/tou2`),`target` 是绑定节点;同 path 幂等更新 target,不重复追加 | -| `remove-node` | `target` | 从父节点移除引用;元素本身保留(orphan),保持其他 `__id__` 稳定 | +| `remove-node` | `target` / `node` | 从父节点移除引用;元素本身保留(orphan),保持其他 `__id__` 稳定。`node` 是旧文档兼容字段,推荐新脚本用 `target` | | `sync-nested-roots` | (无) | 重建根 `PrefabInfo.nestedPrefabInstanceRoots`,剔除「删了一半」残留的悬空嵌套实例根(节点 `_parent` 已移除但根登记残留 → 残留嵌套 prefab 的 asset 仍被当依赖加载,运行时 404)。只重写该数组,不删 elements、不动其他 `__id__`、不产生 null 槽;被孤立的残留对象成为不可达 orphan。复用 remove-node 内部同名逻辑 | | `clone-node` | `source`, `parent`, `name` | 深拷贝整棵子树,分配新 `__id__` + 新 fileId | @@ -395,7 +395,7 @@ stub 节点(嵌套 prefab 实例)在 prefab JSON 里 `_name = ""`(空字 | `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 决定终点) | +| `refSubNode` | string \| string[] | 可选 | stub 内部子节点定位。**字符串**指定单层 stub 的子节点名;**字符串数组**优先按普通节点路径(如 `["content","title"]`)解析;找不到普通路径时再按多层嵌套 stub 链解析 | **常见拼错**:`"comp"` → `componentType`,`"ref"` → `refNode`。schema 校验会友好提示。 @@ -416,7 +416,7 @@ stub 节点(嵌套 prefab 实例)在 prefab JSON 里 `_name = ""`(空字 | 一次改一批节点(按组件类型 / 名前缀 / 正则) | `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"]`) | +| 给脚本 @property 挂引用(节点 / 组件 / 单层 stub / 内部路径 / 多层 stub) | `set-component-ref`(内部普通路径如 `refSubNode: ["content","title"]`;多层 stub 链仍用 `["A","B"]`) | | 加 / 删 / 复制节点 | `add-node` / `remove-node` / `clone-node` | | 加 / 删组件 | `add-component` / `remove-component` | | 合并重复组件 | `dedupe-component` | @@ -667,7 +667,7 @@ Cocos 编辑器反序列化时 `__type__` 可填 @ccclass 名(`"GMUI"`)或 CLI 两端防护: -1. **写入前**:`add-component` / `set-component-ref` 的 `componentType` / `refType` 自动扫 `assets/scripts` 反查 `.ts.meta` uuid,转 23 字符压缩 classId 写入。引擎类(`cc.*`/`sp.*`/`dragonBones.*`)和已压缩格式原样透传 +1. **写入前**:`add-component` / `set-component-ref` 的 `componentType` / `refType` 自动扫 `assets/scripts` 和 `extensions` 下带 `.ts.meta` 的脚本,反查 uuid 后转 23 字符压缩 classId 写入。引擎类(`cc.*`/`sp.*`/`dragonBones.*`)和已压缩格式原样透传 2. **写入后兜底**:用 `dedupe-component` op 合并已经被 round-trip 过的 prefab ### 坑 13:stub-node-field override 的 localID 用错 fileId