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

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

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

Pure Node.js, zero third-party dependencies. Licensed under Apache-2.0.
This commit is contained in:
furao
2026-06-06 11:33:19 +08:00
commit 14c5b00f14
96 changed files with 15855 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
// ============================================================
// editor/diff.js — dry-run 用的 elements 字段级 diff
// 输出:[{ id, type, name, changes: { 'a.b.c': [old, new] } }]
// ============================================================
'use strict';
function computeDiff(before, after) {
const out = [];
const maxLen = Math.max(before.length, after.length);
for (let i = 0; i < maxLen; i++) {
const a = before[i];
const b = after[i];
if (a === undefined && b !== undefined) {
out.push({ id: i, type: 'added', after: b });
continue;
}
if (a !== undefined && b === undefined) {
out.push({ id: i, type: 'removed', before: a });
continue;
}
const changes = {};
diffObject(a, b, '', changes);
if (Object.keys(changes).length > 0) {
out.push({
id: i,
type: b && b.__type__ ? b.__type__ : null,
name: b && b._name !== undefined ? b._name : undefined,
changes,
});
}
}
return out;
}
function diffObject(a, b, prefix, out) {
if (a === b) return;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
out[prefix || '<root>'] = [a, b];
return;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
const av = a[k];
const bv = b[k];
if (av === bv) continue;
const path = prefix ? `${prefix}.${k}` : k;
if (
typeof av === 'object' && av !== null &&
typeof bv === 'object' && bv !== null
) {
diffObject(av, bv, path, out);
} else {
out[path] = [av, bv];
}
}
}
module.exports = { computeDiff };
+206
View File
@@ -0,0 +1,206 @@
// ============================================================
// editor/helpers.js — 节点定位 / 组件查找 / 类型规范化
// 所有 op handler 共用的低层工具
// ============================================================
'use strict';
const { isCompressedClassId, compressUuid } = require('../id.js');
const { resolveClassIdByName } = require('../classid-resolver.js');
// ─── componentType 规范化 ────────────────────────────────────
//
// cli 允许 op 里用以下三种形式传 componentType
// 1. @ccclass 名(如 'MyUI'
// 2. 原始 UUID(如 '5a154a84-89a1-509a-8949-96edd6fb74a2'
// 3. 压缩 classId23 字符,已规范化格式,如 '5a154qEiaFQmolJlu3W+3Si'
//
// 但 Cocos 编辑器序列化 prefab 时会把 __type__ 规范化为压缩 classId。
// 为避免「写入字符串名/原始 UUID → 编辑器 reimport 后规范化 + 清空 refs」的坑,
// 在每个 op 的 handler 开头把 componentType 统一转成压缩 classId。
//
// 规则:
// - 空/非字符串:原样返回(让 handler 各自报参数错)
// - 以 'cc.' / 'sp.' / 'dragonBones.' 开头:引擎类,不可能是 className,原样
// - 已经是 23 字符压缩格式:原样
// - 原始 UUID 格式(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx):压缩为 classId
// - 其他(视作 @ccclass 名):扫 assets/scripts 反查 → 压缩 classId
// 找不到时**直接抛错**cocos 反序列化看到 className 字符串会报 MissingScript
// 与其降级写入留个坑不如让 cli 当场失败,告诉调用方真实原因(meta 未生成 / class 名拼错 / 没加 @ccclass)。
//
// 这确保 add-component / set-component-ref / remove-component 无论传哪种形式
// 都能 lookup 到同一 __type__ 字符串,避免同 batch 内 add+ref 类型不一致。
const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function normalizeComponentType(componentType, resolverStartPath) {
if (typeof componentType !== 'string' || componentType.length === 0) {
return componentType;
}
if (/^(cc|sp|dragonBones)\./.test(componentType)) return componentType;
if (isCompressedClassId(componentType)) return componentType;
// 原始 UUID 格式 → 压缩为 classId,与 @ccclass 名查表结果统一
if (_UUID_RE.test(componentType)) {
try { return compressUuid(componentType); } catch (_) {}
}
// 视作 @ccclass 名:必须查表得到压缩 classId,否则写入会造成 cocos MissingScript
if (!resolverStartPath) {
throw new Error(
`normalizeComponentType: className "${componentType}" 无法解析——缺少 prefab 路径用于定位项目根。` +
`\n 通常因 prefab 在项目目录外(如 /tmp/)时未传 --project-root。`
);
}
const classId = resolveClassIdByName(componentType, resolverStartPath);
if (!classId) {
throw new Error(
`normalizeComponentType: className "${componentType}" 在 assets/scripts 下找不到对应 .ts.meta。` +
`\n 常见原因:` +
`\n 1) .ts 文件刚新建,cocos 编辑器尚未生成 .ts.meta(等编辑器自动 import 后重跑);` +
`\n 2) class 未加 @ccclass('${componentType}') 装饰器;` +
`\n 3) @ccclass 参数与 className 拼写不一致。`
);
}
return classId;
}
// ─── 判断节点是否是 stub(嵌套 prefab 根节点)────────────────
function isStub(elements, node) {
if (!node || node.__type__ !== 'cc.Node') return false;
const prefabRef = node._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return false;
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return false;
const instanceRef = prefabInfo.instance;
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return false;
const instance = elements[instanceRef.__id__];
return !!(instance && instance.__type__ === 'cc.PrefabInstance');
}
// ─── 引用相等查 __id__ ───────────────────────────────────────
function indexOfNode(elements, node) {
for (let i = 0; i < elements.length; i++) {
if (elements[i] === node) return i;
}
return -1;
}
// ─── 节点定位(按名字串 或 {id:N})──────────────────────────
function resolveNode(prefabData, nodeSelector, opDesc) {
const { elements } = prefabData;
if (typeof nodeSelector === 'string') {
const node = prefabData.findNodeByName(nodeSelector);
if (!node) {
throw new Error(`editPrefab [${opDesc}]: 找不到节点 "${nodeSelector}"`);
}
const nodeId = indexOfNode(elements, node);
if (nodeId < 0) {
throw new Error(`editPrefab [${opDesc}]: 节点 "${nodeSelector}" 找到但索引失败(内部错误)`);
}
return { node, nodeId };
}
if (nodeSelector && typeof nodeSelector === 'object') {
if (typeof nodeSelector.id === 'number') {
const nodeId = nodeSelector.id;
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node') {
throw new Error(`editPrefab [${opDesc}]: __id__ ${nodeId} 不是有效 cc.Node`);
}
return { node, nodeId };
}
if (typeof nodeSelector.path === 'string' && nodeSelector.path.length > 0) {
return resolveNodeByPath(prefabData, nodeSelector.path, opDesc);
}
}
throw new Error(
`editPrefab [${opDesc}]: node 参数必须是字符串名称、{ id: N } 或 { path: 'A/B/C' },收到: ${JSON.stringify(nodeSelector)}`
);
}
// 按路径定位节点(DOM-like
// path 形如 "Canvas/Main/itemList",从根节点开始按 _name 逐级下钻
// 每段必须命中 _children 中某个节点的 _name
// 遇到 stub 节点时不下钻(stub _name 在 propertyOverrides 里,超出 cli 范围)
function resolveNodeByPath(prefabData, pathStr, opDesc) {
const { elements, rootId, getRoot } = prefabData;
const segments = pathStr.split('/').filter((s) => s.length > 0);
if (segments.length === 0) {
throw new Error(`editPrefab [${opDesc}]: path 段为空`);
}
let curId = rootId;
let cur = getRoot();
// 第一段对齐根节点名(如 "Canvas"),允许省略
if (cur._name === segments[0]) {
segments.shift();
}
for (const seg of segments) {
if (!Array.isArray(cur._children)) {
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在节点 "${cur._name}" 下没有子节点,无法继续下钻到 "${seg}"`);
}
const matches = [];
for (const cref of cur._children) {
if (typeof cref.__id__ !== 'number') continue;
const child = elements[cref.__id__];
if (child && child._name === seg) {
matches.push(cref.__id__);
}
}
if (matches.length === 0) {
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下找不到子节点 "${seg}"`);
}
if (matches.length > 1) {
// 同名子节点 path 无法消歧,强制报错而非静默取首个
throw new Error(
`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下有 ${matches.length} 个同名子节点 "${seg}"__id__: ${matches.join(', ')}),` +
`path 选择器无法消歧。请改用 {id: N} 精确定位,或对父节点用 path、对该层用 id 组合`
);
}
curId = matches[0];
cur = elements[curId];
}
return { node: cur, nodeId: curId };
}
// ─── 找节点上指定类型的组件 ──────────────────────────────────
function findComponent(elements, node, compType) {
if (!Array.isArray(node._components)) return null;
for (const compRef of node._components) {
if (typeof compRef.__id__ !== 'number') continue;
const comp = elements[compRef.__id__];
if (comp && comp.__type__ === compType) return comp;
}
return null;
}
// ─── 找根节点的 PrefabInfo(持有 nestedPrefabInstanceRoots / targetOverrides)──
function findRootPrefabInfo(elements, rootNodeId) {
// 根节点直接持有其 PrefabInfo 的引用——沿 rootNode._prefab.__id__ 跳一步即可。
// 不遍历:prefab 内每个节点都有自己的 PrefabInforoot/__id__ 均指向根节点),
// 迭代会优先命中遇到的第一个非根节点 PrefabInfo,导致 targetOverrides 写错位置。
const rootNode = elements[rootNodeId];
if (!rootNode || rootNode.__type__ !== 'cc.Node') return null;
const prefabRef = rootNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return null;
const pi = elements[prefabRef.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') return null;
if (pi.instance !== null && pi.instance !== undefined) return null;
return pi;
}
module.exports = {
normalizeComponentType,
isStub,
indexOfNode,
resolveNode,
findComponent,
findRootPrefabInfo,
};
+292
View File
@@ -0,0 +1,292 @@
// ============================================================
// editor/id-utils.js — fileId 分配 / 子树断开 / __id__ 重映射
//
// 用于 add-node / clone-node / remove-node / dedupe-component 共用:
// - fileId 唯一性(deterministic + 冲突检测)
// - 删节点时递归断开 _parent
// - 删 elements 后所有 __id__ 引用收缩
// ============================================================
'use strict';
const { createFileIdGenerator } = require('../id.js');
// ─── 收集 elements 中所有现有 fileId ─────────────────────────
/**
* 遍历 elements,收集所有 cc.PrefabInfo / cc.CompPrefabInfo / cc.PrefabInstance 的 fileId。
* @param {object[]} elements
* @returns {Set<string>}
*/
function collectExistingFileIds(elements) {
const ids = new Set();
for (const el of elements) {
if (!el) continue;
if (
(el.__type__ === 'cc.PrefabInfo' ||
el.__type__ === 'cc.CompPrefabInfo' ||
el.__type__ === 'cc.PrefabInstance') &&
typeof el.fileId === 'string' &&
el.fileId.length > 0
) {
ids.add(el.fileId);
}
}
return ids;
}
/**
* 生成不与 existingIds 冲突的 fileId。
* 先用 baseSeed 生成,若冲突则追加 #1、#2 … 直到不冲突。
* deterministic:相同 baseSeed + 相同现有集合 → 相同结果。
*/
function uniqueFileId(baseSeed, existingIds) {
let candidate = createFileIdGenerator(baseSeed)();
if (!existingIds.has(candidate)) {
existingIds.add(candidate);
return candidate;
}
let counter = 1;
while (true) {
candidate = createFileIdGenerator(`${baseSeed}#${counter}`)();
if (!existingIds.has(candidate)) {
existingIds.add(candidate);
return candidate;
}
counter++;
}
}
// ─── 断开子树(remove-node 用)────────────────────────────────
/**
* 递归断开子树中所有节点及其关联对象的 _parent 引用(置 null)。
* 元素本身保留在数组,只让它们成为真正的孤儿。
*/
function disconnectSubtree(elements, nodeId) {
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node') return;
if (Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ === 'number') {
disconnectSubtree(elements, childRef.__id__);
}
}
}
node._parent = null;
if (node._prefab && typeof node._prefab.__id__ === 'number') {
const pi = elements[node._prefab.__id__];
if (pi && pi.__type__ === 'cc.PrefabInfo') {
pi._parent = null;
if (pi.instance && typeof pi.instance.__id__ === 'number') {
const prefabInst = elements[pi.instance.__id__];
if (prefabInst && prefabInst.__type__ === 'cc.PrefabInstance') {
if (Array.isArray(prefabInst.mountedChildren)) {
for (const mcRef of prefabInst.mountedChildren) {
if (typeof mcRef.__id__ === 'number') {
disconnectSubtree(elements, mcRef.__id__);
}
}
prefabInst.mountedChildren = [];
}
prefabInst.propertyOverrides = [];
if (Array.isArray(prefabInst.mountedComponents)) {
prefabInst.mountedComponents = [];
}
pi.instance = null;
}
}
}
}
if (Array.isArray(node._components)) {
for (const compRef of node._components) {
if (typeof compRef.__id__ !== 'number') continue;
const comp = elements[compRef.__id__];
if (!comp) continue;
comp._parent = null;
if (comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
const cpi = elements[comp.__prefab.__id__];
if (cpi && cpi.__type__ === 'cc.CompPrefabInfo') {
cpi._parent = null;
}
}
}
}
}
// ─── elements 重排:__id__ 引用映射 / 收缩 ───────────────────
//
// 用于 dedupe-component:合并删除组件后,所有 __id__ 指向被删/被合并对象的
// 引用要重定向到 keeper 或按缩减后的下标 shift。
/** @property 字段非 null 计数(粗略打分,挑 keeper */
function countPropertyRefs(comp) {
let n = 0;
for (const [k, v] of Object.entries(comp)) {
if (isReservedCompField(k)) continue;
if (v === null || v === undefined) continue;
if (typeof v === 'object' || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
n++;
}
}
return n;
}
/** 合并时不触碰的核心字段 */
function isReservedCompField(key) {
return (
key === '__type__' ||
key === '_name' ||
key === '_objFlags' ||
key === '__editorExtras__' ||
key === 'node' ||
key === '_enabled' ||
key === '__prefab' ||
key === '_id'
);
}
/** 所有节点的 _components / mountedComponents 去掉指向 deleteSet 的 ref */
function filterCompRefsInElements(elements, deleteSet) {
for (const el of elements) {
if (!el || typeof el !== 'object') continue;
if (Array.isArray(el._components)) {
el._components = el._components.filter(
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
);
}
if (Array.isArray(el.mountedComponents)) {
el.mountedComponents = el.mountedComponents.filter(
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
);
}
}
}
/** 把所有 __id__ 指向 redirect.keys 的 ref 改成指向 redirect.get(...) */
function redirectIdsAcrossElements(elements, redirect) {
if (redirect.size === 0) return;
const visit = (obj) => {
if (obj === null || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
for (const v of obj) visit(v);
return;
}
if (typeof obj.__id__ === 'number' && redirect.has(obj.__id__)) {
obj.__id__ = redirect.get(obj.__id__);
}
for (const k of Object.keys(obj)) visit(obj[k]);
};
visit(elements);
}
function buildShiftMap(total, deleteSet) {
const map = new Array(total);
let removed = 0;
for (let i = 0; i < total; i++) {
if (deleteSet.has(i)) {
map[i] = null;
removed++;
} else {
map[i] = i - removed;
}
}
return map;
}
function shiftIdsAcrossElements(elements, shiftMap) {
const visit = (obj) => {
if (obj === null || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
for (const v of obj) visit(v);
return;
}
if (typeof obj.__id__ === 'number') {
const nv = shiftMap[obj.__id__];
if (nv != null) obj.__id__ = nv;
}
for (const k of Object.keys(obj)) visit(obj[k]);
};
visit(elements);
}
// ─── 清理根 PrefabInfo.targetOverrides 中悬空条目 ─────────────
//
// 从根 PrefabInfo.targetOverrides 移除 source/target 落入 removedIds 的条目。
// 被移除的 cc.TargetOverrideInfo / cc.TargetInfo 对象本身保留为孤儿
// (软删策略,保持其他 __id__ 稳定)。
//
// 调用方:
// - remove-node:删 stub 后清「外层脚本 → stub 内部组件/节点」的悬空 override
// - remove-component:删组件后清「该组件 → 嵌套 stub 内部组件/节点」的悬空 override
//
// 不传 removedIds 则不做任何事;rootId 必须传(指向根 cc.Node 在 elements 数组中的 __id__)。
function cleanupRootTargetOverrides(elements, rootId, removedIds) {
if (!removedIds || removedIds.size === 0) return;
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab || typeof rootNode._prefab.__id__ !== 'number') return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
if (!Array.isArray(rootPrefabInfo.targetOverrides)) return;
rootPrefabInfo.targetOverrides = rootPrefabInfo.targetOverrides.filter((ref) => {
if (!ref || typeof ref.__id__ !== 'number') return false;
const ov = elements[ref.__id__];
if (!ov) return false;
const t = ov.target;
const s = ov.source;
if (t && typeof t.__id__ === 'number' && removedIds.has(t.__id__)) return false;
if (s && typeof s.__id__ === 'number' && removedIds.has(s.__id__)) return false;
return true;
});
}
// ─── 同步根 PrefabInfo.nestedPrefabInstanceRoots ─────────────
//
// 重建根节点 PrefabInfo.nestedPrefabInstanceRoots = 当前所有「有父 + 有 PrefabInfo +
// PrefabInfo.instance 指向 cc.PrefabInstance」的嵌套 stub 节点 __id__。
//
// 调用方:
// - remove-node:软删 stub 后,被删节点 _parent 已置 null,扫描时自动出局,
// 其登记从 nestedPrefabInstanceRoots 剔除。
// - sync-nested-roots op:单独修「删了一半」残留的悬空嵌套实例根(父引用已被移除
// 但根 PrefabInfo 登记残留 → 残留 asset 仍被当依赖加载)。
function syncNestedRoots(elements, rootId) {
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab) return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance) continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
}
module.exports = {
collectExistingFileIds,
uniqueFileId,
disconnectSubtree,
countPropertyRefs,
isReservedCompField,
filterCompRefsInElements,
redirectIdsAcrossElements,
buildShiftMap,
shiftIdsAcrossElements,
cleanupRootTargetOverrides,
syncNestedRoots,
};
+174
View File
@@ -0,0 +1,174 @@
// ============================================================
// editor/index.js — 声明式批量编辑 prefab 主入口
//
// editPrefab(filePath, ops[], options?)
// - 内存内依次执行所有 op
// - 自动判别 stub vs 普通节点
// - 任一 op 失败抛错、不落盘
// - dryRun: 跑完不写盘,返回字段级 diff
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { writePrefab } = require('../write.js');
const { computeDiff } = require('./diff.js');
// 各 op handler
const { execSetPosition } = require('./ops/set-position.js');
const { execSetLabelText } = require('./ops/set-label-text.js');
const { execSetSpriteFrame } = require('./ops/set-sprite-frame.js');
const { execSetActive } = require('./ops/set-active.js');
const { execSetComponentField } = require('./ops/set-component-field.js');
const { execSetComponentEnabled } = require('./ops/set-component-enabled.js');
const { execSetAnchor } = require('./ops/set-anchor.js');
const { execSetSize } = require('./ops/set-size.js');
const { execAdjustPosition } = require('./ops/adjust-position.js');
const { execRenameNode } = require('./ops/rename-node.js');
const { execReparent } = require('./ops/reparent.js');
const { execReorderChildren } = require('./ops/reorder-children.js');
const { execAddNode } = require('./ops/add-node.js');
const { execRemoveNode } = require('./ops/remove-node.js');
const { execCloneNode } = require('./ops/clone-node.js');
const { execAddComponent } = require('./ops/add-component.js');
const { execRemoveComponent } = require('./ops/remove-component.js');
const { execSetComponentRef } = require('./ops/set-component-ref.js');
const { execSetNestedComponentField } = require('./ops/set-nested-component-field.js');
const { execBulkSet } = require('./ops/bulk-set.js');
const { execDedupeComponent } = require('./ops/dedupe-component.js');
const { execSetEditBox } = require('./ops/set-editbox.js');
const { execSetLabel } = require('./ops/set-label.js');
const { execSetButton } = require('./ops/set-button.js');
const { execSetLayout } = require('./ops/set-layout.js');
const { execSetRichText } = require('./ops/set-richtext.js');
const { execSetSprite } = require('./ops/set-sprite.js');
const { execSetNodeColor } = require('./ops/set-node-color.js');
const { execReplaceNestedPrefab } = require('./ops/replace-nested-prefab.js');
const { execAddNestedPrefab } = require('./ops/add-nested-prefab.js');
const { execResetOverrides } = require('./ops/reset-overrides.js');
const { execEnsureMeta } = require('./ops/ensure-meta.js');
const { execSyncNestedRoots } = require('./ops/sync-nested-roots.js');
const { validateOps } = require('./op-schema.js');
const OP_HANDLERS = {
'set-position': execSetPosition,
'set-label-text': execSetLabelText,
'set-sprite-frame': execSetSpriteFrame,
'set-active': execSetActive,
'set-component-field': execSetComponentField,
'set-component-enabled': execSetComponentEnabled,
'set-anchor': execSetAnchor,
'set-size': execSetSize,
'adjust-position': execAdjustPosition,
'rename-node': execRenameNode,
'reparent': execReparent,
'reorder-children': execReorderChildren,
'add-node': execAddNode,
'remove-node': execRemoveNode,
'clone-node': execCloneNode,
'add-component': execAddComponent,
'remove-component': execRemoveComponent,
'set-component-ref': execSetComponentRef,
'set-nested-component-field': execSetNestedComponentField,
'bulk-set': execBulkSet,
'dedupe-component': execDedupeComponent,
'set-editbox': execSetEditBox,
'set-label': execSetLabel,
'set-button': execSetButton,
'set-layout': execSetLayout,
'set-richtext': execSetRichText,
'set-sprite': execSetSprite,
'set-node-color': execSetNodeColor,
'replace-nested-prefab': execReplaceNestedPrefab,
'add-nested-prefab': execAddNestedPrefab,
'reset-overrides': execResetOverrides,
'ensure-meta': execEnsureMeta,
'sync-nested-roots': execSyncNestedRoots,
};
/**
* 声明式批量编辑 prefab
*
* @param {string} filePath prefab 文件路径(读取 + 写回同一路径)
* @param {object[]} ops op 描述数组
* @param {object} [options]
* @param {string} [options.projectRoot] 项目根目录(含 assets/),默认从 filePath 向上推断。
* @param {boolean} [options.dryRun] true 时不写盘,仅返回模拟结果(含 diff)。
* @returns {{ changed: boolean, opsApplied: number, nodesAffected: (string|number)[], dryRun?: boolean, diff?: object[] }}
*
* @throws 任一 op 失败时抛错,不落盘
*/
function editPrefab(filePath, ops, options) {
if (typeof filePath !== 'string') {
throw new Error('editPrefab: filePath 必须是字符串');
}
if (!Array.isArray(ops) || ops.length === 0) {
throw new Error('editPrefab: ops 必须是非空数组');
}
// schema 预校验:跑前发现拼错的字段(comp / ref / 拼漏 op 等),不到 handler 才报错
validateOps(ops, Object.keys(OP_HANDLERS));
const opts = options || {};
const prefabData = parsePrefab(filePath);
prefabData.resolverStartPath = opts.projectRoot || filePath;
const dryRun = !!opts.dryRun;
prefabData.dryRun = dryRun;
const beforeSnapshot = dryRun
? JSON.parse(JSON.stringify(prefabData.elements))
: null;
const affectedIds = new Set();
const affectedNames = [];
let opsApplied = 0;
for (const op of ops) {
if (!op || typeof op.op !== 'string') {
throw new Error(`editPrefab: op 格式错误(缺少 op 字段): ${JSON.stringify(op)}`);
}
const handler = OP_HANDLERS[op.op];
if (!handler) {
throw new Error(
`editPrefab: 不支持的 op 类型 "${op.op}",支持: ${Object.keys(OP_HANDLERS).join(', ')}`
);
}
const nodeId = handler(prefabData, op);
if (typeof nodeId === 'number' && nodeId >= 0) {
affectedIds.add(nodeId);
}
// bulk-set 0 匹配时返回 -1,跳过 affectedIds
opsApplied++;
}
if (!dryRun) {
writePrefab(filePath, prefabData.elements, prefabData.raw);
}
for (const id of affectedIds) {
const node = prefabData.elements[id];
if (node && node._name) {
affectedNames.push(node._name);
} else {
affectedNames.push(id);
}
}
const result = {
changed: !dryRun,
opsApplied,
nodesAffected: affectedNames,
};
if (dryRun) {
result.dryRun = true;
result.diff = computeDiff(beforeSnapshot, prefabData.elements);
}
return result;
}
module.exports = { editPrefab, OP_HANDLERS };
+440
View File
@@ -0,0 +1,440 @@
// ============================================================
// editor/nested.js — stub 节点(嵌套 prefab)相关协议
//
// 涵盖:
// - 从嵌套 prefab 反查 CompPrefabInfo.fileId / Node PrefabInfo.fileId
// - 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 override
// - cc.TargetOverrideInfo 跨 nested @property 挂载
// 协议背景见 prefab-schema.md §4 与 set-component-ref op 上方注释。
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { resolveUuidToPath } = require('../uuid-resolver.js');
const { findRootPrefabInfo } = require('./helpers.js');
// ─── 嵌套 prefab:找指定组件的 CompPrefabInfo.fileId ─────────
/**
* @param {string} hostPrefabPath 宿主 prefab 文件路径(用于 UuidResolver 推断项目根)
* @param {object[]} elements 宿主 prefab elements 数组
* @param {number} stubNodeId stub 节点的 __id__
* @param {string} compType 组件类型,如 'cc.Label' / 'cc.Sprite'
* @param {string|null} nodeName 可选:指定嵌套 prefab 内的节点名(null = 第一个匹配)
* @returns {string} CompPrefabInfo.fileId
*/
function getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, nodeName) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`getNestedCompFileId: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(
`getNestedCompFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
);
}
const nestedUuid = assetRef.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
let nestedData;
try {
nestedData = parsePrefab(nestedPath);
} catch (e) {
throw new Error(
`getNestedCompFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}: ${e.message}`
);
}
const nEls = nestedData.elements;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== compType) continue;
if (nodeName !== null && nodeName !== undefined) {
if (!el.node || typeof el.node.__id__ !== 'number') continue;
const ownerNode = nEls[el.node.__id__];
if (!ownerNode || ownerNode._name !== nodeName) continue;
}
if (!el.__prefab || typeof el.__prefab.__id__ !== 'number') continue;
const cpi = nEls[el.__prefab.__id__];
if (!cpi || cpi.__type__ !== 'cc.CompPrefabInfo') continue;
if (typeof cpi.fileId !== 'string' || cpi.fileId.length === 0) continue;
return cpi.fileId;
}
const nodeHint = nodeName ? `(节点名: "${nodeName}"` : '';
throw new Error(
`getNestedCompFileId: 在嵌套 prefab "${nestedPath}" 中找不到 ${compType} 组件${nodeHint}` +
`或该组件没有 cc.CompPrefabInfo.fileId。`
);
}
// ─── 嵌套 prefab:找目标节点的 PrefabInfo.fileId ──────────────
/**
* @param {string} hostPrefabPath 宿主 prefab 路径
* @param {object[]} elements 宿主 prefab elements
* @param {number} stubNodeId stub 节点 __id__
* @param {string|null} nodeName 目标节点名(null = 嵌套 prefab 根节点)
* @returns {string} 目标节点 cc.PrefabInfo.fileId
*/
function getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, nodeName) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`getNestedNodeFileId: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(
`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
);
}
const nestedUuid = assetRef.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
let nestedData;
try {
nestedData = parsePrefab(nestedPath);
} catch (e) {
throw new Error(
`getNestedNodeFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}: ${e.message}`
);
}
const nEls = nestedData.elements;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = nEls[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (typeof pi.fileId !== 'string' || pi.fileId.length === 0) continue;
if (nodeName === null || nodeName === undefined) {
// 根节点:_parent 为 null
if (el._parent === null || el._parent === undefined) {
return pi.fileId;
}
} else {
if (el._name === nodeName) {
return pi.fileId;
}
}
}
const nodeHint = nodeName ? `(节点名: "${nodeName}"` : '(根节点)';
throw new Error(
`getNestedNodeFileId: 在嵌套 prefab "${nestedPath}" 中找不到目标节点${nodeHint}` +
`或该节点没有 cc.PrefabInfo.fileId。`
);
}
// ─── 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 ─
/**
* 在 stub 节点的 PrefabInstance.propertyOverrides 中写入一条组件属性 override。
* TargetInfo.localID 使用 compFileId(嵌套 prefab 内该组件的 CompPrefabInfo.fileId)。
*
* @param {object} prefabData parsePrefab 返回值
* @param {number} stubNodeId stub 节点 __id__
* @param {string} compFileId 嵌套 prefab 内目标组件的 CompPrefabInfo.fileId
* @param {string[]} propertyPath 属性路径,如 ['_string']
* @param {*} value 要写入的值
*/
function setStubCompOverride(prefabData, stubNodeId, compFileId, propertyPath, value) {
const { elements } = prefabData;
const stubNode = elements[stubNodeId];
const prefabInfo = elements[stubNode._prefab.__id__];
const prefabInstance = elements[prefabInfo.instance.__id__];
if (!prefabInstance || prefabInstance.__type__ !== 'cc.PrefabInstance') {
throw new Error(`setStubCompOverride: stub ${stubNodeId} 没有有效 PrefabInstance`);
}
if (Array.isArray(prefabInstance.propertyOverrides)) {
for (const overrideRef of prefabInstance.propertyOverrides) {
if (typeof overrideRef.__id__ !== 'number') continue;
const info = elements[overrideRef.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
const tiRef = info.targetInfo;
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
const ti = elements[tiRef.__id__];
if (!ti || ti.__type__ !== 'cc.TargetInfo') continue;
if (!Array.isArray(ti.localID) || ti.localID[0] !== compFileId) continue;
if (
Array.isArray(info.propertyPath) &&
info.propertyPath.length === propertyPath.length &&
info.propertyPath.every((p, i) => p === propertyPath[i])
) {
info.value = value;
return;
}
}
}
const targetInfo = {
__type__: 'cc.TargetInfo',
localID: [compFileId],
};
const targetInfoId = elements.length;
elements.push(targetInfo);
const overrideInfo = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: targetInfoId },
propertyPath: [...propertyPath],
value,
};
const overrideInfoId = elements.length;
elements.push(overrideInfo);
if (!Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
prefabInstance.propertyOverrides.push({ __id__: overrideInfoId });
}
// ─── 跨 nested @property 挂载(cc.TargetOverrideInfo)─────────
//
// 背景:主 prefab 里 BottomView.prefab 的某个脚本组件(如 BottomView)有
// @property _btnStore: cc.ButtonbtnStore 节点在主 prefab 里是 stub 代理
// PrefabInstance),真正的 cc.Button 组件在子 prefab StoreBtn.prefab 里。
// 正确协议:在主 prefab root PrefabInfo.targetOverrides 里写一条
// cc.TargetOverrideInfotarget 指向 stub 节点,targetInfo.localID 是子
// prefab 里目标组件的 __prefab.fileId。
//
// localID 为数组支持多层 nested:每过一层 PrefabInstance 边界新开子 map
// 每个元素是该层某节点/组件的 fileId。当前 cli 实现只支持 1 层;多层场景由
// 上游 tools/step-3-script/bind-prefab-components 兜底。
/**
* 在子 prefab 里按 compType + subNode 找目标组件 / 节点 fileId
* 返回 localID 数组。支持多层嵌套:
*
* subNode = null | string → 单层(在子 prefab 根上找 compType
* subNode = ['name1', 'name2'] → 多层(每段是嵌套 stub 节点名,
* 最后一段 + compType 决定终点)
*
* 多层链:path=['A','B'], compType='cc.Label'
* = 主 prefab stub → A.prefab 内的 stub 'A' → B.prefab 内的 cc.Label
* 返回 [stub-A 在 A.prefab 内的 fileId, B.prefab 内 cc.Label 的 fileId]
* 注意每跨一层 PrefabInstance 边界,链 push 一个 fileId。
*/
function resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode) {
// 单层:subNode 为 null 或字符串
if (subNode === null || subNode === undefined || typeof subNode === 'string') {
if (compType === 'cc.Node') {
const nodeFileId = getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, subNode);
return [nodeFileId];
}
const compFileId = getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, subNode);
return [compFileId];
}
// 多层:subNode 是字符串数组(路径)
if (!Array.isArray(subNode) || !subNode.every((s) => typeof s === 'string' && s.length > 0)) {
throw new Error(`resolveLocalIdChain: subNode 必须是 null / 字符串 / 字符串数组,收到 ${JSON.stringify(subNode)}`);
}
if (subNode.length === 0) {
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, null);
}
if (subNode.length === 1) {
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode[0]);
}
// 多层:从当前 stub 进入第一层嵌套,找名字 = subNode[0] 的内嵌 stub
// 拿到它在嵌套 prefab 内的 fileId,递归走剩下的路径
const [firstSeg, ...restPath] = subNode;
const { nestedPath, nestedData } = _loadNestedPrefab(hostPrefabPath, elements, stubNodeId);
const nEls = nestedData.elements;
let innerStubId = -1;
let innerStubFileId = null;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (el._name !== firstSeg) continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const innerPi = nEls[el._prefab.__id__];
if (!innerPi || innerPi.__type__ !== 'cc.PrefabInfo') continue;
if (!innerPi.instance) continue; // 不是 stub
if (typeof innerPi.fileId !== 'string' || innerPi.fileId.length === 0) continue;
innerStubId = i;
innerStubFileId = innerPi.fileId;
break;
}
if (innerStubId < 0) {
throw new Error(
`resolveLocalIdChain: 嵌套 prefab "${nestedPath}" 中找不到名为 "${firstSeg}" 的 stub 节点`
);
}
// 递归到下一层(用嵌套 prefab 自身作为 hostPrefabPath
const innerChain = resolveLocalIdChain(nestedPath, nEls, innerStubId, compType, restPath);
return [innerStubFileId, ...innerChain];
}
/** 加载 stub 指向的嵌套 prefab,返回路径 + parsed data */
function _loadNestedPrefab(hostPrefabPath, elements, stubNodeId) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`_loadNestedPrefab: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(`_loadNestedPrefab: stub ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`);
}
const nestedPath = resolveUuidToPath(assetRef.__uuid__, hostPrefabPath);
const nestedData = parsePrefab(nestedPath);
return { nestedPath, nestedData };
}
// ─── propertyPath 数组索引 normalize ─────────────────────────────────────────
//
// Cocos 编辑器加载 prefab 时按 JSON 类型区分属性名(string)与数组索引(number)。
// 若数组索引以 string 形式写入(如 "0" 代替 0),编辑器无法匹配对应数组槽,
// TargetOverrideInfo 静默失效(inspector 显示空)。
//
// 使用方法:
// addRootTargetOverride 在写入前调用 normalizePropertyPath
// 保证任何经由字符串解析("_items.0"、"_items[0]")或直接传入的数字 string
// 都被转换为 number 类型的数组索引。
//
// 例:["_items", "0"] → ["_items", 0]
// ["_items", 0 ] → ["_items", 0] (已是 number,不变)
// ["_role" ] → ["_role" ] (无下标,不变)
function normalizePropertyPath(path) {
return path.map(function(seg) {
if (typeof seg === 'string' && /^\d+$/.test(seg)) {
return parseInt(seg, 10);
}
return seg;
});
}
/**
* 给主 prefab root PrefabInfo.targetOverrides 追加一条 cc.TargetOverrideInfo
* + cc.TargetInfo,实现跨 stub @property 挂载。
*
* @param {(string|number)[]} propertyPath 属性路径数组,普通字段如 ["_role"]
* 数组字段元素如 ["_items", 0](索引用数字而非字符串)。
* 传入字符串形式的数字索引(如 "0")会被内部自动转为 number,调用方无需预处理。
*/
function addRootTargetOverride(prefabData, rootId, sourceCompId, propertyPath, targetStubId, localIdChain) {
const { elements } = prefabData;
const rootPrefabInfo = findRootPrefabInfo(elements, rootId);
if (!rootPrefabInfo) {
throw new Error(`addRootTargetOverride: 找不到主 prefab root PrefabInforootId=${rootId}`);
}
// 确保数组索引为 number 类型(Cocos 编辑器按类型匹配,string "0" ≠ number 0
const normalizedPath = normalizePropertyPath(propertyPath);
// 幂等:已存在同 source/propertyPath/target/localID 的 override 直接返回
// 注意:dedupe key 使用完整 propertyPath 数组比对,
// 允许同一字段名但不同索引(如 ["_items",0] vs ["_items",1])共存。
const existingRefs = Array.isArray(rootPrefabInfo.targetOverrides) ? rootPrefabInfo.targetOverrides : [];
for (const r of existingRefs) {
if (typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (!ov.source || ov.source.__id__ !== sourceCompId) continue;
if (!Array.isArray(ov.propertyPath) || ov.propertyPath.length !== normalizedPath.length) continue;
if (!ov.propertyPath.every((p, i) => p === normalizedPath[i])) continue;
if (!ov.target || ov.target.__id__ !== targetStubId) continue;
const tiRef = ov.targetInfo;
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
const ti = elements[tiRef.__id__];
if (!ti || !Array.isArray(ti.localID)) continue;
if (ti.localID.length !== localIdChain.length) continue;
if (ti.localID.every((v, i) => v === localIdChain[i])) return;
}
const targetInfoId = elements.length;
elements.push({
__type__: 'cc.TargetInfo',
localID: localIdChain.slice(),
});
const overrideId = elements.length;
elements.push({
__type__: 'cc.TargetOverrideInfo',
source: { __id__: sourceCompId },
sourceInfo: null,
propertyPath: normalizedPath.slice(),
target: { __id__: targetStubId },
targetInfo: { __id__: targetInfoId },
});
if (!Array.isArray(rootPrefabInfo.targetOverrides)) {
rootPrefabInfo.targetOverrides = [];
}
// 插入策略:
// - 单字段 overridepropertyPath.length === 1,如 ["_btnClose"]):插到所有
// 数组字段 override 之前。
// - 数组字段 overridepropertyPath.length > 1,如 ["_items", 0]):追加到末尾。
//
// 为什么:Cocos 加载 prefab 时,若 rootTargetOverrides 数组里前面有数组字段
// override,后面位置的单字段 override 会被静默跳过(实测 cocos 3.8.x 行为,
// 见 forest/extensions/cc-3-8-x-mcp/doc/cli.md 坑 14)。单字段插前面规避此 bug。
const newRef = { __id__: overrideId };
const isSingleField = normalizedPath.length === 1;
if (isSingleField) {
const arr = rootPrefabInfo.targetOverrides;
let firstArrayIdx = arr.length;
for (let i = 0; i < arr.length; i++) {
const r = arr[i];
if (!r || typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (Array.isArray(ov.propertyPath) && ov.propertyPath.length > 1) {
firstArrayIdx = i;
break;
}
}
arr.splice(firstArrayIdx, 0, newRef);
} else {
rootPrefabInfo.targetOverrides.push(newRef);
}
}
module.exports = {
getNestedCompFileId,
getNestedNodeFileId,
setStubCompOverride,
resolveLocalIdChain,
addRootTargetOverride,
normalizePropertyPath,
};
+239
View File
@@ -0,0 +1,239 @@
// ============================================================
// editor/op-schema.js — ops 跑前 schema 校验
//
// 价值:
// - 字段拼错(comp / ref / propery)一次性报齐,不用一条条 op 跑到才发现
// - 未知 op 类型 / 必填字段缺失,跑前就报,避免部分写入后回滚浪费时间
// - 字段类型错(`width: "100"`)跑前就报,避免运行时崩
//
// 校验粒度:必填字段名 + 已知字段拼写白名单 + 字段类型;
// 业务约束(值域、互斥)留给 handler(更易给出场景化错误信息)
// ============================================================
'use strict';
// 类型令牌:
// 'number' | 'string' | 'boolean' | 'object' | 'array' | 'any'
// 'node-selector' — 字符串 / 数字 / { id, path } 三选一
// 'string|array' — property 类支持嵌套路径数组
// 'string|object' — refSubNode 支持字符串或字符串数组(这里只做粗校验)
//
// 'any' 不做类型断言(覆盖 value / props 一类 raw JSON)。
const T = {
node: 'node-selector',
parent: 'node-selector',
target: 'node-selector',
source: 'node-selector',
refNode: 'node-selector',
componentType: 'string',
property: 'string|array',
refType: 'string',
refSubNode: 'any', // string | string[]
value: 'any',
props: 'object',
selector: 'object',
order: 'array',
text: 'string',
name: 'string',
uuid: 'string',
prefabUuid: 'string',
active: 'boolean',
enabled: 'boolean',
compensatePosition: 'boolean',
clearOverrides: 'boolean',
bold: 'boolean',
italic: 'boolean',
underline: 'boolean',
enableWrapText: 'boolean',
grayscale: 'boolean',
trim: 'boolean',
affectedByScale: 'boolean',
interactable: 'boolean',
all: 'boolean',
x: 'number',
y: 'number',
z: 'number',
dx: 'number',
dy: 'number',
dz: 'number',
width: 'number',
height: 'number',
r: 'number',
g: 'number',
b: 'number',
a: 'number',
fontSize: 'number',
lineHeight: 'number',
maxWidth: 'number',
maxLength: 'number',
inputMode: 'number',
inputFlag: 'number',
zoomScale: 'number',
duration: 'number',
type: 'number',
sizeMode: 'number',
overflow: 'number',
horizontalAlign: 'number',
verticalAlign: 'number',
transition: 'number',
resizeMode: 'number',
paddingLeft: 'number',
paddingRight: 'number',
paddingTop: 'number',
paddingBottom: 'number',
spacingX: 'number',
spacingY: 'number',
startAxis: 'number',
constraint: 'number',
constraintNum: 'number',
placeholder: 'string',
string: 'string',
labelNode: 'string',
spriteNode: 'string',
subNode: 'any', // string | string[]
};
// 每个 op 的字段白名单(含必填 + 可选;'op' 隐含必填)
// typeOverrides:对全局 T 表的字段类型做局部覆盖(同名字段在不同 op 里语义不同时用)
const SCHEMAS = {
'set-position': { required: ['node', 'x', 'y'], optional: ['z'] },
'set-label-text': { required: ['node', 'text'], optional: ['labelNode'] },
'set-sprite-frame': { required: ['node', 'uuid'], optional: ['spriteNode'] },
'set-active': { required: ['node', 'active'], optional: [] },
'set-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: [] },
'set-component-enabled': { required: ['node', 'componentType', 'enabled'], optional: ['subNode'] },
'set-anchor': { required: ['node'], optional: ['x', 'y', 'compensatePosition'] },
'set-size': { required: ['node'], optional: ['width', 'height'] },
'adjust-position': { required: ['node'], optional: ['dx', 'dy', 'dz'] },
'rename-node': { required: ['node', 'name'], optional: [] },
// reparent: 把节点搬到另一个父节点下(不复制;普通 inline 节点;自带循环检测)
'reparent': { required: ['node', 'parent'], optional: ['index'] },
'reorder-children': { required: ['node', 'order'], optional: [] },
// add-node 的 node 是「新节点描述对象」而非 selector
'add-node': { required: ['parent', 'node'], optional: [], typeOverrides: { node: 'object' } },
'remove-node': { required: ['target'], optional: [] },
'clone-node': { required: ['source', 'parent', 'name'], optional: [] },
'add-component': { required: ['node', 'componentType'], optional: ['props'] },
'remove-component': { required: ['node', 'componentType'], optional: [] },
'set-component-ref': { required: ['node', 'componentType', 'property', 'refNode'], optional: ['refType', 'refSubNode'] },
'set-nested-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: ['subNode'] },
// bulk-set 的 target 是 "node" 或 "component:<type>" 字符串模式,不是 selector
'bulk-set': { required: ['selector', 'target', 'property', 'value'], optional: [], typeOverrides: { target: 'string' } },
'dedupe-component': { required: [], optional: ['node'] },
'set-editbox': { required: ['node'], optional: ['inputMode', 'maxLength', 'placeholder', 'string', 'inputFlag', 'fontSize'] },
'set-label': { required: ['node'], optional: ['text', 'fontSize', 'lineHeight', 'overflow', 'horizontalAlign', 'verticalAlign', 'bold', 'italic', 'underline', 'enableWrapText'] },
'set-button': { required: ['node'], optional: ['interactable', 'transition', 'zoomScale', 'duration'] },
'set-layout': { required: ['node'], optional: ['type', 'resizeMode', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'spacingX', 'spacingY', 'startAxis', 'constraint', 'constraintNum', 'affectedByScale'] },
'set-richtext': { required: ['node'], optional: ['text', 'maxWidth', 'fontSize', 'lineHeight'] },
'set-sprite': { required: ['node'], optional: ['sizeMode', 'type', 'grayscale', 'trim'] },
'set-node-color': { required: ['node'], optional: ['r', 'g', 'b', 'a'] },
'replace-nested-prefab': { required: ['target', 'prefabUuid'], optional: ['clearOverrides'] },
'add-nested-prefab': { required: ['parent', 'prefabUuid'], optional: ['name', 'lpos'] },
'reset-overrides': { required: ['node'], optional: ['property', 'componentType', 'subNode', 'all'] },
// ensure-meta: 给 .ts/.json 文件创建 .metav4 uuid),让后续 className → classId 查表能命中
'ensure-meta': { required: ['path'], optional: [], typeOverrides: { path: 'string' } },
'sync-nested-roots': { required: [], optional: [] },
};
// 已知拼错 → 正确字段映射(友好提示)
const COMMON_TYPOS = {
'comp': 'componentType',
'compType': 'componentType',
'ref': 'refNode',
'propery': 'property',
'val': 'value',
'newName': 'name',
'nodeName': 'node',
};
function _formatType(token) {
switch (token) {
case 'node-selector': return '字符串/数字/{id}/{path}';
case 'string|array': return '字符串或数组';
default: return token;
}
}
function _checkType(value, token) {
if (token === 'any') return true;
if (token === 'node-selector') {
if (typeof value === 'string') return value.length > 0;
if (typeof value === 'number') return Number.isInteger(value) && value >= 0;
if (value && typeof value === 'object' && !Array.isArray(value)) {
return typeof value.id === 'number' || typeof value.path === 'string';
}
return false;
}
if (token === 'string|array') {
return typeof value === 'string' || Array.isArray(value);
}
if (token === 'array') return Array.isArray(value);
if (token === 'object') {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
return typeof value === token; // number / string / boolean
}
function validateOps(ops, knownOpTypes) {
const errors = [];
for (let i = 0; i < ops.length; i++) {
const op = ops[i];
const prefix = `ops[${i}]`;
if (!op || typeof op !== 'object' || Array.isArray(op)) {
errors.push(`${prefix}: 不是对象`);
continue;
}
if (typeof op.op !== 'string') {
errors.push(`${prefix}: 缺 'op' 字段`);
continue;
}
if (!knownOpTypes.includes(op.op)) {
errors.push(`${prefix}: 不支持的 op 类型 "${op.op}",已知: ${knownOpTypes.join(', ')}`);
continue;
}
const schema = SCHEMAS[op.op];
if (!schema) continue; // 没登记 schema 的 op 跳过
const known = new Set(['op', ...schema.required, ...schema.optional]);
// 必填检查
for (const r of schema.required) {
if (!(r in op)) {
errors.push(`${prefix} (${op.op}): 缺必填字段 "${r}"`);
}
}
// 多余字段 + 类型检查
for (const k of Object.keys(op)) {
if (k === 'op') continue;
if (!known.has(k)) {
const suggest = COMMON_TYPOS[k];
if (suggest && known.has(suggest)) {
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",可能想写 "${suggest}"`);
} else {
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",已知: ${[...known].join(', ')}`);
}
continue;
}
// 类型检查(only 在 T 中登记的字段;未登记的留给 handler)
// op 的 typeOverrides 优先级高于全局 T
const token = (schema.typeOverrides && schema.typeOverrides[k]) || T[k];
if (!token) continue;
if (!_checkType(op[k], token)) {
const got = Array.isArray(op[k]) ? 'array' : (op[k] === null ? 'null' : typeof op[k]);
errors.push(
`${prefix} (${op.op}): 字段 "${k}" 类型应为 ${_formatType(token)},实际是 ${got}(值: ${JSON.stringify(op[k])}`
);
}
}
}
if (errors.length > 0) {
throw new Error(`editPrefab: ops schema 校验失败:\n ${errors.join('\n ')}`);
}
}
module.exports = { validateOps, SCHEMAS };
+75
View File
@@ -0,0 +1,75 @@
// add-component: 在节点 _components 数组里加一个指向指定 ccclass 的组件条目
// + 配套的 cc.CompPrefabInfo(含 deterministic fileId
// op: { op: 'add-component', node, componentType, props? }
//
// - componentType: 组件 ccclass 名(如 'TaskBtn' / 'cc.Sprite'
// - props: 可选,初始 @property 字段值,会浅合并到组件对象上
//
// 限制:
// - stub 节点暂不支持
// - 同节点同类型组件已存在时抛错
'use strict';
const { ref, makeCompPrefabInfo } = require('../../primitives.js');
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execAddComponent(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawComponentType, props } = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [add-component]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'add-component');
if (isStub(elements, node)) {
throw new Error(`editPrefab [add-component]: stub 节点挂自定义组件暂未实现(需 PrefabInstance.mountedComponents`);
}
if (findComponent(elements, node, componentType)) {
throw new Error(`editPrefab [add-component]: 节点 "${node._name}" 已挂 "${componentType}" 组件`);
}
let seed = 'unknown';
if (node._prefab && typeof node._prefab.__id__ === 'number') {
const pi = elements[node._prefab.__id__];
if (pi && pi.fileId) seed = pi.fileId;
}
const existingFileIds = collectExistingFileIds(elements);
const compFileId = uniqueFileId(`${seed}#addComp#${componentType}`, existingFileIds);
const compId = elements.length;
const cpiId = compId + 1;
const compObj = Object.assign(
{
__type__: componentType,
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
__prefab: ref(cpiId),
_id: '',
},
props && typeof props === 'object' ? props : {}
);
const cpiObj = makeCompPrefabInfo(compFileId);
elements.push(compObj);
elements.push(cpiObj);
if (!Array.isArray(node._components)) {
node._components = [];
}
node._components.push(ref(compId));
return nodeId;
}
module.exports = { execAddComponent };
+149
View File
@@ -0,0 +1,149 @@
// add-nested-prefab: 在指定父节点下嵌入一个外部 prefab 实例(stub)。
//
// 等效于在 Cocos 编辑器把某个 prefab 文件拖入当前 prefab 树。生成三个对象:
// - 一个 stub cc.Node_name/_active 留空,由子 prefab 默认或 override 决定)
// - 一个 cc.PrefabInfoasset.__uuid__ = prefabUuidinstance 指向 PrefabInstance
// - 一个 cc.PrefabInstanceprefabRootNode 指向外层 prefab 根 = rootId
//
// 可选 name / lpos 通过 propertyOverrides 写到 PrefabInstance 上(targetInfo.localID
// 用子 prefab 内根节点的 PrefabInfo.fileId,需读外部 prefab 文件解析)。
//
// op: { op: 'add-nested-prefab', parent: string|{id:N}, prefabUuid: string, name?: string, lpos?: [x,y,z] }
//
// 协议背景:参 doc/nested-prefab-protocol.md;与 replace-nested-prefab 互补
// (replace 替换 asset uuid 不动节点结构,add 是从零生成嵌套实例)。
'use strict';
const { resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execAddNestedPrefab(prefabData, op) {
const { elements, rootId } = prefabData;
const { parent: parentSelector, prefabUuid, name, lpos } = op;
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
throw new Error(`editPrefab [add-nested-prefab]: prefabUuid 必须是非空字符串`);
}
const cleanUuid = prefabUuid.trim();
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-nested-prefab');
// 父 prefab fileId 作 deterministic 种子
let parentFileId = 'unknown';
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
const parentPi = elements[parentNode._prefab.__id__];
if (parentPi && parentPi.fileId) parentFileId = parentPi.fileId;
}
const existingFileIds = collectExistingFileIds(elements);
const baseSeed = `${parentFileId}#addNested#${cleanUuid}#${name ?? ''}`;
const stubFileId = uniqueFileId(baseSeed, existingFileIds);
const instanceFileId = uniqueFileId(`${baseSeed}#instance`, existingFileIds);
// 分配 idstubNode → prefabInfo → prefabInstance → [TargetInfo + OverrideInfo] × N
const stubNodeId = elements.length;
const prefabInfoId = stubNodeId + 1;
const instanceId = stubNodeId + 2;
let nextId = instanceId + 1;
const propertyOverrideRefs = [];
const overrideElements = [];
// PropertyOverride 的 targetInfo.localID 用 stub 自己在外层 prefab 内的 PrefabInfo.fileId
// (而不是子 prefab 内根节点 fileId)。CC3 协议:targetInfo 定位 override 应用的「目标对象」,
// 对于 stub Node 自己的 _name/_lpos 这类字段,目标对象就是 stub 在外层 prefab 内的标识。
function pushOverride(propertyPath, value) {
const tiId = nextId++;
const oiId = nextId++;
overrideElements.push({
__type__: 'cc.TargetInfo',
localID: [stubFileId],
});
overrideElements.push({
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: tiId },
propertyPath,
value,
});
propertyOverrideRefs.push({ __id__: oiId });
}
if (name !== undefined) pushOverride(['_name'], name);
if (lpos !== undefined) {
pushOverride(['_lpos'], {
__type__: 'cc.Vec3',
x: lpos[0] || 0,
y: lpos[1] || 0,
z: lpos[2] || 0,
});
}
const stubNode = {
__type__: 'cc.Node',
_objFlags: 0,
_parent: { __id__: parentId },
_prefab: { __id__: prefabInfoId },
__editorExtras__: {},
};
const stubPrefabInfo = {
__type__: 'cc.PrefabInfo',
root: { __id__: stubNodeId },
asset: { __uuid__: cleanUuid, __expectedType__: 'cc.Prefab' },
fileId: stubFileId,
instance: { __id__: instanceId },
targetOverrides: null,
};
const prefabInstance = {
__type__: 'cc.PrefabInstance',
fileId: instanceFileId,
prefabRootNode: { __id__: rootId },
mountedChildren: [],
mountedComponents: [],
propertyOverrides: propertyOverrideRefs,
removedComponents: [],
};
elements.push(stubNode);
elements.push(stubPrefabInfo);
elements.push(prefabInstance);
for (const o of overrideElements) elements.push(o);
if (!Array.isArray(parentNode._children)) parentNode._children = [];
parentNode._children.push({ __id__: stubNodeId });
// 同步外层 prefab 根 PrefabInfo.nestedPrefabInstanceRootscocos 加载嵌套实例的入口列表)。
// 缺这一步运行时 stub 节点不会被解析渲染,子 prefab 内容看不到。
syncNestedRoots(elements, rootId);
return stubNodeId;
}
/**
* 重建外层 prefab 根 PrefabInfo.nestedPrefabInstanceRoots,包含所有 _parent 非 null 的活 stub 节点。
* 软删(remove-node)留下的孤儿 stub 自动排除。
*/
function syncNestedRoots(elements, rootId) {
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab) return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance) continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
}
module.exports = { execAddNestedPrefab };
+120
View File
@@ -0,0 +1,120 @@
// add-node: 在指定父节点下新增一个 cc.Node
// op: { op: 'add-node', parent: string|{id:N}, node: { name, lpos?, components? } }
//
// 支持:
// - 普通父节点:新节点进入 parent._children
// - stub 父节点(嵌套 prefab 实例):新节点进入 PrefabInstance.mountedChildren
// 若 node.components 包含 'UITransform',自动创建 cc.UITransform(默认 100×100
'use strict';
const { ref, makeNode, makePrefabInfo, makeCompPrefabInfo, makeUITransform } = require('../../primitives.js');
const { isStub, resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
const SUPPORTED_COMPONENTS = ['UITransform'];
function execAddNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { parent: parentSelector, node: nodeSpec } = op;
if (!nodeSpec || typeof nodeSpec.name !== 'string') {
throw new Error(`editPrefab [add-node]: node.name 必须是字符串`);
}
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-node');
if (Array.isArray(nodeSpec.components)) {
for (const comp of nodeSpec.components) {
if (typeof comp === 'string' && !SUPPORTED_COMPONENTS.includes(comp)) {
throw new Error(
`editPrefab [add-node]: unknown component type: ${comp}(已支持: ${SUPPORTED_COMPONENTS.join(', ')}`
);
}
}
}
const newNodeId = elements.length;
// 父节点 fileId 用作 deterministic 种子
let parentFileId = 'unknown';
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
const parentPrefabInfo = elements[parentNode._prefab.__id__];
if (parentPrefabInfo && parentPrefabInfo.fileId) {
parentFileId = parentPrefabInfo.fileId;
}
}
const baseSeed = `${parentFileId}#addNode#${nodeSpec.name}`;
const existingFileIds = collectExistingFileIds(elements);
const nodeFileId = uniqueFileId(baseSeed, existingFileIds);
const uitFileId = uniqueFileId(`${baseSeed}#uit`, existingFileIds);
const prefabInfoId = newNodeId + 1;
let componentIds = [];
const newObjects = [];
if (Array.isArray(nodeSpec.components) && nodeSpec.components.includes('UITransform')) {
const uitId = newNodeId + 2;
const uitPrefabInfoId = newNodeId + 3;
componentIds = [uitId];
const uitObj = makeUITransform({
nodeId: newNodeId,
width: nodeSpec.width || 100,
height: nodeSpec.height || 100,
anchor: nodeSpec.anchor || [0.5, 0.5],
prefabInfoId: uitPrefabInfoId,
});
const uitCpi = makeCompPrefabInfo(uitFileId);
newObjects.push(uitObj);
newObjects.push(uitCpi);
}
const lpos = nodeSpec.lpos || [0, 0, 0];
const newNodeObj = makeNode({
name: nodeSpec.name,
pos: lpos,
active: nodeSpec.active !== undefined ? nodeSpec.active : true,
parentId,
childIds: [],
componentIds,
prefabId: prefabInfoId,
});
const newPrefabInfoObj = makePrefabInfo({
rootId,
fileId: nodeFileId,
assetId: 0,
nestedPrefabInstanceRoots: null,
});
elements.push(newNodeObj);
elements.push(newPrefabInfoObj);
for (const o of newObjects) elements.push(o);
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (!Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = [];
}
prefabInstance.mountedChildren.push({ __id__: newNodeId });
} else {
if (!Array.isArray(parentNode._children)) {
parentNode._children = [];
}
parentNode._children.push({ __id__: newNodeId });
}
// ref 在此模块虽然没直接用,但保留 import 以便上层调试时一致;
// 实际节点对象的子引用全在 makeNode/makePrefabInfo/makeUITransform 内部生成。
void ref;
return newNodeId;
}
module.exports = { execAddNode };
+41
View File
@@ -0,0 +1,41 @@
// adjust-position: lpos 相对偏移
// op: { op:'adjust-position', node, dx?, dy?, dz? }
//
// 适合"在原位置基础上挪 N 像素"场景,免去先 query 取原值。
// 任一轴缺省视为 0。stub 节点走 setOverrideProperty,与 set-position 一致。
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execAdjustPosition(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, dx = 0, dy = 0, dz = 0 } = op;
if (typeof dx !== 'number' || typeof dy !== 'number' || typeof dz !== 'number') {
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 必须是数字`);
}
if (dx === 0 && dy === 0 && dz === 0) {
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 至少一个非零`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'adjust-position');
const lpos = node._lpos || { x: 0, y: 0, z: 0 };
const newLpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: (lpos.z || 0) + dz,
};
if (isStub(elements, node)) {
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
} else {
node._lpos = newLpos;
}
return nodeId;
}
module.exports = { execAdjustPosition };
+112
View File
@@ -0,0 +1,112 @@
// bulk-set: 按 selector 找一批节点,统一改字段(一条 op 顶 N 条)
// op: { op:'bulk-set', selector, target, property, value }
//
// selector:节点筛选条件
// { byComponent: 'cc.Label' } → 所有挂 cc.Label 的节点
// { byNamePrefix: 'btn' } → 所有 _name 以 'btn' 开头的节点
// { byNameRegex: '^icon_\\d+$' } → 正则匹配
// 多条件并存为 AND
//
// target:要改的对象层
// 'node' → 改节点字段,如 _active / _name
// 'component:<T>' → 改节点上 type=T 的组件字段(每个匹配节点都得有这个组件,否则跳过)
//
// property:字符串或字符串数组(嵌套路径)
// value:写入值
//
// 行为:
// - 匹配 0 个不算错(返回 [] 但 opsApplied 仍 +1
// - stub 节点跳过(bulk-set 不处理 stub,避免不同代码路径混用)
// - 返回所有受影响的 nodeId 数组(editPrefab 主循环会聚合到 affectedNodes
'use strict';
const { isStub, findComponent } = require('../helpers.js');
function _matchSelector(elements, node, selector) {
if (selector.byComponent) {
if (!findComponent(elements, node, selector.byComponent)) return false;
}
if (selector.byNamePrefix) {
if (typeof node._name !== 'string' || !node._name.startsWith(selector.byNamePrefix)) return false;
}
if (selector.byNameRegex) {
if (typeof node._name !== 'string') return false;
const re = new RegExp(selector.byNameRegex);
if (!re.test(node._name)) return false;
}
return true;
}
function _setNested(obj, path, value) {
if (typeof path === 'string') {
obj[path] = value;
return;
}
let cur = obj;
for (let i = 0; i < path.length - 1; i++) {
const k = path[i];
if (cur[k] === null || cur[k] === undefined || typeof cur[k] !== 'object') {
throw new Error(
`bulk-set: 路径 ${path.slice(0, i + 1).join('.')} 不是对象(${JSON.stringify(cur[k])}),无法继续下钻`
);
}
cur = cur[k];
}
cur[path[path.length - 1]] = value;
}
function execBulkSet(prefabData, op) {
const { elements } = prefabData;
const { selector, target, property, value } = op;
if (!selector || typeof selector !== 'object' || Object.keys(selector).length === 0) {
throw new Error(`editPrefab [bulk-set]: selector 必须是非空对象`);
}
if (typeof target !== 'string' || target.length === 0) {
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>'`);
}
if (
!(typeof property === 'string' && property.length > 0) &&
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string'))
) {
throw new Error(`editPrefab [bulk-set]: property 必须是非空字符串或字符串数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [bulk-set]: value 不能是 undefined`);
}
let targetKind = target;
let targetCompType = null;
if (target.startsWith('component:')) {
targetCompType = target.slice('component:'.length);
if (targetCompType.length === 0) {
throw new Error(`editPrefab [bulk-set]: target='component:' 后必须跟组件类型`);
}
targetKind = 'component';
} else if (target !== 'node') {
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>',收到 "${target}"`);
}
const affected = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (isStub(elements, el)) continue;
if (!_matchSelector(elements, el, selector)) continue;
if (targetKind === 'node') {
_setNested(el, property, value);
} else {
const comp = findComponent(elements, el, targetCompType);
if (!comp) continue; // 节点匹配但没这个组件,跳过
_setNested(comp, property, value);
}
affected.push(i);
}
// 至少返回一个 id 让 affectedNodes 不报错(即使 0 匹配也不算 op fail)
return affected.length > 0 ? affected[0] : -1;
}
module.exports = { execBulkSet };
+150
View File
@@ -0,0 +1,150 @@
// clone-node: 深拷贝 source 及其整棵子树,挂到 parent 下
// op: { op: 'clone-node', source: string|{id:N}, parent: string|{id:N}, name: string }
//
// - 为每个新节点/组件分配新 __id__(push 到数组末尾)
// - 为每个新节点和组件生成新 fileIddeterministic,种子基于 source fileId + newName
// - 更新所有内部 _parent 引用指向新副本
// - 新树挂到 parent._children(若 parent 是 stub 则走 mountedChildren
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execCloneNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { source: sourceSelector, parent: parentSelector, name: newName } = op;
if (typeof newName !== 'string') {
throw new Error(`editPrefab [clone-node]: name 必须是字符串`);
}
const { node: sourceNode, nodeId: sourceId } = resolveNode(prefabData, sourceSelector, 'clone-node');
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'clone-node');
const oldToNew = new Map();
function collectSubtreeNodeIds(nodeId) {
const ids = [nodeId];
const node = elements[nodeId];
if (node && Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ === 'number') {
ids.push(...collectSubtreeNodeIds(childRef.__id__));
}
}
}
return ids;
}
const subtreeNodeIds = collectSubtreeNodeIds(sourceId);
const allSourceIds = [];
for (const nid of subtreeNodeIds) {
allSourceIds.push(nid);
const n = elements[nid];
if (!n) continue;
if (n._prefab && typeof n._prefab.__id__ === 'number') {
allSourceIds.push(n._prefab.__id__);
}
if (Array.isArray(n._components)) {
for (const cRef of n._components) {
if (typeof cRef.__id__ === 'number') {
const compId = cRef.__id__;
allSourceIds.push(compId);
const comp = elements[compId];
if (comp && comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
allSourceIds.push(comp.__prefab.__id__);
}
}
}
}
}
const uniqueSourceIds = [...new Set(allSourceIds)];
const insertStart = elements.length;
for (let i = 0; i < uniqueSourceIds.length; i++) {
oldToNew.set(uniqueSourceIds[i], insertStart + i);
elements.push(null);
}
let sourceFileId = 'unknown';
if (sourceNode._prefab && typeof sourceNode._prefab.__id__ === 'number') {
const srcPInfo = elements[sourceNode._prefab.__id__];
if (srcPInfo && srcPInfo.fileId) sourceFileId = srcPInfo.fileId;
}
const cloneBaseSeed = `${sourceFileId}#clone#${newName}`;
const cloneExistingFileIds = collectExistingFileIds(elements);
let cloneGenCounter = 0;
function cloneGen() {
const subSeed = `${cloneBaseSeed}#slot${cloneGenCounter++}`;
return uniqueFileId(subSeed, cloneExistingFileIds);
}
function cloneObj(obj) {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(cloneObj);
if (typeof obj.__id__ === 'number') {
const newId = oldToNew.get(obj.__id__);
if (newId !== undefined) return { __id__: newId };
return { ...obj };
}
const result = {};
for (const k of Object.keys(obj)) {
result[k] = cloneObj(obj[k]);
}
return result;
}
for (const oldId of uniqueSourceIds) {
const newId = oldToNew.get(oldId);
const srcObj = elements[oldId];
if (!srcObj) {
elements[newId] = null;
continue;
}
const cloned = cloneObj(srcObj);
if (cloned.__type__ === 'cc.PrefabInfo') {
cloned.fileId = cloneGen();
cloned.root = { __id__: rootId };
cloned.asset = { __id__: 0 };
cloned.instance = null;
cloned.targetOverrides = null;
cloned.nestedPrefabInstanceRoots = null;
}
if (cloned.__type__ === 'cc.CompPrefabInfo') {
cloned.fileId = cloneGen();
}
elements[newId] = cloned;
}
const newRootId = oldToNew.get(sourceId);
const newRootNode = elements[newRootId];
newRootNode._name = newName;
newRootNode._parent = { __id__: parentId };
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (!Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = [];
}
prefabInstance.mountedChildren.push({ __id__: newRootId });
} else {
if (!Array.isArray(parentNode._children)) {
parentNode._children = [];
}
parentNode._children.push({ __id__: newRootId });
}
return newRootId;
}
module.exports = { execCloneNode };
+132
View File
@@ -0,0 +1,132 @@
// dedupe-component: 合并同节点上同语义但重复挂载的组件条目
//
// 背景:cli 若用 className 写入 __type__(如 "GMUI"),而 Cocos 编辑器 reimport
// 时会把 __type__ 规范化为压缩 classId(如 "a57b6RRA21B5I70mCpu1pBP"),
// 在 TS 脚本尚未注册时 @property refs 会被丢弃,造成同节点出现「字符串版 +
// 压缩版」两份组件,其中一份 refs 完整、另一份全 null。本 op 把它们合并成一条。
//
// op: { op: 'dedupe-component', node? }
// - node: 仅扫指定节点;缺省 → 扫整个 prefab 所有普通节点
//
// 策略:
// 1. 按 normalizeComponentType() 后的 compType 分组
// 2. 同 compType >=2 命中时,选非空 @property 字段最多的作为 keeper
// 3. 把 losers 的非空字段合并进 keeperkeeper 为 null/undefined 才填)
// 4. keeper.__type__ 写成规范化后的 compType
// 5. losers 的 comp idx 和 __prefab CompPrefabInfo idx 进入删除集
// 6. _components 数组过滤被删的引用
// 7. 其他 elements 里 __id__ 指向被删组件的引用映射到 keeper 新 id
// 8. 全部 __id__ 按缩减后的索引重映射 + splice 实际删除
// 限制:stub 节点暂不处理。
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const {
countPropertyRefs,
isReservedCompField,
filterCompRefsInElements,
redirectIdsAcrossElements,
buildShiftMap,
shiftIdsAcrossElements,
} = require('../id-utils.js');
function execDedupeComponent(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op || {};
// ── 1. 决定扫哪些节点
const targets = [];
if (nodeSelector == null) {
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el && el.__type__ === 'cc.Node') targets.push({ node: el, nodeId: i });
}
} else {
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'dedupe-component');
targets.push({ node, nodeId });
}
// ── 2. 逐节点分组,找到所有要合并的 group
const merges = [];
const affectedNodes = new Set();
for (const { node, nodeId } of targets) {
if (isStub(elements, node)) continue;
if (!Array.isArray(node._components)) continue;
const groups = new Map();
for (const cref of node._components) {
if (!cref || typeof cref.__id__ !== 'number') continue;
const comp = elements[cref.__id__];
if (!comp || typeof comp.__type__ !== 'string') continue;
const normalized = normalizeComponentType(comp.__type__, prefabData.resolverStartPath);
if (!groups.has(normalized)) groups.set(normalized, []);
groups.get(normalized).push({ compId: cref.__id__, comp });
}
for (const [normalized, list] of groups.entries()) {
if (list.length < 2) continue;
const scored = list.map((x) => ({ ...x, score: countPropertyRefs(x.comp) }));
scored.sort((a, b) => b.score - a.score || a.compId - b.compId);
const keeper = scored[0];
const losers = scored.slice(1);
for (const loser of losers) {
for (const [k, v] of Object.entries(loser.comp)) {
if (isReservedCompField(k)) continue;
if ((keeper.comp[k] === null || keeper.comp[k] === undefined) && v !== null && v !== undefined) {
keeper.comp[k] = v;
}
}
}
keeper.comp.__type__ = normalized;
merges.push({
keeperCompId: keeper.compId,
loserCompIds: losers.map((x) => x.compId),
normalizedType: normalized,
nodeId,
});
affectedNodes.add(nodeId);
}
}
if (merges.length === 0) return [];
// ── 3. 收集要删除的 elements id 与「loser→keeper」重定向
const deleteSet = new Set();
const redirect = new Map();
for (const m of merges) {
for (const loserId of m.loserCompIds) {
deleteSet.add(loserId);
redirect.set(loserId, m.keeperCompId);
const loserComp = elements[loserId];
const pref = loserComp && loserComp.__prefab;
if (pref && typeof pref.__id__ === 'number') {
deleteSet.add(pref.__id__);
}
}
}
// ── 4. 先把所有节点的 _components / mountedComponents 数组过滤掉被删的引用
filterCompRefsInElements(elements, deleteSet);
// ── 5. __id__ 重定向(loser → keeper
redirectIdsAcrossElements(elements, redirect);
// ── 6. 构建 shift 映射 + 执行删除
const shiftMap = buildShiftMap(elements.length, deleteSet);
shiftIdsAcrossElements(elements, shiftMap);
const sortedToDel = Array.from(deleteSet).sort((a, b) => b - a);
for (const idx of sortedToDel) elements.splice(idx, 1);
// ── 7. 更新 prefabData.rootId
if (typeof prefabData.rootId === 'number' && shiftMap[prefabData.rootId] != null) {
prefabData.rootId = shiftMap[prefabData.rootId];
}
return Array.from(affectedNodes).map((id) => shiftMap[id] ?? id);
}
module.exports = { execDedupeComponent };
+109
View File
@@ -0,0 +1,109 @@
// ensure-meta: 给指定 .ts / .json 文件创建 .meta(如果不存在)
// op: { op:'ensure-meta', path }
//
// 用途:新建 .ts / .ctrl.json 后 cocos 编辑器尚未生成 .meta,但 cli 后续要用
// className → classId 查表(add-component 等)。这时在 add-component 之前插一条
// ensure-meta,主动写一个标准 .meta(v4 uuid + 按扩展名选模板),让 cli 当场能查到表,
// 而不必等 cocos 编辑器异步 import。
//
// 路径规则:path 是绝对路径,或相对项目根(如 'assets/scripts/.../X.ts')。
// 已存在 .meta 时幂等不动。
// dry-run 时不写盘(让 --dry-run 语义一致)。
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { clearCache } = require('../../classid-resolver.js');
function _v4Uuid() {
const b = crypto.randomBytes(16);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = b.toString('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
}
const _META_TEMPLATES = {
'.ts': (uuid) => ({
ver: '4.0.24',
importer: 'typescript',
imported: true,
uuid,
files: [],
subMetas: {},
userData: { simulateGlobals: [] },
}),
'.json': (uuid) => ({
ver: '2.0.1',
importer: 'json',
imported: true,
uuid,
files: ['.json'],
subMetas: {},
userData: {},
}),
};
function _resolveProjectRoot(startPath) {
let dir = fs.statSync(startPath).isDirectory() ? startPath : path.dirname(startPath);
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, 'package.json')) && fs.existsSync(path.join(dir, 'assets'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function execEnsureMeta(prefabData, op) {
if (typeof op.path !== 'string' || op.path.length === 0) {
throw new Error("ensure-meta: 缺必填字段 'path'");
}
let filePath = op.path;
if (!path.isAbsolute(filePath)) {
const projectRoot = _resolveProjectRoot(prefabData.resolverStartPath);
if (!projectRoot) {
throw new Error(
`ensure-meta: 无法定位项目根(含 assets/+package.json),请用绝对 path`
);
}
filePath = path.resolve(projectRoot, op.path);
}
if (!fs.existsSync(filePath)) {
throw new Error(`ensure-meta: 文件不存在: ${filePath}`);
}
const metaPath = filePath + '.meta';
if (fs.existsSync(metaPath)) {
// 幂等:已存在不动
return -1;
}
const ext = path.extname(filePath).toLowerCase();
const template = _META_TEMPLATES[ext];
if (!template) {
throw new Error(
`ensure-meta: 不支持的文件扩展名 "${ext}"(当前支持: ${Object.keys(_META_TEMPLATES).join(' / ')}`
);
}
if (prefabData.dryRun) {
// dry-run 模式不落盘
return -1;
}
const meta = template(_v4Uuid());
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf8');
// 同 batch 后续 op(如 add-component)会调 resolveClassIdByName 查表,
// resolver 有进程内 cache,必须 invalidate 让下次重扫覆盖新建的 meta
clearCache();
return -1;
}
module.exports = { execEnsureMeta };
+78
View File
@@ -0,0 +1,78 @@
// remove-component: 从普通节点 `_components` 数组移除指定组件的引用,
// 组件元素本身保留为 orphan(保持其他 __id__ 稳定,与 remove-node 同策略)。
// 关联的 cc.CompPrefabInfo 也随之 orphan(它只被 component._ _prefab 引用)。
//
// 同步清根 PrefabInfo.targetOverrides 中 source 指向被删组件的悬空条目:
// 外层脚本通过 targetOverride 把嵌套 stub 内部组件/节点挂到自己 @property 字段时,
// 删组件后这些 override 仍被根 PrefabInfo 引用 → 可达悬空引用 → cocos 解析时
// 反序列化 source.__id__ 触发 missing-class 报错。
//
// op: { op: 'remove-component', node, componentType }
//
// 不支持 stub 节点:嵌套 prefab 的组件由子 prefab 拥有,外层无法删除,
// 只能 set-component-enabled 禁用。stub 上调用本 op 会抛错。
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const { cleanupRootTargetOverrides } = require('../id-utils.js');
function execRemoveComponent(prefabData, op) {
const { elements, rootId } = prefabData;
const { node: nodeSelector, componentType: rawCompType } = op;
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
throw new Error(`editPrefab [remove-component]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'remove-component');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 是 stub(嵌套 prefab 根),无法删除其内部组件;改用 set-component-enabled 禁用`
);
}
if (!Array.isArray(node._components) || node._components.length === 0) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 没有 _components 数组`
);
}
let matchedCompId = -1;
const next = [];
for (const ref of node._components) {
if (!ref || typeof ref.__id__ !== 'number') {
next.push(ref);
continue;
}
const comp = elements[ref.__id__];
if (matchedCompId < 0 && comp && comp.__type__ === componentType) {
matchedCompId = ref.__id__;
continue; // 丢弃这条引用
}
next.push(ref);
}
if (matchedCompId < 0) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 上找不到 ${rawCompType} 组件`
);
}
node._components = next;
// 收集被删组件相关 __id__:组件本身 + 它的 cc.CompPrefabInfo__prefab 字段)。
// targetOverride 的 source 一般指向组件本身;带上 CompPrefabInfo 为防御性兜底。
const removedIds = new Set([matchedCompId]);
const matchedComp = elements[matchedCompId];
if (matchedComp && matchedComp.__prefab && typeof matchedComp.__prefab.__id__ === 'number') {
removedIds.add(matchedComp.__prefab.__id__);
}
cleanupRootTargetOverrides(elements, rootId, removedIds);
return nodeId;
}
module.exports = { execRemoveComponent };
+98
View File
@@ -0,0 +1,98 @@
// remove-node: 从父 _children(或 stub 的 mountedChildren)移除节点引用,
// 并递归断开整棵子树所有节点/组件的 _parent 引用。
// 节点元素本身保留在数组(保持其他 __id__ 稳定)。
// op: { op: 'remove-node', target: string|{id:N} }
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
const { disconnectSubtree, cleanupRootTargetOverrides, syncNestedRoots } = require('../id-utils.js');
function execRemoveNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { target: targetSelector } = op;
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'remove-node');
if (!targetNode._parent || typeof targetNode._parent.__id__ !== 'number') {
throw new Error(`editPrefab [remove-node]: 目标节点没有父节点,无法移除(根节点不能删除)`);
}
const parentId = targetNode._parent.__id__;
const parentNode = elements[parentId];
if (!parentNode || parentNode.__type__ !== 'cc.Node') {
throw new Error(`editPrefab [remove-node]: 父节点 __id__=${parentId} 不是有效 cc.Node`);
}
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = prefabInstance.mountedChildren.filter(
(r) => r.__id__ !== targetId
);
}
} else {
if (Array.isArray(parentNode._children)) {
parentNode._children = parentNode._children.filter(
(r) => r.__id__ !== targetId
);
}
}
// 收集整棵子树的所有 __id__(节点/组件/PrefabInfo/PrefabInstance)。
// 必须在 disconnectSubtree 之前——后者会清空 mountedChildren、置 pi.instance=null
// 之后就拿不到嵌套实例的关联对象了。
const subtreeIds = collectSubtreeIds(elements, targetId);
disconnectSubtree(elements, targetId);
// 软删后同步外层 PrefabInfo.nestedPrefabInstanceRoots,清掉孤儿 stub 引用
syncNestedRoots(elements, rootId);
// 清掉根 PrefabInfo.targetOverrides 中 source/target 指向被删子树的悬空条目。
// 外层脚本对嵌套 stub 内部组件/节点的引用(如 _passScoreView → scoreView)走 targetOverride
// 删了 stub 后这条 override 仍被根 PrefabInfo 引用 → 可达悬空引用,运行时解析会报错。
cleanupRootTargetOverrides(elements, rootId, subtreeIds);
return targetId;
}
// 收集子树所有相关 __id__:节点、其组件、_prefab(PrefabInfo)、instance(PrefabInstance)、
// 以及 mountedChildren 指向的嵌套子树。供 targetOverride 悬空判断用。
function collectSubtreeIds(elements, nodeId, acc) {
acc = acc || new Set();
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node' || acc.has(nodeId)) return acc;
acc.add(nodeId);
if (Array.isArray(node._children)) {
for (const c of node._children) {
if (c && typeof c.__id__ === 'number') collectSubtreeIds(elements, c.__id__, acc);
}
}
if (node._prefab && typeof node._prefab.__id__ === 'number') {
acc.add(node._prefab.__id__);
const pi = elements[node._prefab.__id__];
if (pi && pi.instance && typeof pi.instance.__id__ === 'number') {
acc.add(pi.instance.__id__);
const inst = elements[pi.instance.__id__];
if (inst && Array.isArray(inst.mountedChildren)) {
for (const mc of inst.mountedChildren) {
if (mc && typeof mc.__id__ === 'number') collectSubtreeIds(elements, mc.__id__, acc);
}
}
}
}
if (Array.isArray(node._components)) {
for (const c of node._components) {
if (c && typeof c.__id__ === 'number') acc.add(c.__id__);
}
}
return acc;
}
module.exports = { execRemoveNode };
+32
View File
@@ -0,0 +1,32 @@
// rename-node: 改节点 _name
// op: { op:'rename-node', node, name }
//
// 普通节点:直接改 node._name
// stub 节点:name 存在 PrefabInstance.propertyOverrides 而不是 node._name
// 走 setOverrideProperty(['_name']) 与 set-active 同模式
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execRenameNode(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, name } = op;
if (typeof name !== 'string' || name.length === 0) {
throw new Error(`editPrefab [rename-node]: name 必须是非空字符串`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'rename-node');
if (isStub(elements, node)) {
setOverrideProperty(prefabData, nodeId, ['_name'], name);
} else {
node._name = name;
}
return nodeId;
}
module.exports = { execRenameNode };
+75
View File
@@ -0,0 +1,75 @@
// reorder-children: 调整节点的 _children 顺序(影响 UI 渲染层级)
// op: { op:'reorder-children', node, order }
//
// order:子节点名字数组(必须包含全部 _children 的 name),按这个顺序重排
// 或 __id__ 数组:[{id:N}, {id:M}, ...]
//
// stub 节点暂不支持(mountedChildren 顺序场景少见)
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
function execReorderChildren(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, order } = op;
if (!Array.isArray(order) || order.length === 0) {
throw new Error(`editPrefab [reorder-children]: order 必须是非空数组`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reorder-children');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [reorder-children]: 节点 "${node._name}" 是 stubstub 子节点重排暂不支持`
);
}
if (!Array.isArray(node._children)) {
throw new Error(`editPrefab [reorder-children]: 节点 "${node._name}" 没有 _children`);
}
const childMap = new Map(); // key (name 或 id) -> child ref
for (const cref of node._children) {
if (typeof cref.__id__ !== 'number') continue;
const child = elements[cref.__id__];
if (!child) continue;
childMap.set(cref.__id__, cref);
if (typeof child._name === 'string' && child._name.length > 0) {
childMap.set(child._name, cref);
}
}
if (order.length !== node._children.length) {
throw new Error(
`editPrefab [reorder-children]: order 长度 ${order.length} ≠ _children 长度 ${node._children.length}(必须包含所有子节点)`
);
}
const newChildren = [];
const seen = new Set();
for (const item of order) {
let key;
if (typeof item === 'string') {
key = item;
} else if (item && typeof item.id === 'number') {
key = item.id;
} else {
throw new Error(`editPrefab [reorder-children]: order 元素必须是字符串名或 {id:N},收到 ${JSON.stringify(item)}`);
}
const ref = childMap.get(key);
if (!ref) {
throw new Error(`editPrefab [reorder-children]: order 中的 "${key}" 不在 _children 内`);
}
if (seen.has(ref.__id__)) {
throw new Error(`editPrefab [reorder-children]: order 中重复出现 "${key}"`);
}
seen.add(ref.__id__);
newChildren.push(ref);
}
node._children = newChildren;
return nodeId;
}
module.exports = { execReorderChildren };
+91
View File
@@ -0,0 +1,91 @@
// reparent: 把节点从原父节点下移到新父节点下(不复制,原节点搬家)
// op: { op:'reparent', node, parent, index? }
//
// 行为:
// 1. 从原 parent._children 数组里移除 node 引用
// 2. 把 node 引用 push 到新 parent._children(或按 index 插入指定位置)
// 3. 改 node._parent 指向新 parent
//
// 限制:
// - 不支持 stub 节点(嵌套 prefab 实例)作为 source 或 target
// stub 的父子关系存在 PrefabInstance.mountedChildren / nestedPrefabInstanceRoots
// 需要独立的 nested-reparent op,本 op 仅处理普通 inline 节点
// - node 不能是 prefab 根节点(rootId=1),根节点 _parent 必须为 null
// - parent 不能是 node 的后代(避免循环)
// - 不修改 PrefabInfo.fileId(节点身份不变,外部引用仍然有效)
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
/** node 是否是 parent(或其后代)的祖先 → 循环检测 */
function isAncestorOf(elements, ancestorId, candidateId) {
let cur = candidateId;
let safety = 0;
while (cur != null && safety++ < 10000) {
if (cur === ancestorId) return true;
const node = elements[cur];
if (!node || !node._parent) return false;
cur = node._parent.__id__;
}
return false;
}
function execReparent(prefabData, op) {
const { elements, rootId } = prefabData;
const { node: nodeSelector, parent: parentSelector, index } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reparent');
const { node: newParent, nodeId: newParentId } = resolveNode(prefabData, parentSelector, 'reparent');
// 根节点不能搬家
if (nodeId === rootId) {
throw new Error(`editPrefab [reparent]: 根节点(id=${rootId})不能 reparent,其 _parent 必须为 null`);
}
// stub 检查
if (isStub(elements, node)) {
throw new Error(`editPrefab [reparent]: source 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
}
if (isStub(elements, newParent)) {
throw new Error(`editPrefab [reparent]: target parent 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
}
// 同一节点不动
const oldParentId = node._parent ? node._parent.__id__ : null;
if (oldParentId === newParentId) {
// 仅 index 调整 → 走 reorder-children 更清晰;这里允许只换位(reorder 调整)
if (index === undefined) return nodeId;
}
// 循环检测:newParent 不能是 node 的后代
if (isAncestorOf(elements, nodeId, newParentId)) {
throw new Error(`editPrefab [reparent]: 循环引用——新父节点(id=${newParentId})是源节点(id=${nodeId})的后代`);
}
// 1. 从原 parent._children 移除
if (oldParentId != null) {
const oldParent = elements[oldParentId];
if (oldParent && Array.isArray(oldParent._children)) {
oldParent._children = oldParent._children.filter(
(c) => !c || c.__id__ !== nodeId
);
}
}
// 2. 加到新 parent._children
if (!Array.isArray(newParent._children)) newParent._children = [];
const ref = { __id__: nodeId };
if (typeof index === 'number' && index >= 0 && index < newParent._children.length) {
newParent._children.splice(index, 0, ref);
} else {
newParent._children.push(ref);
}
// 3. 改 node._parent
node._parent = { __id__: newParentId };
return nodeId;
}
module.exports = { execReparent };
@@ -0,0 +1,57 @@
// replace-nested-prefab: 替换 stub 节点(嵌套 prefab 实例)引用的外部 prefab asset。
// 改 PrefabInfo.asset.__uuid__;可选清空 PrefabInstance.propertyOverrides。
//
// 适用场景:
// 想把 ListItem.prefab 里某个嵌套子 prefab 从 OldPrefab 换成 NewPrefab,但
// 保留 stub 节点的父子关系、_prefab fileId 不变(即 ListItem 内的 __id__
// 引用稳定)。
//
// 注意:
// - propertyOverrides 里的 targetFileId 是按老 prefab 内部 fileId 写的,新
// prefab 通常没有对应 fileId。默认保留 overrides(编辑器加载时 skip 找不
// 到的 override,不报错);clearOverrides=true 显式清空更干净。
// - 不修改 PrefabInstance.fileId(这个是 stub 在外层 prefab 内的稳定标识,
// 跟外部 prefab 的 fileId 无关)。
//
// op: { op: 'replace-nested-prefab', target: string|{id:N}, prefabUuid: string, clearOverrides?: boolean }
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
function execReplaceNestedPrefab(prefabData, op) {
const { elements } = prefabData;
const { target: targetSelector, prefabUuid, clearOverrides } = op;
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
throw new Error(`editPrefab [replace-nested-prefab]: prefabUuid 必须是非空字符串`);
}
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'replace-nested-prefab');
if (!isStub(elements, targetNode)) {
throw new Error(`editPrefab [replace-nested-prefab]: 目标节点 [${targetId}] 不是嵌套 prefab stub(无 _prefab.instance`);
}
if (!targetNode._prefab || typeof targetNode._prefab.__id__ !== 'number') {
throw new Error(`editPrefab [replace-nested-prefab]: stub 节点 [${targetId}] 没有 _prefab 引用`);
}
const prefabInfo = elements[targetNode._prefab.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`editPrefab [replace-nested-prefab]: _prefab 指向的不是 cc.PrefabInfo`);
}
if (!prefabInfo.asset || typeof prefabInfo.asset !== 'object') {
throw new Error(`editPrefab [replace-nested-prefab]: PrefabInfo 缺 asset 字段`);
}
prefabInfo.asset.__uuid__ = prefabUuid.trim();
if (clearOverrides === true && prefabInfo.instance && typeof prefabInfo.instance.__id__ === 'number') {
const prefabInstance = elements[prefabInfo.instance.__id__];
if (prefabInstance && Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
}
}
module.exports = { execReplaceNestedPrefab };
+127
View File
@@ -0,0 +1,127 @@
// reset-overrides: 清除 stub 节点的 propertyOverrides(回滚到嵌套 prefab 默认值)
// op: { op:'reset-overrides', node, property?, componentType?, subNode?, all? }
//
// 调用形态:
// 1) all=true:清空 stub 的整个 propertyOverrides 数组(一键回滚)
// 不能同时指定 property / componentType
// 2) property(无 componentType):清匹配 stub 节点字段 override
// target = stub 自身 fileIdpropertyPath = [property]
// 常见字段 _lpos / _name / _active / _lscale 等
// 3) property + componentType:清嵌套内某组件字段 override
// subNode 用于嵌套 prefab 内同类型组件消歧(同 set-nested-component-field
//
// 移除的 CCPropertyOverrideInfo / TargetInfo 作为 orphan 留在 elements
// 保持其他 __id__ 稳定(与 remove-node / remove-component 同策略)。
//
// 幂等:未找到匹配 override 不报错(缺省静默,CC3_MCP_DEBUG=1 时打 warn)。
'use strict';
const { isStub, resolveNode, normalizeComponentType } = require('../helpers.js');
const { getNestedCompFileId, getNestedNodeFileId } = require('../nested.js');
function execResetOverrides(prefabData, op) {
const { elements } = prefabData;
const {
node: nodeSelector,
property,
componentType: rawComponentType,
subNode = null,
all = false,
} = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reset-overrides');
if (!isStub(elements, node)) {
throw new Error(
`editPrefab [reset-overrides]: 节点 "${node._name || nodeId}" 不是 stub,普通节点没有 propertyOverrides`
);
}
const prefabInfo = elements[node._prefab.__id__];
const prefabInstance = elements[prefabInfo.instance.__id__];
const stubFileId = prefabInfo.fileId;
// 模式 1:清空全部
if (all) {
if (property !== undefined || rawComponentType !== undefined) {
throw new Error(
`editPrefab [reset-overrides]: all=true 时禁止同时提供 property / componentType`
);
}
if (Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
return nodeId;
}
// 模式 2/3:按 propertyPath 匹配单条
if (property === undefined) {
throw new Error(
`editPrefab [reset-overrides]: 必须提供 property,或显式 all=true 清空全部`
);
}
if (typeof property !== 'string' && !Array.isArray(property)) {
throw new Error(`editPrefab [reset-overrides]: property 必须是字符串或数组`);
}
const propertyPath = Array.isArray(property) ? property : [property];
// 决定要匹配的 localID[0]
// 节点字段 override(无 componentType):嵌套 prefab 内根节点 fileId 是 Cocos 运行时
// 实际识别的 key(见 overrides.js 地雷 3)。
let targetFileId;
if (rawComponentType) {
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
targetFileId = getNestedCompFileId(prefabData.resolverStartPath, elements, nodeId, componentType, subNode);
} else {
if (subNode !== null && subNode !== undefined) {
throw new Error(
`editPrefab [reset-overrides]: subNode 必须与 componentType 一起用(节点字段 override 无嵌套子节点定位)`
);
}
targetFileId = getNestedNodeFileId(prefabData.resolverStartPath, elements, nodeId, null);
}
if (!Array.isArray(prefabInstance.propertyOverrides) || prefabInstance.propertyOverrides.length === 0) {
return nodeId; // 无 override 数组,幂等返回
}
const remaining = [];
let removed = 0;
for (const ref of prefabInstance.propertyOverrides) {
if (!ref || typeof ref.__id__ !== 'number') {
remaining.push(ref);
continue;
}
const info = elements[ref.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') {
remaining.push(ref);
continue;
}
const tiRef = info.targetInfo;
const ti = tiRef && typeof tiRef.__id__ === 'number' ? elements[tiRef.__id__] : null;
if (!ti || !Array.isArray(ti.localID) || ti.localID[0] !== targetFileId) {
remaining.push(ref);
continue;
}
if (!Array.isArray(info.propertyPath) || info.propertyPath.length !== propertyPath.length) {
remaining.push(ref);
continue;
}
if (info.propertyPath.every((p, i) => p === propertyPath[i])) {
removed++;
continue;
}
remaining.push(ref);
}
if (removed === 0 && process.env.CC3_MCP_DEBUG) {
console.warn(
`[reset-overrides] stub [${nodeId}]: 未找到匹配 propertyPath=${JSON.stringify(propertyPath)} target=${targetFileId} 的 override(无操作)`
);
}
prefabInstance.propertyOverrides = remaining;
return nodeId;
}
module.exports = { execResetOverrides };
+28
View File
@@ -0,0 +1,28 @@
// set-active: 设置节点 _active
// op: { op, node, active }
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execSetActive(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, active } = op;
if (typeof active !== 'boolean') {
throw new Error(`editPrefab [set-active]: active 必须是布尔值`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-active');
if (isStub(elements, node)) {
setOverrideProperty(prefabData, id, ['_active'], active);
} else {
node._active = active;
}
return id;
}
module.exports = { execSetActive };
+117
View File
@@ -0,0 +1,117 @@
// set-anchor: cc.UITransform 锚点便捷写法 + 自动补偿 lpos
// op: { op:'set-anchor', node, x?, y?, compensatePosition? }
//
// - x / y 为新 anchor 值(0~1),任一缺省则保留原值
// - compensatePosition: true 时按 anchor 差值 * 节点 size 自动补偿 lpos
// 补偿公式:lpos.x += width * (newAnchorX - oldAnchorX)
// lpos.y += height * (newAnchorY - oldAnchorY)
// 场景:改 anchor 又想保持节点视觉位置不动
// - stub 节点:_anchorPoint 走 PrefabInstance.propertyOverrides 写嵌套 UITransform
// compensate 时 _lpos 走 stub 节点自身的 propertyOverrides(节点字段)。
// oldA / size 从嵌套 prefab 默认值读(不查 propertyOverrides 历史值)。
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
const { setOverrideProperty } = require('../../overrides.js');
const { parsePrefab } = require('../../parse.js');
const { resolveUuidToPath } = require('../../uuid-resolver.js');
// 从嵌套 prefab 内读 root UITransform 的 _anchorPoint / _contentSize(默认值)
function _readNestedUITransform(hostPath, elements, stubNodeId) {
const stub = elements[stubNodeId];
const pi = elements[stub._prefab.__id__];
const nestedUuid = pi.asset.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (el && el.__type__ === 'cc.UITransform') {
const a = el._anchorPoint || { x: 0.5, y: 0.5 };
const s = el._contentSize || { width: 0, height: 0 };
return {
anchor: { x: a.x || 0, y: a.y || 0 },
size: { width: s.width || 0, height: s.height || 0 },
};
}
}
return { anchor: { x: 0.5, y: 0.5 }, size: { width: 0, height: 0 } };
}
function execSetAnchor(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, x, y, compensatePosition = false } = op;
if (x === undefined && y === undefined) {
throw new Error(`editPrefab [set-anchor]: 至少提供 x 或 y 之一`);
}
if (x !== undefined && (typeof x !== 'number' || x < 0 || x > 1)) {
throw new Error(`editPrefab [set-anchor]: x 必须是 0~1 数字`);
}
if (y !== undefined && (typeof y !== 'number' || y < 0 || y > 1)) {
throw new Error(`editPrefab [set-anchor]: y 必须是 0~1 数字`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-anchor');
if (isStub(elements, node)) {
const { anchor: oldA, size } = _readNestedUITransform(
prefabData.resolverStartPath, elements, nodeId
);
const newA = {
__type__: 'cc.Vec2',
x: x === undefined ? oldA.x : x,
y: y === undefined ? oldA.y : y,
};
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_anchorPoint'], newA);
if (compensatePosition) {
// stub 节点 _lpos 改值走自身 propertyOverrides(节点字段,不是组件字段)
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
const dx = size.width * (newA.x - oldA.x);
const dy = size.height * (newA.y - oldA.y);
const newLpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: lpos.z || 0,
};
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
}
return nodeId;
}
const ut = findComponent(elements, node, 'cc.UITransform');
if (!ut) {
throw new Error(`editPrefab [set-anchor]: 节点 "${node._name}" 上没有 cc.UITransform`);
}
const oldA = ut._anchorPoint || { x: 0.5, y: 0.5 };
const newA = {
__type__: 'cc.Vec2',
x: x === undefined ? oldA.x : x,
y: y === undefined ? oldA.y : y,
};
ut._anchorPoint = newA;
if (compensatePosition) {
const size = ut._contentSize || { width: 0, height: 0 };
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
const dx = (size.width || 0) * (newA.x - oldA.x);
const dy = (size.height || 0) * (newA.y - oldA.y);
node._lpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: lpos.z || 0,
};
}
return nodeId;
}
module.exports = { execSetAnchor };
+52
View File
@@ -0,0 +1,52 @@
// set-button: 批量设置节点上 cc.Button 的常用字段
// op: {
// op: 'set-button',
// node,
// interactable?: boolean
// transition?: 0=NONE 1=COLOR 2=SPRITE 3=SCALE
// zoomScale?: numbertransition=SCALE 时的缩放比例)
// duration?: number(过渡动画时长,秒)
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
interactable: '_interactable',
transition: '_transition',
zoomScale: '_zoomScale',
duration: '_duration',
};
function execSetButton(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-button');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-button]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Button');
if (!comp) {
throw new Error(`editPrefab [set-button]: 节点 "${node._name}" 上找不到 cc.Button 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-button]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetButton };
@@ -0,0 +1,44 @@
// set-component-enabled: 改组件 _enabled
// op: { op:'set-component-enabled', node, componentType, enabled }
//
// 普通节点直接改 comp._enabled
// stub 节点:写 PrefabInstance.propertyOverrides(与 set-nested-component-field 同模式)
'use strict';
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetComponentEnabled(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawCompType, enabled, subNode = null } = op;
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
throw new Error(`editPrefab [set-component-enabled]: componentType 必须是非空字符串`);
}
if (typeof enabled !== 'boolean') {
throw new Error(`editPrefab [set-component-enabled]: enabled 必须是布尔值`);
}
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-enabled');
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_enabled'], enabled);
} else {
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(
`editPrefab [set-component-enabled]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
);
}
comp._enabled = enabled;
}
return nodeId;
}
module.exports = { execSetComponentEnabled };
+68
View File
@@ -0,0 +1,68 @@
// set-component-field: 普通节点改任意组件字段
// op: { op:'set-component-field', node, componentType, property, value }
//
// set-nested-component-field 只覆盖 stub 节点;本 op 是普通节点版本。
//
// - node: 普通节点选择器(不能是 stub)
// - property: 字段名,可以是字符串(顶层字段)或字符串数组(嵌套路径)
// 例:'_string' / ['_color', 'r'] / ['_anchorPoint', 'x']
// - value: 任意 JSON-serializable 值;改 cc.Vec2 / cc.Vec3 / cc.Size 时需带 __type__
'use strict';
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
function execSetComponentField(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawComponentType, property, value } = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-component-field]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
if (
!(typeof property === 'string' && property.length > 0) &&
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string' && p.length > 0))
) {
throw new Error(`editPrefab [set-component-field]: property 必须是非空字符串或非空字符串数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [set-component-field]: value 不能是 undefined`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-field');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [set-component-field]: 节点 "${node._name}" 是 stub 代理,请用 set-nested-component-field`
);
}
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(
`editPrefab [set-component-field]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
);
}
// 单层 property
if (typeof property === 'string') {
comp[property] = value;
return nodeId;
}
// 嵌套 property 路径:逐层下钻,路径中断时报错(不自动建中间对象,避免悄悄改坏结构)
let cursor = comp;
for (let i = 0; i < property.length - 1; i++) {
const k = property[i];
if (cursor[k] === null || cursor[k] === undefined || typeof cursor[k] !== 'object') {
throw new Error(
`editPrefab [set-component-field]: 路径 ${property.slice(0, i + 1).join('.')} 不是对象(实际值 ${JSON.stringify(cursor[k])}),无法继续下钻`
);
}
cursor = cursor[k];
}
cursor[property[property.length - 1]] = value;
return nodeId;
}
module.exports = { execSetComponentField };
+138
View File
@@ -0,0 +1,138 @@
// set-component-ref: 把节点上指定组件的 @property 字段序列化指向另一节点/组件
// op: { op: 'set-component-ref', node, componentType, property, refNode, refType?, refSubNode? }
//
// - node: 持有目标组件的节点
// - componentType: 目标组件 ccclass 名
// - property: @property 字段名,支持以下格式:
// "_role" → 普通字段,propertyPath: ["_role"]
// "_items.0" → 数组字段第 0 项,propertyPath: ["_items", 0]
// "_items[0]" → 同上,[] 写法等价
// - refNode: 引用指向的节点(字符串名或 {id:N})
// - refType: 缺省或 'cc.Node' 表示字段指向节点本身;否则指向 refNode 上该类型的第一个组件
// - refSubNode: refNode 是 stub 时指定嵌套 prefab 内的子节点名(可选)
'use strict';
const { ref } = require('../../primitives.js');
const { normalizeComponentType, isStub, indexOfNode, resolveNode, findComponent } = require('../helpers.js');
const { resolveLocalIdChain, addRootTargetOverride } = require('../nested.js');
// ─── propertyPath 解析 ────────────────────────────────────────
//
// 把 property 字符串拆成 propertyPath 数组,传给 addRootTargetOverride
// 和 setByPropertyPath,统一用数字表示数组索引(Cocos 引擎序列化格式)。
//
// 例:
// "_role" → ["_role"]
// "_items.0" → ["_items", 0]
// "_items[2]" → ["_items", 2]
function parsePropertyPath(property) {
// [] 下标 → . 分隔:_items[0] → _items.0
const normalized = property.replace(/\[(\d+)\]/g, '.$1');
const parts = normalized.split('.').filter(p => p.length > 0);
return parts.map(p => {
const n = Number(p);
// 纯整数字符串(不含前导零的,如 "0" "1" "10")→ 数字索引
return Number.isInteger(n) && String(n) === p ? n : p;
});
}
// ─── 按 propertyPath 多层路径赋值 ─────────────────────────────
//
// 支持数组索引(数字 key)和对象属性(字符串 key)的任意组合。
// 中间层不存在时:下一段是数字 → 创建数组;否则 → 创建对象。
function setByPropertyPath(obj, pathParts, value) {
if (pathParts.length === 1) {
obj[pathParts[0]] = value;
return;
}
const head = pathParts[0];
const tail = pathParts.slice(1);
if (!obj[head] || typeof obj[head] !== 'object') {
obj[head] = typeof tail[0] === 'number' ? [] : {};
}
setByPropertyPath(obj[head], tail, value);
}
function execSetComponentRef(prefabData, op) {
const { elements, rootId } = prefabData;
const {
node: nodeSelector,
componentType: rawComponentType,
property,
refNode: refNodeSelector,
refType: rawRefType,
refSubNode = null,
} = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-component-ref]: componentType 必须是非空字符串`);
}
if (typeof property !== 'string' || property.length === 0) {
throw new Error(`editPrefab [set-component-ref]: property 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
const refType = rawRefType
? normalizeComponentType(rawRefType, prefabData.resolverStartPath)
: rawRefType;
// 解析 property 为 propertyPath 数组(支持 "_items.0" / "_items[0]" 数组写法)
const propertyPath = parsePropertyPath(property);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-ref');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [set-component-ref]: 源节点 "${node._name}" 是 stub(嵌套 prefab 代理),` +
`对 stub 自身组件挂 @property 字段的场景(需要 TargetOverrideInfo.sourceInfo)暂不支持`
);
}
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(`editPrefab [set-component-ref]: 节点 "${node._name}" 未挂 "${componentType}" 组件`);
}
const { node: refNode, nodeId: refNodeId } = resolveNode(prefabData, refNodeSelector, 'set-component-ref');
// refNode 是 stub 代理 → 走 cc.TargetOverrideInfo 跨 nested 挂载
if (isStub(elements, refNode)) {
const targetCompType = !refType || refType === 'cc.Node' ? 'cc.Node' : refType;
const localIdChain = resolveLocalIdChain(
prefabData.resolverStartPath,
elements,
refNodeId,
targetCompType,
refSubNode
);
const compId = indexOfNode(elements, comp);
if (compId < 0) {
throw new Error(`editPrefab [set-component-ref]: 源组件索引失败(内部错误)`);
}
// 传入 propertyPath 数组(支持 ["_items", 0] 数组元素挂载)
addRootTargetOverride(prefabData, rootId, compId, propertyPath, refNodeId, localIdChain);
return nodeId;
}
// 普通节点:按 propertyPath 多层路径赋值(支持数组字段 _items[0]/_items[1]...
if (!refType || refType === 'cc.Node') {
setByPropertyPath(comp, propertyPath, ref(refNodeId));
} else {
const refComp = findComponent(elements, refNode, refType);
if (!refComp) {
throw new Error(`editPrefab [set-component-ref]: 引用节点 "${refNode._name}" 未挂 "${refType}" 组件`);
}
const refCompId = indexOfNode(elements, refComp);
if (refCompId < 0) {
throw new Error(`editPrefab [set-component-ref]: 引用组件索引失败(内部错误)`);
}
setByPropertyPath(comp, propertyPath, ref(refCompId));
}
return nodeId;
}
module.exports = { execSetComponentRef };
+56
View File
@@ -0,0 +1,56 @@
// set-editbox: 批量设置节点上 cc.EditBox 的常用字段
// op: {
// op: 'set-editbox',
// node,
// inputMode?: 0=ANY 1=EMAIL_ADDR 2=NUMERIC 3=PHONE_NUMBER 4=URL 5=DECIMAL 6=SINGLE_LINE
// maxLength?: number-1 无限制)
// placeholder?: string
// string?: string(当前文字值)
// inputFlag?: 0=DEFAULT 1=PASSWORD 2=SENSITIVE 3=INITIAL_CAPS_WORD 4=INITIAL_CAPS_SENTENCE 5=INITIAL_CAPS_ALL_CHARACTERS
// fontSize?: number
// }
//
// 至少提供一个可选字段,否则 op 无意义。
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
inputMode: '_inputMode',
maxLength: '_maxLength',
placeholder: '_placeholder',
string: '_string',
inputFlag: '_inputFlag',
fontSize: '_fontSize',
};
function execSetEditBox(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-editbox');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-editbox]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.EditBox');
if (!comp) {
throw new Error(`editPrefab [set-editbox]: 节点 "${node._name}" 上找不到 cc.EditBox 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(`editPrefab [set-editbox]: 至少需要提供一个字段(inputMode/maxLength/placeholder/string/inputFlag/fontSize`);
}
return nodeId;
}
module.exports = { execSetEditBox };
+39
View File
@@ -0,0 +1,39 @@
// set-label-text: 设置节点上 cc.Label 的 _string
// op: { op, node, text, labelNode? }
//
// 普通节点:直接修改 cc.Label._string
// stub 节点:从嵌套 prefab 中找 cc.Label 的 CompPrefabInfo.fileId
// 写入 PrefabInstance.propertyOverrides
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetLabelText(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, text, labelNode = null } = op;
if (typeof text !== 'string') {
throw new Error(`editPrefab [set-label-text]: text 必须是字符串`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-label-text');
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, id, 'cc.Label', labelNode
);
setStubCompOverride(prefabData, id, compFileId, ['_string'], text);
} else {
const comp = findComponent(elements, node, 'cc.Label');
if (!comp) {
throw new Error(`editPrefab [set-label-text]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Label 组件`);
}
comp._string = text;
}
return id;
}
module.exports = { execSetLabelText };
+64
View File
@@ -0,0 +1,64 @@
// set-label: 批量设置节点上 cc.Label 的常用字段
// op: {
// op: 'set-label',
// node,
// text?: string_string
// fontSize?: number
// lineHeight?: number0 = auto
// overflow?: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE
// horizontalAlign?: 0=LEFT 1=CENTER 2=RIGHT
// verticalAlign?: 0=TOP 1=CENTER 2=BOTTOM
// bold?: boolean
// italic?: boolean
// underline?: boolean
// enableWrapText?: boolean_enableWrapText
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
text: '_string',
fontSize: '_fontSize',
lineHeight: '_lineHeight',
overflow: '_overflow',
horizontalAlign: '_horizontalAlign',
verticalAlign: '_verticalAlign',
bold: '_isBold',
italic: '_isItalic',
underline: '_isUnderline',
enableWrapText: '_enableWrapText',
};
function execSetLabel(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-label');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-label]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Label');
if (!comp) {
throw new Error(`editPrefab [set-label]: 节点 "${node._name}" 上找不到 cc.Label 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-label]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetLabel };
+68
View File
@@ -0,0 +1,68 @@
// set-layout: 批量设置节点上 cc.Layout 的常用字段
// op: {
// op: 'set-layout',
// node,
// type?: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID
// resizeMode?: 0=NONE 1=CHILDREN 2=CONTAINER
// paddingLeft?: number
// paddingRight?: number
// paddingTop?: number
// paddingBottom?: number
// spacingX?: number
// spacingY?: number
// startAxis?: 0=HORIZONTAL 1=VERTICALGRID 模式)
// constraint?: 0=NONE 1=FIXED_ROW 2=FIXED_COLGRID 模式)
// constraintNum?: numberconstraint 对应的行数/列数)
// affectedByScale?: boolean
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
type: '_layoutType',
resizeMode: '_resizeMode',
paddingLeft: '_paddingLeft',
paddingRight: '_paddingRight',
paddingTop: '_paddingTop',
paddingBottom: '_paddingBottom',
spacingX: '_spacingX',
spacingY: '_spacingY',
startAxis: '_startAxis',
constraint: '_constraint',
constraintNum: '_constraintNum',
affectedByScale: '_affectedByScale',
};
function execSetLayout(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-layout');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-layout]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Layout');
if (!comp) {
throw new Error(`editPrefab [set-layout]: 节点 "${node._name}" 上找不到 cc.Layout 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-layout]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetLayout };
@@ -0,0 +1,52 @@
// set-nested-component-field: 改 stub 节点展开后内部某组件的字段
// op: { op, node, componentType, property, value, subNode? }
//
// - node: stub 节点名或 {id}(必须是 stub 代理)
// - componentType: 子 prefab 里目标组件类型(如 'cc.Label'
// - property: 字段名(如 '_string' / '_spriteFrame' / 'interactable');支持嵌套路径数组
// - value: 要写入的值(raw JSON
// - subNode: 子 prefab 内部节点名(可选,默认 null = 子 prefab root 上第一个匹配组件)
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetNestedComponentField(prefabData, op) {
const { elements } = prefabData;
const {
node: nodeSelector,
componentType: rawComponentType,
property,
value,
subNode = null,
} = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-nested-component-field]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
if (!property || (typeof property !== 'string' && !Array.isArray(property))) {
throw new Error(`editPrefab [set-nested-component-field]: property 必须是字符串或数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [set-nested-component-field]: value 不能是 undefined`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-nested-component-field');
if (!isStub(elements, node)) {
throw new Error(
`editPrefab [set-nested-component-field]: 节点 "${node._name}" 不是 stub 代理——` +
`普通节点直接用 set-label-text/set-sprite-frame 或在代码里改组件字段`
);
}
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
);
const propertyPath = Array.isArray(property) ? property : [property];
setStubCompOverride(prefabData, nodeId, compFileId, propertyPath, value);
return nodeId;
}
module.exports = { execSetNestedComponentField };
+42
View File
@@ -0,0 +1,42 @@
// set-node-color: 设置节点的 _colorcc.Node 自身颜色,影响整棵子树透明度和染色)
// op: {
// op: 'set-node-color',
// node,
// r?: number (0-255)
// g?: number (0-255)
// b?: number (0-255)
// a?: number (0-255)
// }
//
// 示例:{ op:'set-node-color', node:'btnClose', a:0 } // 全透明
// { op:'set-node-color', node:'bg', r:255, g:200, b:100, a:255 }
'use strict';
const { resolveNode, isStub } = require('../helpers.js');
function execSetNodeColor(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, r, g, b, a } = op;
if (r === undefined && g === undefined && b === undefined && a === undefined) {
throw new Error(`editPrefab [set-node-color]: 至少提供一个分量(r/g/b/a)`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-node-color');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-node-color]: 节点是 stub,请用 set-nested-component-field 改节点颜色分量`);
}
if (!node._color || typeof node._color !== 'object') {
node._color = { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 };
}
if (r !== undefined) node._color.r = r;
if (g !== undefined) node._color.g = g;
if (b !== undefined) node._color.b = b;
if (a !== undefined) node._color.a = a;
return nodeId;
}
module.exports = { execSetNodeColor };
+29
View File
@@ -0,0 +1,29 @@
// set-position: 设置节点本地位置
// op: { op, node, x, y, z? }
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execSetPosition(prefabData, op) {
const { elements } = prefabData;
const { node: nodeId, x, y, z = 0 } = op;
if (typeof x !== 'number' || typeof y !== 'number') {
throw new Error(`editPrefab [set-position]: x/y 必须是数字`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeId, 'set-position');
const newLpos = { __type__: 'cc.Vec3', x, y, z };
if (isStub(elements, node)) {
setOverrideProperty(prefabData, id, ['_lpos'], newLpos);
} else {
node._lpos = newLpos;
}
return id;
}
module.exports = { execSetPosition };
+52
View File
@@ -0,0 +1,52 @@
// set-richtext: 批量设置节点上 cc.RichText 的常用字段
// op: {
// op: 'set-richtext',
// node,
// text?: string_string,支持 BBCode 标签)
// maxWidth?: number0 = 不限制)
// fontSize?: number
// lineHeight?: number
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
text: '_string',
maxWidth: '_maxWidth',
fontSize: '_fontSize',
lineHeight: '_lineHeight',
};
function execSetRichText(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-richtext');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-richtext]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.RichText');
if (!comp) {
throw new Error(`editPrefab [set-richtext]: 节点 "${node._name}" 上找不到 cc.RichText 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-richtext]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetRichText };
+78
View File
@@ -0,0 +1,78 @@
// set-size: 改 cc.UITransform 内容尺寸
// op: { op:'set-size', node, width?, height? }
//
// width / height 任一缺省则保留原值
// stub 节点:走 PrefabInstance.propertyOverrides 写嵌套 UITransform._contentSize
// - 任一缺省时从嵌套 prefab 读默认值补齐
// - 不读 propertyOverrides 里的历史 override(少见且增加复杂度)
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
const { parsePrefab } = require('../../parse.js');
const { resolveUuidToPath } = require('../../uuid-resolver.js');
// 从嵌套 prefab 内读 root UITransform 默认 _contentSize(用作 stub set-size 的缺省补齐)
function _readNestedUITransformSize(hostPath, elements, stubNodeId) {
const stub = elements[stubNodeId];
const pi = elements[stub._prefab.__id__];
const nestedUuid = pi.asset.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (el && el.__type__ === 'cc.UITransform') {
const s = el._contentSize || { width: 0, height: 0 };
return { width: s.width || 0, height: s.height || 0 };
}
}
return { width: 0, height: 0 };
}
function execSetSize(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, width, height } = op;
if (width === undefined && height === undefined) {
throw new Error(`editPrefab [set-size]: 至少提供 width 或 height 之一`);
}
if (width !== undefined && (typeof width !== 'number' || width < 0)) {
throw new Error(`editPrefab [set-size]: width 必须是非负数字`);
}
if (height !== undefined && (typeof height !== 'number' || height < 0)) {
throw new Error(`editPrefab [set-size]: height 必须是非负数字`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-size');
if (isStub(elements, node)) {
const oldSize = _readNestedUITransformSize(prefabData.resolverStartPath, elements, nodeId);
const newSize = {
__type__: 'cc.Size',
width: width === undefined ? oldSize.width : width,
height: height === undefined ? oldSize.height : height,
};
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_contentSize'], newSize);
return nodeId;
}
const ut = findComponent(elements, node, 'cc.UITransform');
if (!ut) {
throw new Error(`editPrefab [set-size]: 节点 "${node._name}" 上没有 cc.UITransform`);
}
const oldSize = ut._contentSize || { width: 0, height: 0 };
ut._contentSize = {
__type__: 'cc.Size',
width: width === undefined ? oldSize.width : width,
height: height === undefined ? oldSize.height : height,
};
return nodeId;
}
module.exports = { execSetSize };
+36
View File
@@ -0,0 +1,36 @@
// set-sprite-frame: 设置节点上 cc.Sprite 的 _spriteFrame uuid
// op: { op, node, uuid, spriteNode? }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetSpriteFrame(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, uuid, spriteNode = null } = op;
if (typeof uuid !== 'string') {
throw new Error(`editPrefab [set-sprite-frame]: uuid 必须是字符串`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-sprite-frame');
const newFrame = { __uuid__: uuid, __expectedType__: 'cc.SpriteFrame' };
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, id, 'cc.Sprite', spriteNode
);
setStubCompOverride(prefabData, id, compFileId, ['_spriteFrame'], newFrame);
} else {
const comp = findComponent(elements, node, 'cc.Sprite');
if (!comp) {
throw new Error(`editPrefab [set-sprite-frame]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Sprite 组件`);
}
comp._spriteFrame = newFrame;
}
return id;
}
module.exports = { execSetSpriteFrame };
+52
View File
@@ -0,0 +1,52 @@
// set-sprite: 批量设置节点上 cc.Sprite 的常用字段(不含 spriteFrame,用 set-sprite-frame
// op: {
// op: 'set-sprite',
// node,
// sizeMode?: 0=CUSTOM 1=TRIMMED 2=RAW
// type?: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH
// grayscale?: boolean_useGrayscale
// trim?: boolean_isTrimmedMode
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
sizeMode: '_sizeMode',
type: '_type',
grayscale: '_useGrayscale',
trim: '_isTrimmedMode',
};
function execSetSprite(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-sprite');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-sprite]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Sprite');
if (!comp) {
throw new Error(`editPrefab [set-sprite]: 节点 "${node._name}" 上找不到 cc.Sprite 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-sprite]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})。更换图片用 set-sprite-frame`
);
}
return nodeId;
}
module.exports = { execSetSprite };
+20
View File
@@ -0,0 +1,20 @@
// sync-nested-roots: 重建根 PrefabInfo.nestedPrefabInstanceRoots,剔除「删了一半」
// 残留的悬空嵌套实例根——节点的父引用已被移除(_parent=null)但根 PrefabInfo 里
// 对它的登记还在,导致残留嵌套 prefab 的 asset 仍被当依赖加载(运行时 404 / 加载失败)。
//
// 只重写 nestedPrefabInstanceRoots 数组(依据当前「有父 + 有 PrefabInfo + instance」的
// 实际 stub 节点),不删 elements、不动其他 __id__、不产生 null 槽;被孤立的残留对象
// 成为不可达 orphan(软删策略,无害)。
//
// op: { op: 'sync-nested-roots' } 无参数,作用于 prefab 根。
'use strict';
const { syncNestedRoots } = require('../id-utils.js');
function execSyncNestedRoots(prefabData) {
const { elements, rootId } = prefabData;
syncNestedRoots(elements, rootId);
return rootId;
}
module.exports = { execSyncNestedRoots };