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
+378
View File
@@ -0,0 +1,378 @@
// ============================================================
// CC3 AnimationClip (.anim) 对象构建原语(纯 CJS,零三方依赖)
//
// .anim 文件和 .prefab 一样是 JSON 数组 + `__id__` 交叉引用,
// 复用 parsePrefab / writePrefab 解析写入。
//
// 但 .anim 内部对象类型(AnimationClip / Track / Curve / Channel
// 有自己的 schema 规范,最容易踩的坑:
//
// ✅ cc.animation.RealTrack → 单通道 → `_channel: {__id__: X}` (单数)
// ✅ cc.animation.ObjectTrack → 单通道 → `_channel: {__id__: X}` (单数)
// ✅ cc.animation.VectorTrack → 多通道 → `_channels: [x, y, z]` (复数数组)
// ✅ cc.animation.ColorTrack → 多通道 → `_channels: [r, g, b, a]` (复数数组)
//
// 写错字段名(比如给 RealTrack 写 `_channels: [...]`)会导致 CC3 编辑器
// 按 schema 验证时直接忽略整条轨道,表现为「anim 文件里有数据但编辑器
// 不显示关键帧、运行时也不播」——历史踩过的坑,见 anim-schema.md。
//
// 用本文件里的 make* 工厂函数,保证字段名一定写对。
// ============================================================
'use strict';
const { ref } = require('./primitives.js');
// ─────────────────────────────────────────────
// 插值模式常量
// 对应 cc.RealCurve.interpolationMode 枚举
// ─────────────────────────────────────────────
const InterpolationMode = Object.freeze({
LINEAR: 0, // 线性插值,两关键帧之间平滑过渡
CONSTANT: 1, // 常量插值,保持当前值直到下一帧瞬变
CUBIC: 2, // 三次插值,带缓动曲线
});
// ─────────────────────────────────────────────
// Extrapolation 模式常量
// 对应 cc.RealCurve.preExtrapolation / postExtrapolation
// ─────────────────────────────────────────────
const Extrapolation = Object.freeze({
LINEAR: 0,
CLAMP: 1, // 最常用:首帧之前保持首帧值,末帧之后保持末帧值
REPEAT: 2,
PINGPONG: 3,
});
// ─────────────────────────────────────────────
// RealKeyframeValue:单个浮点关键帧
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number} opts.value
* @param {number} [opts.interpolationMode=CONSTANT] 0=LINEAR 1=CONSTANT 2=CUBIC
* @param {number} [opts.easingMethod=0] 0=Linear,其余见 cc.EasingMethod
*/
function makeRealKeyframe(opts) {
const { value, interpolationMode = InterpolationMode.CONSTANT, easingMethod = 0 } = opts;
return {
__type__: 'cc.RealKeyframeValue',
interpolationMode,
tangentWeightMode: 0,
value,
rightTangent: 0,
rightTangentWeight: 1,
leftTangent: 0,
leftTangentWeight: 1,
easingMethod,
__editorExtras__: null,
};
}
// ─────────────────────────────────────────────
// cc.RealCurve:浮点曲线
// times/values 一一对应,长度必须相同
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number[]} opts.times - 递增时间点(秒)
* @param {object[]} opts.values - RealKeyframeValue 对象数组,与 times 同长
* @param {number} [opts.preExtrapolation=1] CLAMP
* @param {number} [opts.postExtrapolation=1] CLAMP
*/
function makeRealCurve(opts) {
const { times, values, preExtrapolation = Extrapolation.CLAMP, postExtrapolation = Extrapolation.CLAMP } = opts;
if (times.length !== values.length) {
throw new Error(`makeRealCurve: times.length (${times.length}) !== values.length (${values.length})`);
}
return {
__type__: 'cc.RealCurve',
_times: times,
_values: values,
preExtrapolation,
postExtrapolation,
};
}
// ─────────────────────────────────────────────
// cc.ObjectCurve:对象引用曲线(用于 spriteFrame 等资产序列)
// values 是资产引用对象数组(`{ __uuid__, __expectedType__ }`
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number[]} opts.times
* @param {object[]} opts.values - 资产引用对象
*/
function makeObjectCurve(opts) {
const { times, values, preExtrapolation = Extrapolation.CLAMP, postExtrapolation = Extrapolation.CLAMP } = opts;
if (times.length !== values.length) {
throw new Error(`makeObjectCurve: times.length (${times.length}) !== values.length (${values.length})`);
}
return {
__type__: 'cc.ObjectCurve',
_times: times,
_values: values,
preExtrapolation,
postExtrapolation,
};
}
// ─────────────────────────────────────────────
// cc.animation.ChannelTrack → Curve 的中间层
// ─────────────────────────────────────────────
/**
* @param {number} curveIdx - RealCurve/ObjectCurve 在 objects 数组里的下标
*/
function makeChannel(curveIdx) {
return {
__type__: 'cc.animation.Channel',
_curve: ref(curveIdx),
};
}
// ─────────────────────────────────────────────
// cc.animation.HierarchyPath / ComponentPath / TrackPath
// 用于定位轨道的目标节点与属性
// ─────────────────────────────────────────────
/**
* @param {string} path - 节点层级路径,如 "n4" 或 "content/titleBar";根节点自身写 ""
*/
function makeHierarchyPath(path) {
return { __type__: 'cc.animation.HierarchyPath', path };
}
/**
* @param {string} component - 组件类型,如 "cc.UIOpacity" / "cc.Sprite"
*/
function makeComponentPath(component) {
return { __type__: 'cc.animation.ComponentPath', component };
}
/**
* @param {unknown[]} parts - 混合 HierarchyPath/ComponentPath 引用与属性字符串
* 例:[ref(hierIdx), ref(compIdx), "opacity"]
* 或根节点属性:[ref(compIdx), "position"](无 hierarchy path
*/
function makeTrackPath(parts) {
return { __type__: 'cc.animation.TrackPath', _paths: parts };
}
// ─────────────────────────────────────────────
// cc.animation.RealTrack(单通道浮点轨道)
// ⚠️ 字段必须是 `_channel`(单数),不是 `_channels`
// ⚠️ 写错会被 CC3 编辑器 schema 验证忽略,轨道形同虚设
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx - TrackPath 在 objects 数组里的下标
* @param {number} channelIdx - Channel 在 objects 数组里的下标
*/
function makeRealTrack(trackPathIdx, channelIdx) {
return {
__type__: 'cc.animation.RealTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channel: ref(channelIdx),
};
}
// ─────────────────────────────────────────────
// cc.animation.ObjectTrack(单通道对象轨道,常用于 spriteFrame 序列帧)
// ⚠️ 字段必须是 `_channel`(单数)
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx
* @param {number} channelIdx
*/
function makeObjectTrack(trackPathIdx, channelIdx) {
return {
__type__: 'cc.animation.ObjectTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channel: ref(channelIdx),
};
}
// ─────────────────────────────────────────────
// cc.animation.VectorTrack(多通道,x/y/z 或 x/y/z/w
// ⚠️ 字段必须是 `_channels`(复数,数组),且必须提供 `_nComponents`
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx
* @param {number[]} channelIndices - 各轴 Channel 下标数组(长度 = nComponents
* @param {number} nComponents - 2(Vec2) / 3(Vec3) / 4(Vec4/Quat)
*/
function makeVectorTrack(trackPathIdx, channelIndices, nComponents) {
if (channelIndices.length !== nComponents) {
throw new Error(`makeVectorTrack: channelIndices.length (${channelIndices.length}) !== nComponents (${nComponents})`);
}
return {
__type__: 'cc.animation.VectorTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channels: channelIndices.map(ref),
_nComponents: nComponents,
};
}
// ─────────────────────────────────────────────
// cc.animation.ColorTrack4 通道 r/g/b/a
// ⚠️ 字段必须是 `_channels`(复数,数组),无 `_nComponents`
// ─────────────────────────────────────────────
/**
* @param {number} trackPathIdx
* @param {number[]} channelIndices - [rIdx, gIdx, bIdx, aIdx]
*/
function makeColorTrack(trackPathIdx, channelIndices) {
if (channelIndices.length !== 4) {
throw new Error(`makeColorTrack: expected 4 channel indices, got ${channelIndices.length}`);
}
return {
__type__: 'cc.animation.ColorTrack',
_binding: {
__type__: 'cc.animation.TrackBinding',
path: ref(trackPathIdx),
proxy: null,
},
_channels: channelIndices.map(ref),
};
}
// ─────────────────────────────────────────────
// cc.AnimationClipAdditiveSettings(每个 clip 尾巴一份)
// ─────────────────────────────────────────────
function makeAdditiveSettings() {
return {
__type__: 'cc.AnimationClipAdditiveSettings',
enabled: false,
refClip: null,
};
}
// ─────────────────────────────────────────────
// cc.AnimationClip 文件头(objects[0]
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {string} opts.name
* @param {number} opts.sample - 采样帧率
* @param {number} opts.duration - 秒
* @param {number} [opts.speed=1]
* @param {number} [opts.wrapMode=1] 1=Normal, 2=Loop, 22=PingPong, 36=PingPongLoop
* @param {number} opts.hash - 哈希值(通常 hashString(name)
* @param {number[]} opts.trackIndices - 所有 Track 在 objects 数组里的下标
* @param {number} opts.additiveIdx - AdditiveSettings 下标
* @param {number[]} [opts.embeddedPlayerIndices=[]] - EmbeddedPlayer 下标
*/
function makeAnimationClip(opts) {
const {
name,
sample,
duration,
speed = 1,
wrapMode = 1,
hash,
trackIndices,
additiveIdx,
embeddedPlayerIndices = [],
} = opts;
return {
__type__: 'cc.AnimationClip',
_name: name,
_objFlags: 0,
__editorExtras__: { embeddedPlayerGroups: [] },
_native: '',
sample,
speed,
wrapMode,
enableTrsBlending: false,
_duration: duration,
_hash: hash,
_tracks: trackIndices.map(ref),
_exoticAnimation: null,
_events: [],
_embeddedPlayers: embeddedPlayerIndices.map(ref),
_additiveSettings: ref(additiveIdx),
_auxiliaryCurveEntries: [],
};
}
// ─────────────────────────────────────────────
// cc.animation.EmbeddedPlayer / EmbeddedAnimationClipPlayable
// 用于 movieclip 这类「主 clip 触发节点自己 Animation 播子 clip」的结构
// ─────────────────────────────────────────────
/**
* @param {object} opts
* @param {number} opts.begin - 主 clip 时间线上子 clip 开始秒
* @param {number} opts.end - 主 clip 时间线上子 clip 结束秒
* @param {number} opts.playableIdx - EmbeddedAnimationClipPlayable 下标
*/
function makeEmbeddedPlayer(opts) {
const { begin, end, playableIdx } = opts;
return {
__type__: 'cc.animation.EmbeddedPlayer',
begin,
end,
reconciledSpeed: false,
playable: ref(playableIdx),
};
}
/**
* @param {object} opts
* @param {string} opts.path - 目标节点路径(相对 clip 所在节点)
* @param {string} opts.clipUuid - 子 clip 资产 UUID
*/
function makeEmbeddedAnimationClipPlayable(opts) {
const { path, clipUuid } = opts;
return {
__type__: 'cc.animation.EmbeddedAnimationClipPlayable',
path,
clip: {
__uuid__: clipUuid,
__expectedType__: 'cc.AnimationClip',
},
};
}
// ─────────────────────────────────────────────
// 哈希函数:生成 AnimationClip._hash
// 用简单字符串哈希,只需保证同名 clip 得到同一 hash 即可
// ─────────────────────────────────────────────
function hashString(s) {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = ((hash << 5) - hash) + s.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
module.exports = {
InterpolationMode,
Extrapolation,
makeRealKeyframe,
makeRealCurve,
makeObjectCurve,
makeChannel,
makeHierarchyPath,
makeComponentPath,
makeTrackPath,
makeRealTrack,
makeObjectTrack,
makeVectorTrack,
makeColorTrack,
makeAdditiveSettings,
makeAnimationClip,
makeEmbeddedPlayer,
makeEmbeddedAnimationClipPlayable,
hashString,
};
+138
View File
@@ -0,0 +1,138 @@
// ============================================================
// ClassIdResolver:扫描 assets/scripts/ 下所有 .ts,建立
// className → { uuid, classId, scriptPath } 映射。
//
// 来源:
// - @ccclass('SomeName') 装饰器提供 className
// - 同路径 <script>.ts.meta 的 uuid 字段
// - compressUuid(uuid) 得到 Cocos 序列化 prefab 用的 23 字符 classId
//
// 缓存粒度:projectRoot;多次调用复用。
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { compressUuid } = require('./id');
const _cache = new Map(); // projectRoot -> Map<className, entry>
/** 从路径向上找含 assets/+package.json 的目录。 */
function _findProjectRoot(startPath) {
const resolved = path.resolve(startPath);
let dir;
try {
dir = fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
} catch (_) {
dir = path.dirname(resolved);
}
for (let i = 0; i < 20; i++) {
if (fs.existsSync(path.join(dir, 'assets')) && fs.existsSync(path.join(dir, 'package.json'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`ClassIdResolver: 无法从 "${startPath}" 向上找到项目根`);
}
/** 扫 .ts 抽取 @ccclass('Name');一个文件可以多个 */
function _extractCcClassNames(src) {
const names = [];
const re = /@ccclass\(\s*['"]([^'"]+)['"]\s*\)/g;
let m;
while ((m = re.exec(src)) !== null) {
names.push(m[1]);
}
return names;
}
/** 在 assetsDir 下找所有 .ts(跳过 .d.ts),返回绝对路径数组。 */
function _listTsFiles(assetsDir) {
try {
const raw = execSync(
`find "${assetsDir}" -name "*.ts" -not -name "*.d.ts" -type f`,
{ encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }
);
return raw.trim().split('\n').filter(Boolean);
} catch (e) {
throw new Error(`ClassIdResolver: find 命令失败: ${e.message}`);
}
}
/** 建 projectRoot 下的 className -> entry 索引。 */
function _buildIndex(projectRoot) {
const scriptsDir = path.join(projectRoot, 'assets', 'scripts');
const scanDir = fs.existsSync(scriptsDir) ? scriptsDir : path.join(projectRoot, 'assets');
const tsFiles = _listTsFiles(scanDir);
const index = new Map();
for (const tsPath of tsFiles) {
const metaPath = tsPath + '.meta';
if (!fs.existsSync(metaPath)) continue;
let src, meta;
try {
src = fs.readFileSync(tsPath, 'utf8');
meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
} catch (_) {
continue;
}
if (typeof meta.uuid !== 'string') continue;
const names = _extractCcClassNames(src);
if (names.length === 0) continue;
let classId;
try {
classId = compressUuid(meta.uuid);
} catch (_) {
continue;
}
for (const name of names) {
if (!index.has(name)) {
index.set(name, { uuid: meta.uuid, classId, scriptPath: tsPath });
}
}
}
return index;
}
function _getIndex(startPath) {
const projectRoot = _findProjectRoot(startPath);
if (_cache.has(projectRoot)) return _cache.get(projectRoot);
const idx = _buildIndex(projectRoot);
_cache.set(projectRoot, idx);
return idx;
}
/**
* 根据 @ccclass 名字解析对应的压缩 classId。
* @param {string} className 例如 'GMUI'
* @param {string} startPath 项目内任一路径(用于定位 projectRoot
* @returns {string|null} 命中返回 classId;未命中返回 null(调用方决定是否报错)
*/
function resolveClassIdByName(className, startPath) {
const idx = _getIndex(startPath);
const entry = idx.get(className);
return entry ? entry.classId : null;
}
/** 测试/诊断:列出所有 className → classId 对。 */
function listAll(startPath) {
const idx = _getIndex(startPath);
const out = {};
for (const [k, v] of idx.entries()) out[k] = v.classId;
return out;
}
function clearCache() {
_cache.clear();
}
module.exports = { resolveClassIdByName, listAll, clearCache };
+113
View File
@@ -0,0 +1,113 @@
// cli/anim-cmd.js — anim 子命令
//
// .anim 文件与 prefab 同为 JSON 数组 + __id__ 引用格式,editPrefab 可直接复用。
// 本子命令是封装:让用户语义上明确"这是动画文件操作",并允许 query 节点结构。
//
// 用法:
// anim query <anim> [--selector tree|node|find|field] ...
// anim batch <anim> <ops.json> [--dry-run]
//
// 注意:op 库当前面向 cc.Node 树设计,对 .anim 内的 cc.AnimationClip /
// cc.Track / cc.Curve 等结构无专属 op,但通用的 set-component-field(用于
// AnimationClip 顶层)/ set-component-ref / dedupe-component 等仍可用。
// 真正的 anim 曲线编辑应由 src/anim-primitives.js 暴露的 helper 在脚本中处理。
'use strict';
const fs = require('fs');
const path = require('path');
const { editPrefab } = require('../editor/index.js');
const { queryPrefab } = require('../query/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdAnim(args) {
const sub = args[0];
const rest = args.slice(1);
if (!sub || sub === '--help' || sub === '-h') {
process.stdout.write(`anim <subcommand> <file> [args]
Subcommands:
query <anim> [--selector tree|node|find|field] ...
batch <anim> <ops.json> [--project-root <path>] [--dry-run]
注意:.anim 与 .prefab 同为 JSON 数组 + __id__ 引用格式,复用 editPrefab。
op 库当前主要面向 cc.Node 树;编辑动画曲线请用 src/anim-primitives.js
暴露的 helper 在脚本中处理。
`);
return;
}
if (sub === 'query') {
const { flags, positional } = parseFlags(rest);
const animArg = positional[0];
if (!animArg) die('anim query: 必须提供 <anim>');
const animPath = resolvePath(animArg);
if (!fs.existsSync(animPath)) die(`anim query: 文件不存在: ${animPath}`);
const selectorType = flags['selector'] || 'tree';
const withComps = flags['with-comps'] === true;
let selector;
if (selectorType === 'tree') selector = { type: 'tree', withComps };
else if (selectorType === 'node') selector = { type: 'node', name: flags['name'], withComps };
else if (selectorType === 'find') selector = { type: 'find', nodeType: flags['type'] };
else if (selectorType === 'field') selector = {
type: 'field', name: flags['name'], componentType: flags['comp'], field: flags['field'],
};
else die(`anim query: 不支持的 --selector "${selectorType}"`);
let result;
try {
result = queryPrefab(animPath, selector);
} catch (e) {
die('anim query 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return;
}
if (sub === 'batch') {
const { flags, positional } = parseFlags(rest);
const [animArg, opsArg] = positional;
if (!animArg) die('anim batch: 必须提供 <anim>');
if (!opsArg) die('anim batch: 必须提供 <ops.json>');
const animPath = resolvePath(animArg);
const opsPath = resolvePath(opsArg);
if (!fs.existsSync(animPath)) die(`anim batch: 文件不存在: ${animPath}`);
if (!fs.existsSync(opsPath)) die(`anim batch: ops 文件不存在: ${opsPath}`);
let ops;
try {
ops = JSON.parse(fs.readFileSync(opsPath, 'utf8'));
} catch (e) {
die('anim batch: ops.json 解析失败: ' + e.message);
}
if (!Array.isArray(ops)) die('anim batch: ops.json 必须是数组');
const editOptions = {};
if (flags['project-root']) editOptions.projectRoot = resolvePath(flags['project-root']);
if (flags['dry-run'] === true) editOptions.dryRun = true;
let result;
try {
result = editPrefab(animPath, ops, editOptions);
} catch (e) {
die('anim batch 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return;
}
die(`anim: 未知子命令 "${sub}",可用: query / batch`);
}
module.exports = { cmdAnim };
+178
View File
@@ -0,0 +1,178 @@
// cli/batch-cmd.js — batch 子命令
//
// 用法:
// batch <prefab> <ops.json> [--project-root P] [--dry-run]
// batch <ops.json> --glob <pattern> [--project-root P] [--dry-run]
//
// --glob:把第一个位置参数当 ops.json,对所有匹配 pattern 的 prefab 跑同一组 ops
'use strict';
const fs = require('fs');
const path = require('path');
const { editPrefab } = require('../editor/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
// glob 匹配(自实现,支持 ** / * / ?)
// ** 匹配任意层目录(包括 0 层)
// * 匹配一段非斜杠字符
// ? 匹配单字符
function _globToRegex(pattern) {
let re = '^';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*' && pattern[i + 1] === '*') {
// 处理 **/ 和 ** 末尾
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else if (c === '*') {
re += '[^/]*';
i++;
} else if (c === '?') {
re += '[^/]';
i++;
} else if ('.+()|[]{}^$\\'.includes(c)) {
re += '\\' + c;
i++;
} else {
re += c;
i++;
}
}
re += '$';
return new RegExp(re);
}
function _walk(dir, relRoot, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (_) {
return;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
const rel = path.relative(relRoot, full);
if (ent.isDirectory()) {
// 跳过 node_modules / .git 等
if (ent.name === 'node_modules' || ent.name === '.git') continue;
_walk(full, relRoot, out);
} else if (ent.isFile()) {
out.push(rel);
}
}
}
function _expandGlob(pattern) {
// 取 pattern 里第一个含通配符之前的目录段作为扫描根
const cwd = process.cwd();
const segments = pattern.split('/');
const baseSegs = [];
for (const s of segments) {
if (s.includes('*') || s.includes('?')) break;
baseSegs.push(s);
}
const baseRel = baseSegs.join('/');
const baseAbs = baseRel ? path.resolve(cwd, baseRel) : cwd;
if (!fs.existsSync(baseAbs)) return [];
const all = [];
if (fs.statSync(baseAbs).isFile()) {
return [path.resolve(cwd, pattern)];
}
_walk(baseAbs, cwd, all);
const re = _globToRegex(pattern);
return all.filter((rel) => re.test(rel)).map((rel) => path.resolve(cwd, rel));
}
function cmdBatch(args) {
const { flags, positional } = parseFlags(args);
const editOptions = {};
if (flags['project-root']) {
editOptions.projectRoot = resolvePath(flags['project-root']);
}
if (flags['dry-run'] === true) {
editOptions.dryRun = true;
}
// glob 模式:第一个位置参数当 ops.json
if (flags['glob']) {
const opsArg = positional[0];
if (!opsArg) die('batch --glob: 必须提供 <ops.json>');
const opsPath = resolvePath(opsArg);
if (!fs.existsSync(opsPath)) die(`batch --glob: ops 文件不存在: ${opsPath}`);
const ops = _readOps(opsPath);
const matched = _expandGlob(flags['glob']);
if (matched.length === 0) {
die(`batch --glob: pattern "${flags['glob']}" 未匹配任何文件`);
}
const summary = [];
for (const prefabPath of matched) {
try {
const result = editPrefab(prefabPath, ops, editOptions);
summary.push({ file: path.relative(process.cwd(), prefabPath), ...result });
} catch (e) {
summary.push({ file: path.relative(process.cwd(), prefabPath), error: e.message });
}
}
process.stdout.write(JSON.stringify({ matchedCount: matched.length, results: summary }, null, 2) + '\n');
return;
}
// 单文件模式
const [prefabArg, opsArg] = positional;
if (!prefabArg) die('batch: 必须提供 <prefab>');
if (!opsArg) die('batch: 必须提供 <ops.json>');
const prefabPath = resolvePath(prefabArg);
if (!fs.existsSync(prefabPath)) die(`batch: prefab 文件不存在: ${prefabPath}`);
const ops = opsArg === '-'
? _readOpsRaw(fs.readFileSync('/dev/stdin', 'utf8'))
: _readOps(resolvePath(opsArg));
let result;
try {
result = editPrefab(prefabPath, ops, editOptions);
} catch (e) {
die('batch 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
function _readOps(opsPath) {
if (!fs.existsSync(opsPath)) die(`batch: ops 文件不存在: ${opsPath}`);
return _readOpsRaw(fs.readFileSync(opsPath, 'utf8'));
}
function _readOpsRaw(raw) {
let ops;
try {
ops = JSON.parse(raw);
} catch (e) {
die('batch: ops.json 解析失败: ' + e.message);
}
if (!Array.isArray(ops)) die('batch: ops.json 必须是数组');
return ops;
}
module.exports = { cmdBatch };
+141
View File
@@ -0,0 +1,141 @@
// ============================================================
// cli/build-cmd.js — Cocos CLI 命令行打包(headless,不依赖编辑器/MCP
//
// 封装 Cocos Creator 的命令行构建:
// CocosCreator --project <path> --build "configPath=<json>"
// CocosCreator --project <path> --build "platform=<plat>;debug=<bool>"
//
// 退出码:Cocos 退出非 0 常见于 postBuild 阶段的资源警告等非致命问题(主构建其实成功)。
// 默认照产物存在性判定:产物齐了就退 0(成功),产物缺才透传 Cocos 退出码(失败)。
// --strict 关掉这个兜底,直接透传 Cocos 退出码。
//
// 用法:
// cocos-mcp-cli build --project <path> --version 3.8.8 --config <buildConfig.json>
// cocos-mcp-cli build --project <path> --cocos <CocosCreator可执行> --platform web-mobile
// cocos-mcp-cli build ... --dry-run # 只打印命令不真跑
// cocos-mcp-cli build ... --strict # 严格透传 Cocos 退出码(不做产物兜底)
// ============================================================
'use strict';
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
function die(msg) { process.stderr.write('Error: ' + msg + '\n'); process.exit(1); }
function parseArgs(rest) {
const a = { project: '', cocos: '', version: '', config: '', platform: '', debug: false, dryRun: false, strict: false };
for (let i = 0; i < rest.length; i++) {
const k = rest[i];
if (k === '--project' || k === '-p') a.project = rest[++i];
else if (k === '--cocos' || k === '-c') a.cocos = rest[++i];
else if (k === '--version' || k === '-v') a.version = rest[++i];
else if (k === '--config') a.config = rest[++i];
else if (k === '--platform') a.platform = rest[++i];
else if (k === '--debug') a.debug = true;
else if (k === '--dry-run') a.dryRun = true;
else if (k === '--strict') a.strict = true;
else die(`未知参数 "${k}"。用法见 cocos-mcp-cli build --help`);
}
return a;
}
// 解析 CocosCreator 可执行:--cocos 显式优先,否则 --version 拼标准安装路径(macOS
function resolveCocos(a) {
if (a.cocos) {
if (!fs.existsSync(a.cocos)) die(`--cocos 路径不存在: ${a.cocos}`);
return a.cocos;
}
if (a.version) {
const p = `/Applications/Cocos/Creator/${a.version}/CocosCreator.app/Contents/MacOS/CocosCreator`;
if (!fs.existsSync(p)) die(`版本 ${a.version} 不在标准路径: ${p}\n 用 --cocos <可执行绝对路径> 显式指定`);
return p;
}
die('需指定 CocosCreator 可执行:--cocos <path> 或 --version <如 3.8.8>');
}
// 退出非 0 时按产物存在性判定主构建是否成功。true=产物齐 / false=产物缺 / null=无法定位产物目录
function checkArtifact(a) {
let outDir;
if (a.platform) {
// outputName 默认等于 platform;非默认场景请用 --config
outDir = path.join(a.project, 'build', a.platform);
} else if (a.config) {
try {
const cfg = JSON.parse(fs.readFileSync(a.config, 'utf8'));
const bp = String(cfg.buildPath || 'project://build').replace(/^project:\/\//, a.project + path.sep);
const on = cfg.outputName || cfg.platform || '';
outDir = path.join(bp, on);
} catch (e) { return null; }
}
if (!outDir || !fs.existsSync(outDir)) return false;
// 关键产物标志:index.html(web) / game.json(小游戏) / application.js
return ['index.html', 'game.json', 'application.js'].some(function (m) { return fs.existsSync(path.join(outDir, m)); });
}
function cmdBuild(rest) {
if (rest[0] === '--help' || rest[0] === '-h') {
process.stdout.write(
'cocos-mcp-cli build — Cocos 命令行打包(headless\n\n' +
' --project, -p <path> 项目根目录(必填)\n' +
' --version, -v <ver> Cocos 版本(拼标准安装路径,如 3.8.8)\n' +
' --cocos, -c <path> 或直接给 CocosCreator 可执行绝对路径(优先于 --version\n' +
' --config <json> 构建配置文件(→ --build "configPath=<json>"\n' +
' --platform <plat> 或按平台构建(→ --build "platform=<plat>"),如 web-mobile / alipay-mini-game\n' +
' --debug 平台构建时 debug=true(默认 false\n' +
' --dry-run 只打印将执行的命令,不真跑\n' +
' --strict 严格透传 Cocos 退出码(默认会按产物存在性兜底,把 postBuild 非致命的非 0 当成功)\n\n' +
'示例:\n' +
' cocos-mcp-cli build -p /path/to/forest -v 3.8.8 --config /path/build.json\n' +
' cocos-mcp-cli build -p /path/to/forest -v 3.8.8 --platform web-mobile --dry-run\n');
return;
}
const a = parseArgs(rest);
if (!a.project) die('缺 --project <项目路径>');
if (!fs.existsSync(a.project)) die(`项目路径不存在: ${a.project}`);
if (!a.config && !a.platform) die('需指定构建配置:--config <buildConfig.json> 或 --platform <平台>');
const cocos = resolveCocos(a);
let buildArg;
if (a.config) {
if (!fs.existsSync(a.config)) die(`--config 文件不存在: ${a.config}`);
buildArg = `configPath=${a.config}`;
} else {
buildArg = `platform=${a.platform};debug=${a.debug ? 'true' : 'false'}`;
}
const argv = ['--project', a.project, '--build', buildArg];
if (a.dryRun) {
process.stdout.write('[dry-run] 将执行:\n ' + cocos + ' --project ' + a.project + ' --build "' + buildArg + '"\n');
return;
}
process.stdout.write('Cocos CLI build 启动(headless,可能耗时几分钟):\n --project ' + a.project + '\n --build "' + buildArg + '"\n\n');
const child = spawn(cocos, argv, { stdio: 'inherit' });
child.on('error', function (e) { die('启动 Cocos 失败: ' + e.message); });
child.on('exit', function (code) {
if (code === 0) {
process.stdout.write('\nCocos 退出码: 0 — 构建成功\n');
process.exit(0);
}
const arti = a.strict ? null : checkArtifact(a);
if (arti === true) {
process.stdout.write('\nCocos 退出码: ' + code + '(非 0),但产物已生成 → 判定主构建成功(非 0 多为 postBuild 资源警告等非致命问题)。\n');
process.exit(0);
}
if (arti === false) {
process.stdout.write('\nCocos 退出码: ' + code + ',产物未生成 → 构建失败。\n');
process.exit(code == null ? 1 : code);
}
// null--strict 或无法定位产物目录(如 --config 解析失败)→ 透传
process.stdout.write('\nCocos 退出码: ' + (code == null ? '(被信号终止)' : code) +
(a.strict ? '--strict,透传)' : '(无法定位产物目录,透传)') + '\n');
process.exit(code == null ? 1 : code);
});
}
module.exports = { cmdBuild };
+136
View File
@@ -0,0 +1,136 @@
// ============================================================
// compact-cmd.js — 清理 prefab data 数组里的 null 槽位 + 重映射 __id__
//
// 用途:早期某些 prefab(比如 extract-prefab 命令上线前手工生成的)含 null 槽位,
// Cocos editor 反序列化容错跳过,但 build worker 严格 scan 撞 null 崩
// 「Cannot read properties of undefined (reading '__type__')」。
//
// 算法(跟 extract-cmd line 105-132 同款,但不剔除任何东西):
// 1) 收集所有 null 索引
// 2) 构造 oldIdx → newIdx 映射(newIdx = oldIdx - 前面 null 数量)
// 3) newData = data.filter(el => el !== null)
// 4) 递归重映射所有 __id__ 引用
//
// 用法:
// cocos-mcp-cli compact-prefab <prefab> [--dry-run]
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function cmdCompactPrefab(argv) {
let prefabPath = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--dry-run') {
dryRun = true;
} else if (arg.startsWith('--')) {
die(`未知参数 "${arg}"`);
} else if (prefabPath === null) {
prefabPath = arg;
} else {
die(`多余位置参数 "${arg}"`);
}
}
if (!prefabPath) die('需要 <prefab> 路径参数');
compactOne(prefabPath, dryRun);
}
function compactOne(prefabPath, dryRun) {
const abs = path.resolve(process.cwd(), prefabPath);
if (!fs.existsSync(abs)) die(`prefab 不存在: ${prefabPath}`);
const raw = fs.readFileSync(abs, 'utf8');
let data;
try {
data = JSON.parse(raw);
} catch (e) {
die(`JSON 解析失败 (${prefabPath}): ${e.message}`);
}
if (!Array.isArray(data)) die(`不是 prefab 数据数组: ${prefabPath}`);
// 1) 收集 null 索引
const nullIdxs = [];
data.forEach((el, i) => { if (el === null) nullIdxs.push(i); });
if (nullIdxs.length === 0) {
process.stdout.write(`${prefabPath} → 无 null 槽位,无需 compact\n`);
return;
}
// 2) 构造 oldIdx → newIdx 映射
const oldToNew = new Map();
let newIdx = 0;
for (let i = 0; i < data.length; i++) {
if (data[i] !== null) {
oldToNew.set(i, newIdx);
newIdx++;
}
}
// 3) 新数组
const newData = data.filter((el) => el !== null);
// 4) 重映射 __id__;跟踪指向已删 null 的 dangling 引用
let danglingRefs = 0;
const danglingDetails = [];
for (let i = 0; i < newData.length; i++) {
_remapIds(newData[i], oldToNew, '[' + i + ']', danglingDetails);
}
danglingRefs = danglingDetails.length;
// 5) Dry-run / Apply
const summary = `${prefabPath}${data.length}${newData.length} (清掉 ${nullIdxs.length} 个 null)`;
process.stdout.write(summary + (dryRun ? ' [dry-run]' : '') + '\n');
if (nullIdxs.length <= 50) {
process.stdout.write(' 原 null 索引: ' + nullIdxs.join(',') + '\n');
} else {
process.stdout.write(' 原 null 索引(前 20: ' + nullIdxs.slice(0, 20).join(',') + ' ... (共 ' + nullIdxs.length + ')\n');
}
if (danglingRefs > 0) {
process.stderr.write(`${danglingRefs} 个 __id__ 引用原本指向 null 槽位(已置 null):\n`);
danglingDetails.slice(0, 5).forEach((d) => process.stderr.write(' ' + d + '\n'));
if (danglingDetails.length > 5) process.stderr.write(` ... 共 ${danglingDetails.length}\n`);
}
if (dryRun) return;
fs.writeFileSync(abs, JSON.stringify(newData, null, 2) + '\n', 'utf8');
process.stdout.write(` ✓ 写入 ${prefabPath}\n`);
}
function _remapIds(obj, map, location, dangling) {
if (obj === null || obj === undefined) return;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) _remapIds(obj[i], map, location + '[' + i + ']', dangling);
return;
}
if (typeof obj === 'object') {
if (typeof obj.__id__ === 'number') {
const oldId = obj.__id__;
const newId = map.get(oldId);
if (newId === undefined) {
// 引用指向 null 槽位 —— 把 __id__ 置 null
obj.__id__ = null;
dangling.push(location + ' → __id__:' + oldId);
} else {
obj.__id__ = newId;
}
return;
}
for (const k of Object.keys(obj)) _remapIds(obj[k], map, location + '.' + k, dangling);
}
}
module.exports = { cmdCompactPrefab };
+160
View File
@@ -0,0 +1,160 @@
// ============================================================
// cli/create-cmd.js — create-prefab 子命令
//
// 用法:
// cocos-mcp-cli create-prefab <output-path>
// [--name <name>] [--width <w>] [--height <h>]
// [--add-spine <skel-uuid>] [--dry-run]
//
// 生成最小 prefabroot 节点 + UITransform+ 配套 .prefab.meta。
//
// 加 --add-spine <uuid> 时,在 root 节点上多挂一个 sp.Skeleton 组件,
// _skeletonData.__uuid__ 指向给定的 .skel 资产 UUID。批量生成 spine prefab
// 推荐外层 shell 循环 + N 次调用:
//
// for meta in assets/res/<group>/<xxxN>/*.skel.meta; do
// name=$(basename "$meta" .skel.meta)
// uuid=$(node -e 'console.log(require("./"+process.argv[1]).uuid)' "$meta")
// node bin/cocos-mcp-cli.js create-prefab \
// "assets/packages/<group>/<xxxN>/prefab/${name}.prefab" --add-spine "$uuid"
// done
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { deterministicUUID, deterministicFileId } = require('../id.js');
const {
makePrefabRoot,
makeNode,
makeUITransform,
makePrefabInfo,
makeCompPrefabInfo,
makeSpSkeleton,
} = require('../primitives.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function cmdCreatePrefab(argv) {
let outputPath = null;
let name = null;
let width = null;
let height = null;
let dryRun = false;
let spineSkelUuid = null;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--name') {
name = argv[++i];
if (!name) die('--name 需要一个值');
} else if (arg === '--width') {
width = Number(argv[++i]);
if (isNaN(width)) die('--width 必须是数字');
} else if (arg === '--height') {
height = Number(argv[++i]);
if (isNaN(height)) die('--height 必须是数字');
} else if (arg === '--add-spine') {
spineSkelUuid = argv[++i];
if (!spineSkelUuid) die('--add-spine 需要一个 .skel 资产 UUID');
} else if (arg === '--dry-run') {
dryRun = true;
} else if (!arg.startsWith('--')) {
if (outputPath !== null) die('多余的位置参数: ' + arg);
outputPath = arg;
} else {
die(`未知参数 "${arg}"`);
}
}
if (!outputPath) {
die('用法: create-prefab <output-path> [--name <name>] [--width <w>] [--height <h>] [--add-spine <skel-uuid>] [--dry-run]');
}
// 确保 .prefab 后缀
if (!outputPath.endsWith('.prefab')) outputPath += '.prefab';
// 推断 prefab 名称(basename 去掉 .prefab
if (!name) name = path.basename(outputPath, '.prefab');
// 默认 UITransform 尺寸:spine prefab 走 100×100sp.Skeleton 自己控制渲染,
// contentSize 不影响运行时;与现有 sk_loading.prefab 等产物对齐),
// 普通 UI prefab 走 750×200
if (width === null) width = spineSkelUuid ? 100 : 750;
if (height === null) height = spineSkelUuid ? 100 : 200;
// 确定性 ID(以名称为种子,保证同名 prefab 每次生成相同 UUID
const seed = `create-prefab:${name}`;
const prefabUuid = deterministicUUID(`${seed}:uuid`);
const rootFileId = deterministicFileId(`${seed}:root:fid`);
const uitransformFileId = deterministicFileId(`${seed}:uitransform:fid`);
// 索引分配 — 不带 spine(5 条):
// 0 cc.Prefab
// 1 cc.Node (root)
// 2 cc.UITransform
// 3 cc.CompPrefabInfo (UITransform 的)
// 4 cc.PrefabInfo (root 的)
// 带 spine7 条):
// 0 cc.Prefab
// 1 cc.Node (root, _components: [2, 4])
// 2 cc.UITransform
// 3 cc.CompPrefabInfo (UITransform 的)
// 4 sp.Skeleton
// 5 cc.CompPrefabInfo (sp.Skeleton 的)
// 6 cc.PrefabInfo (root 的)
let data;
if (spineSkelUuid) {
const spineFileId = deterministicFileId(`${seed}:sp.Skeleton:fid`);
data = [
makePrefabRoot({ name, rootId: 1 }),
makeNode({ name, componentIds: [2, 4], prefabId: 6 }),
makeUITransform({ nodeId: 1, width, height, prefabInfoId: 3 }),
makeCompPrefabInfo(uitransformFileId),
makeSpSkeleton({ nodeId: 1, skeletonUuid: spineSkelUuid, prefabInfoId: 5 }),
makeCompPrefabInfo(spineFileId),
makePrefabInfo({ rootId: 1, fileId: rootFileId, assetId: 0 }),
];
} else {
data = [
makePrefabRoot({ name, rootId: 1 }),
makeNode({ name, componentIds: [2], prefabId: 4 }),
makeUITransform({ nodeId: 1, width, height, prefabInfoId: 3 }),
makeCompPrefabInfo(uitransformFileId),
makePrefabInfo({ rootId: 1, fileId: rootFileId, assetId: 0 }),
];
}
const meta = {
ver: '1.1.50',
importer: 'prefab',
imported: true,
uuid: prefabUuid,
files: ['.json'],
subMetas: {},
userData: { syncNodeName: name },
};
if (dryRun) {
process.stdout.write('=== PREFAB ===\n');
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
process.stdout.write('\n=== META ===\n');
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
return;
}
const dir = path.dirname(outputPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.writeFileSync(outputPath + '.meta', JSON.stringify(meta, null, 2) + '\n', 'utf8');
process.stdout.write(`created: ${outputPath}\n`);
process.stdout.write(`created: ${outputPath}.meta\n`);
}
module.exports = { cmdCreatePrefab };
+60
View File
@@ -0,0 +1,60 @@
// cli/diff-cmd.js — diff 子命令(比较两个 prefab 的字段级差异)
//
// 用法:
// diff <prefabA> <prefabB>
//
// 输出与 batch --dry-run 同格式:
// { diff: [{ id, type, name, changes: { 'a.b.c': [oldVal, newVal] } }] }
//
// 适用于:CI 验证转换工具产物 / 对照历史版本 / review 自动 diff
'use strict';
const fs = require('fs');
const path = require('path');
const { parsePrefab } = require('../parse.js');
const { computeDiff } = require('../editor/diff.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdDiff(args) {
const { positional } = parseFlags(args);
const [a, b] = positional;
if (!a) die('diff: 必须提供 <prefabA>');
if (!b) die('diff: 必须提供 <prefabB>');
const aPath = resolvePath(a);
const bPath = resolvePath(b);
if (!fs.existsSync(aPath)) die(`diff: 文件不存在: ${aPath}`);
if (!fs.existsSync(bPath)) die(`diff: 文件不存在: ${bPath}`);
let aData;
let bData;
try {
aData = parsePrefab(aPath);
bData = parsePrefab(bPath);
} catch (e) {
die('diff: 解析失败: ' + e.message);
}
const diff = computeDiff(aData.elements, bData.elements);
const result = {
a: path.relative(process.cwd(), aPath),
b: path.relative(process.cwd(), bPath),
elementsA: aData.elements.length,
elementsB: bData.elements.length,
changedCount: diff.length,
diff,
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
module.exports = { cmdDiff };
+99
View File
@@ -0,0 +1,99 @@
// ============================================================
// cli/ensure-meta-cmd.js — ensure-meta 子命令
//
// 用法:
// cocos-mcp-cli ensure-meta <file-path> [--dry-run]
//
// 若目标文件已有同名 .meta → 跳过(幂等)。
// 若没有 .meta → 按扩展名生成对应格式的 .meta 文件。
//
// 支持的文件类型:
// .ts / .js → typescript importerver 4.0.24
// .json → json importerver 2.0.1
//
// 典型场景:新建脚本后 Cocos 编辑器未就绪时,先手动补 .meta。
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { randomUUID } = require('crypto');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function makeMeta(ext, uuid) {
if (ext === '.ts' || ext === '.js') {
return {
ver: '4.0.24',
importer: 'typescript',
imported: true,
uuid,
files: [],
subMetas: {},
userData: {},
};
}
if (ext === '.json') {
return {
ver: '2.0.1',
importer: 'json',
imported: true,
uuid,
files: ['.json'],
subMetas: {},
userData: {},
};
}
return null;
}
function cmdEnsureMeta(argv) {
let filePath = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--dry-run') {
dryRun = true;
} else if (!arg.startsWith('--')) {
if (filePath !== null) die('多余的位置参数: ' + arg);
filePath = arg;
} else {
die(`未知参数 "${arg}"`);
}
}
if (!filePath) {
die('用法: ensure-meta <file-path> [--dry-run]\n支持类型: .ts .js .json');
}
if (!fs.existsSync(filePath)) {
die(`文件不存在: ${filePath}`);
}
const metaPath = filePath + '.meta';
if (fs.existsSync(metaPath)) {
process.stdout.write(`skip (already exists): ${metaPath}\n`);
return;
}
const ext = path.extname(filePath).toLowerCase();
const meta = makeMeta(ext, randomUUID());
if (!meta) {
die(`不支持的文件类型 "${ext}",支持: .ts .js .json`);
}
if (dryRun) {
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
return;
}
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf8');
process.stdout.write(`created: ${metaPath}\n`);
}
module.exports = { cmdEnsureMeta };
+238
View File
@@ -0,0 +1,238 @@
// ============================================================
// cli/extract-cmd.js — extract-prefab 子命令
//
// 用法:
// cocos-mcp-cli extract-prefab <src-prefab> <out-prefab>
// --node <selector> [--name <new-name>] [--dry-run]
//
// 把 src-prefab 中某个子节点连同其整棵子树(含组件 / PrefabInfo /
// 嵌套 PrefabInstance / propertyOverrides / TargetInfo / mountedComponents
// 等所有 __id__ 引用闭包)提取出来,构造一个独立的新 prefab + .meta。
//
// 跟 batch op clone-node 的区别:
// - clone-node 在同 prefab 内复制 + 挂到 parent
// - extract-prefab 写出到新 .prefab 文件(含 cc.Prefab 头),脱离源文件
//
// 典型场景:把 HomeBottom 上的 btnTask 子树提取成独立的 task BottomEntry.prefab。
//
// selector 接受 batch 同款三种形式:
// "btnTask"
// { "id": 13 }
// { "path": "btnTask" }
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { deterministicUUID } = require('../id.js');
const { parsePrefab } = require('../parse.js');
const { resolveNode } = require('../editor/helpers.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function parseSelector(raw) {
// 支持 --node btnTask 或 --node '{"id":13}'
const t = raw.trim();
if (t.startsWith('{')) {
try { return JSON.parse(t); } catch (e) {
die(`--node JSON 解析失败: ${t} (${e.message})`);
}
}
return t;
}
function cmdExtractPrefab(argv) {
let srcPath = null;
let outPath = null;
let nodeSelector = null;
let newName = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--node') {
nodeSelector = parseSelector(argv[++i] ?? '');
if (!nodeSelector) die('--node 需要一个值');
} else if (arg === '--name') {
newName = argv[++i];
if (!newName) die('--name 需要一个值');
} else if (arg === '--dry-run') {
dryRun = true;
} else if (!arg.startsWith('--')) {
if (srcPath === null) srcPath = arg;
else if (outPath === null) outPath = arg;
else die('多余的位置参数: ' + arg);
} else {
die(`未知参数 "${arg}"`);
}
}
if (!srcPath || !outPath) {
die('用法: extract-prefab <src-prefab> <out-prefab> --node <selector> [--name <new-name>] [--dry-run]');
}
if (nodeSelector === null) die('--node 是必需参数');
const srcAbs = path.resolve(process.cwd(), srcPath);
if (!fs.existsSync(srcAbs)) die(`源 prefab 不存在: ${srcPath}`);
// 确保 .prefab 后缀
if (!outPath.endsWith('.prefab')) outPath += '.prefab';
if (!newName) newName = path.basename(outPath, '.prefab');
// 1) 解析源 prefab
const prefabData = parsePrefab(srcAbs);
const { elements } = prefabData;
const { nodeId: srcNodeId } = resolveNode(prefabData, nodeSelector, 'extract-prefab');
// 2) BFS 闭包收集:从 srcNodeId 出发,把所有递归引用的 __id__ 拉进来
const collected = new Set();
const queue = [srcNodeId];
while (queue.length > 0) {
const idx = queue.shift();
if (collected.has(idx)) continue;
collected.add(idx);
const refs = [];
_walkCollect(elements[idx], refs);
for (const r of refs) {
if (!collected.has(r)) queue.push(r);
}
}
// 3) 重新编号:new[0] = 新 cc.Prefab 头部,new[1] = srcNoderoot),其余按原 idx 升序
const oldToNew = new Map();
const sortedOld = [srcNodeId, ...[...collected].filter((i) => i !== srcNodeId).sort((a, b) => a - b)];
const newData = [];
// new[0]: 复制源 prefab 头部模板(只用 __type__ / data / optimizationPolicy / persistent 等基础字段)
const srcHead = elements[0] && elements[0].__type__ === 'cc.Prefab' ? elements[0] : null;
const newHead = {
__type__: 'cc.Prefab',
_name: '',
_objFlags: 0,
__editorExtras__: {},
_native: '',
data: { __id__: 1 },
optimizationPolicy: srcHead && srcHead.optimizationPolicy !== undefined ? srcHead.optimizationPolicy : 0,
persistent: srcHead && srcHead.persistent !== undefined ? srcHead.persistent : false,
};
newData.push(newHead);
for (let i = 0; i < sortedOld.length; i++) {
oldToNew.set(sortedOld[i], i + 1);
newData.push(_deepClone(elements[sortedOld[i]]));
}
// 4) Remap __id__ 引用
for (let i = 1; i < newData.length; i++) {
_remapIds(newData[i], oldToNew);
}
// 5) 修正根节点:_parent=null, _name=newName
const newRoot = newData[1];
newRoot._parent = null;
newRoot._name = newName;
// 6) 修正根节点 _prefabPrefabInfo):root 指向新根 idx 1asset 指向 idx 0
if (newRoot._prefab && typeof newRoot._prefab.__id__ === 'number') {
const rootPInfo = newData[newRoot._prefab.__id__];
if (rootPInfo && rootPInfo.__type__ === 'cc.PrefabInfo') {
rootPInfo.root = { __id__: 1 };
rootPInfo.asset = { __id__: 0 };
// 这些字段在源 prefab 是相对宿主 prefab 的,新 prefab 是独立的,清掉
if ('instance' in rootPInfo) rootPInfo.instance = null;
if ('targetOverrides' in rootPInfo) rootPInfo.targetOverrides = null;
if ('nestedPrefabInstanceRoots' in rootPInfo) rootPInfo.nestedPrefabInstanceRoots = null;
}
}
// 7) 生成 meta
const seed = `extract-prefab:${outPath}:${newName}`;
const newUuid = deterministicUUID(`${seed}:uuid`);
const meta = {
ver: '1.1.50',
importer: 'prefab',
imported: true,
uuid: newUuid,
files: ['.json'],
subMetas: {},
userData: { syncNodeName: newName },
};
if (dryRun) {
process.stdout.write('=== PREFAB ===\n');
process.stdout.write(JSON.stringify(newData, null, 2) + '\n');
process.stdout.write('\n=== META ===\n');
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
process.stdout.write(`\n=== STATS ===\ncollected ${collected.size} objects from source idx ${srcNodeId}\n`);
return;
}
const outAbs = path.resolve(process.cwd(), outPath);
const dir = path.dirname(outAbs);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(outAbs, JSON.stringify(newData, null, 2) + '\n', 'utf8');
fs.writeFileSync(outAbs + '.meta', JSON.stringify(meta, null, 2) + '\n', 'utf8');
process.stdout.write(`created: ${outPath} (${collected.size} objects)\n`);
process.stdout.write(`created: ${outPath}.meta\n`);
}
// ── internals ────────────────────────────────────────────
// 跳过的字段:_parent 反向引用会把父链/兄弟子树拖进闭包,破坏"只提取子树"的语义
const SKIP_KEYS = new Set(['_parent']);
function _walkCollect(obj, out) {
if (obj === null || obj === undefined) return;
if (Array.isArray(obj)) {
for (const v of obj) _walkCollect(v, out);
return;
}
if (typeof obj === 'object') {
if (typeof obj.__id__ === 'number') {
out.push(obj.__id__);
return;
}
for (const k of Object.keys(obj)) {
if (SKIP_KEYS.has(k)) continue;
_walkCollect(obj[k], out);
}
}
}
function _deepClone(obj) {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(_deepClone);
const out = {};
for (const k of Object.keys(obj)) out[k] = _deepClone(obj[k]);
return out;
}
function _remapIds(obj, map) {
if (obj === null || obj === undefined) return;
if (Array.isArray(obj)) {
for (const v of obj) _remapIds(v, map);
return;
}
if (typeof obj === 'object') {
if (typeof obj.__id__ === 'number') {
const newId = map.get(obj.__id__);
if (newId !== undefined) {
obj.__id__ = newId;
} else {
// 引用集合外的 idx —— 闭包应该完整,理论不会发生
obj.__id__ = null;
}
return;
}
for (const k of Object.keys(obj)) _remapIds(obj[k], map);
}
}
module.exports = { cmdExtractPrefab };
+29
View File
@@ -0,0 +1,29 @@
// cli/flags.js — argv 中提取 --key value 形式的选项
'use strict';
function parseFlags(argv) {
const flags = {};
const positional = [];
let i = 0;
while (i < argv.length) {
const arg = argv[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[key] = next;
i += 2;
} else {
flags[key] = true;
i += 1;
}
} else {
positional.push(arg);
i += 1;
}
}
return { flags, positional };
}
module.exports = { parseFlags };
+87
View File
@@ -0,0 +1,87 @@
// cli/help.js — 帮助信息
'use strict';
function printHelp() {
process.stdout.write(`cocos-mcp-cli — CC3 prefab 离线编辑工具
Usage:
cocos-mcp-cli query <prefab> [--selector tree|node|find|field] [--name X] [--type cc.Label]
[--comp cc.UITransform] [--field _anchorPoint] [--with-comps]
cocos-mcp-cli set <prefab> <nodeName> <field> <value>
cocos-mcp-cli batch <prefab> <ops.json> [--project-root <projectRoot>] [--dry-run]
cocos-mcp-cli batch <ops.json> --glob <pattern> [--project-root <path>] [--dry-run]
cocos-mcp-cli anim <subcommand> <file> [args] # subcommand: query | batch
cocos-mcp-cli diff <prefabA> <prefabB> # 字段级 diff
cocos-mcp-cli create-prefab <out> [--name X] [--width W] [--height H] [--add-spine <uuid>]
cocos-mcp-cli extract-prefab <src> <out> --node <selector> [--name X] [--dry-run]
Commands:
query 只读查询,输出 JSON
set 单条属性写入(field: active / label.text / position.x|y|z
batch 批量写入,ops.json 是 editPrefab ops 数组;--glob 跨多个 prefab
anim 操作 .anim 文件(与 prefab 同格式)
diff 比较两个 prefab 的字段级差异
create-prefab 创建空白 prefabroot + UITransform,可选 sp.Skeleton
extract-prefab 把 src 中的某个子节点闭包提取为独立 prefab(含组件 / PrefabInfo /
嵌套 PrefabInstance / overrides 的全部 __id__ 引用)
Query options:
--selector tree 节点树(默认)
--selector node 单节点详情,需要 --name <节点名>
--selector find 按 __type__ 列 id,需要 --type <类型>
--selector field 单组件单字段值,需要 --name --comp --field
--with-comps tree/node 下展开组件字段(输出 components: [{type,id,fields}]
Batch options:
--project-root <path> 指定含 assets/+package.json 的项目根;
当 prefab 放在 /tmp/ 等非项目目录时必须显式传入,
否则 className → classId 自动规范化会抛错(避免写入 className 字符串导致 cocos MissingScript)。
--dry-run 不写盘,输出会改的字段 diff{ path: [old, new] } 形式)。
Supported ops:
set-position / set-label-text / set-sprite-frame / set-active / rename-node
set-component-field # 普通节点改任意组件任意字段,property 接字符串或嵌套路径数组
set-component-enabled # 改 _enabled
set-anchor / set-size # cc.UITransform 锚点 / 尺寸便捷写法
# set-anchor 支持 compensatePosition 自动补偿 lpos
adjust-position # lpos 相对偏移
reorder-children # 调子节点顺序(影响渲染层级)
bulk-set # 按 selectorbyComponent/byNamePrefix/byNameRegex)一次改一批
add-node / remove-node / clone-node
add-component / set-component-ref # componentType 支持 @ccclass 名或压缩 classId
# set-component-ref 的 refSubNode 可用字符串数组走多层嵌套 stub
set-nested-component-field # 仅 stub 节点(嵌套 prefab)改组件字段
dedupe-component # 合并同节点重复组件
ensure-meta # 给新建 .ts/.json 创建 .metav4 uuid),
# 避免等 cocos 编辑器生成;放在 add-component 前即可
# 联动(同 batch 内 cache invalidate 重扫)
Component shortcuts(组件快捷 op,多字段一次设置):
set-editbox node + inputMode?/maxLength?/placeholder?/string?/inputFlag?/fontSize?
inputMode: 0=ANY 1=EMAIL 2=NUMERIC 3=PHONE 4=URL 5=DECIMAL 6=SINGLE_LINE
set-label node + text?/fontSize?/lineHeight?/overflow?/horizontalAlign?/verticalAlign?/bold?/italic?/underline?/enableWrapText?
overflow: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE
set-button node + interactable?/transition?/zoomScale?/duration?
transition: 0=NONE 1=COLOR 2=SPRITE 3=SCALE
set-layout node + type?/resizeMode?/paddingLeft?/paddingRight?/paddingTop?/paddingBottom?/spacingX?/spacingY?/startAxis?/constraint?/constraintNum?/affectedByScale?
type: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID
set-richtext node + text?/maxWidth?/fontSize?/lineHeight?
set-sprite node + sizeMode?/type?/grayscale?/trim?(换图用 set-sprite-frame
type: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH
set-node-color node + r?/g?/b?/a?0-255
节点定位三种形式(适用所有 op 的 node/parent/target/source/refNode):
"name" / { id: N } / { path: "Canvas/Main/itemList" }
Examples:
cocos-mcp-cli query HomeUI.prefab --selector tree --with-comps
cocos-mcp-cli query HomeUI.prefab --selector field --name itemList \\
--comp cc.UITransform --field _anchorPoint
cocos-mcp-cli set HomeUI.prefab btnClose label.text "关闭"
cocos-mcp-cli batch HomeUI.prefab ops.json
cocos-mcp-cli batch HomeUI.prefab ops.json --dry-run
`);
}
module.exports = { printHelp };
+63
View File
@@ -0,0 +1,63 @@
// ============================================================
// cli/main.js — 子命令分发入口
//
// 用法:
// cocos-mcp-cli query <prefab> [--selector ...] [--with-comps] ...
// cocos-mcp-cli set <prefab> <nodeName> <field> <value>
// cocos-mcp-cli batch <prefab> <ops.json> [--project-root ...] [--dry-run]
// ============================================================
'use strict';
const { printHelp } = require('./help.js');
const { cmdQuery } = require('./query-cmd.js');
const { cmdSet } = require('./set-cmd.js');
const { cmdBatch } = require('./batch-cmd.js');
const { cmdAnim } = require('./anim-cmd.js');
const { cmdDiff } = require('./diff-cmd.js');
const { cmdCreatePrefab } = require('./create-cmd.js');
const { cmdExtractPrefab } = require('./extract-cmd.js');
const { cmdCompactPrefab } = require('./compact-cmd.js');
const { cmdEnsureMeta } = require('./ensure-meta-cmd.js');
const { cmdBuild } = require('./build-cmd.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function main(argv) {
const cmd = argv[0];
const rest = argv.slice(1);
if (!cmd || cmd === '--help' || cmd === '-h') {
printHelp();
return;
}
if (cmd === 'query') {
cmdQuery(rest);
} else if (cmd === 'set') {
cmdSet(rest);
} else if (cmd === 'batch') {
cmdBatch(rest);
} else if (cmd === 'anim') {
cmdAnim(rest);
} else if (cmd === 'diff') {
cmdDiff(rest);
} else if (cmd === 'create-prefab') {
cmdCreatePrefab(rest);
} else if (cmd === 'extract-prefab') {
cmdExtractPrefab(rest);
} else if (cmd === 'compact-prefab') {
cmdCompactPrefab(rest);
} else if (cmd === 'ensure-meta') {
cmdEnsureMeta(rest);
} else if (cmd === 'build') {
cmdBuild(rest);
} else {
die(`未知子命令 "${cmd}",可用: query / set / batch / anim / diff / create-prefab / extract-prefab / compact-prefab / ensure-meta / build`);
}
}
module.exports = { main };
+81
View File
@@ -0,0 +1,81 @@
// cli/query-cmd.js — query 子命令
'use strict';
const fs = require('fs');
const path = require('path');
const { queryPrefab } = require('../query/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdQuery(args) {
const { flags, positional } = parseFlags(args);
const prefabArg = positional[0];
if (!prefabArg) die('query: 必须提供 <prefab> 路径');
const prefabPath = resolvePath(prefabArg);
if (!fs.existsSync(prefabPath)) die(`query: 文件不存在: ${prefabPath}`);
const selectorType = flags['selector'] || 'tree';
const withComps = flags['with-comps'] === true;
let selector;
if (selectorType === 'tree') {
selector = { type: 'tree', withComps };
} else if (selectorType === 'node') {
const name = flags['name'];
if (!name) die('query --selector node: 必须提供 --name <节点名>');
selector = { type: 'node', name, withComps };
} else if (selectorType === 'find') {
const nodeType = flags['type'];
if (!nodeType) die('query --selector find: 必须提供 --type <组件类型>');
selector = { type: 'find', nodeType };
} else if (selectorType === 'field') {
const name = flags['name'];
const compType = flags['comp'];
const field = flags['field'];
if (!name) die('query --selector field: 必须提供 --name <节点名>');
if (!compType) die('query --selector field: 必须提供 --comp <组件类型>');
if (!field) die('query --selector field: 必须提供 --field <字段名>');
selector = { type: 'field', name, componentType: compType, field };
} else if (selectorType === 'overrides') {
// --node 支持 name / --id N / --path A/B/C 三种
let nodeSel;
if (flags['id'] !== undefined) {
const idNum = Number(flags['id']);
if (!Number.isInteger(idNum) || idNum < 0) die('query --selector overrides: --id 必须是非负整数');
nodeSel = { id: idNum };
} else if (typeof flags['path'] === 'string' && flags['path'].length > 0) {
nodeSel = { path: flags['path'] };
} else if (typeof flags['node'] === 'string' && flags['node'].length > 0) {
nodeSel = flags['node'];
} else if (typeof flags['name'] === 'string' && flags['name'].length > 0) {
nodeSel = flags['name'];
} else {
die('query --selector overrides: 必须提供 --id N 或 --path A/B/C 或 --name <name>');
}
selector = { type: 'overrides', node: nodeSel };
} else {
die(`query: 不支持的 --selector 值 "${selectorType}",可选: tree / node / find / field / overrides`);
}
let result;
try {
result = queryPrefab(prefabPath, selector);
} catch (e) {
die('query 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
module.exports = { cmdQuery };
+79
View File
@@ -0,0 +1,79 @@
// cli/set-cmd.js — set 子命令(单字段快捷写入)
//
// field 支持:
// active → set-active (true/false)
// label.text → set-label-text
// position.x|y|z → set-position(只改一轴,其余保留)
'use strict';
const fs = require('fs');
const path = require('path');
const { parsePrefab } = require('../parse.js');
const { editPrefab } = require('../editor/index.js');
const { parseFlags } = require('./flags.js');
function die(msg) {
process.stderr.write('Error: ' + msg + '\n');
process.exit(1);
}
function resolvePath(p) {
return path.resolve(process.cwd(), p);
}
function cmdSet(args) {
const { positional } = parseFlags(args);
const [prefabArg, nodeName, field, rawValue] = positional;
if (!prefabArg) die('set: 必须提供 <prefab>');
if (!nodeName) die('set: 必须提供 <nodeName>');
if (!field) die('set: 必须提供 <field>');
if (rawValue === undefined) die('set: 必须提供 <value>');
const prefabPath = resolvePath(prefabArg);
if (!fs.existsSync(prefabPath)) die(`set: 文件不存在: ${prefabPath}`);
let op;
if (field === 'active') {
if (rawValue !== 'true' && rawValue !== 'false') {
die('set active: value 必须是 true 或 false');
}
op = { op: 'set-active', node: nodeName, active: rawValue === 'true' };
} else if (field === 'label.text') {
op = { op: 'set-label-text', node: nodeName, text: rawValue };
} else if (field === 'position.x' || field === 'position.y' || field === 'position.z') {
const axis = field.split('.')[1];
const num = parseFloat(rawValue);
if (isNaN(num)) die(`set ${field}: value 必须是数字`);
const prefabData = parsePrefab(prefabPath);
const node = prefabData.findNodeByName(nodeName);
if (!node) die(`set: 找不到节点 "${nodeName}"`);
const lpos = node._lpos || { x: 0, y: 0, z: 0 };
const newPos = { x: lpos.x || 0, y: lpos.y || 0, z: lpos.z || 0 };
newPos[axis] = num;
op = { op: 'set-position', node: nodeName, x: newPos.x, y: newPos.y, z: newPos.z };
} else {
die(
`set: 不支持的 field "${field}",支持:\n` +
' active\n' +
' label.text\n' +
' position.x / position.y / position.z'
);
}
let result;
try {
result = editPrefab(prefabPath, [op]);
} catch (e) {
die('set 失败: ' + e.message);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
module.exports = { cmdSet };
+59
View File
@@ -0,0 +1,59 @@
// ============================================================
// editor/diff.js — dry-run 用的 elements 字段级 diff
// 输出:[{ id, type, name, changes: { 'a.b.c': [old, new] } }]
// ============================================================
'use strict';
function computeDiff(before, after) {
const out = [];
const maxLen = Math.max(before.length, after.length);
for (let i = 0; i < maxLen; i++) {
const a = before[i];
const b = after[i];
if (a === undefined && b !== undefined) {
out.push({ id: i, type: 'added', after: b });
continue;
}
if (a !== undefined && b === undefined) {
out.push({ id: i, type: 'removed', before: a });
continue;
}
const changes = {};
diffObject(a, b, '', changes);
if (Object.keys(changes).length > 0) {
out.push({
id: i,
type: b && b.__type__ ? b.__type__ : null,
name: b && b._name !== undefined ? b._name : undefined,
changes,
});
}
}
return out;
}
function diffObject(a, b, prefix, out) {
if (a === b) return;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
out[prefix || '<root>'] = [a, b];
return;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
const av = a[k];
const bv = b[k];
if (av === bv) continue;
const path = prefix ? `${prefix}.${k}` : k;
if (
typeof av === 'object' && av !== null &&
typeof bv === 'object' && bv !== null
) {
diffObject(av, bv, path, out);
} else {
out[path] = [av, bv];
}
}
}
module.exports = { computeDiff };
+206
View File
@@ -0,0 +1,206 @@
// ============================================================
// editor/helpers.js — 节点定位 / 组件查找 / 类型规范化
// 所有 op handler 共用的低层工具
// ============================================================
'use strict';
const { isCompressedClassId, compressUuid } = require('../id.js');
const { resolveClassIdByName } = require('../classid-resolver.js');
// ─── componentType 规范化 ────────────────────────────────────
//
// cli 允许 op 里用以下三种形式传 componentType
// 1. @ccclass 名(如 'MyUI'
// 2. 原始 UUID(如 '5a154a84-89a1-509a-8949-96edd6fb74a2'
// 3. 压缩 classId23 字符,已规范化格式,如 '5a154qEiaFQmolJlu3W+3Si'
//
// 但 Cocos 编辑器序列化 prefab 时会把 __type__ 规范化为压缩 classId。
// 为避免「写入字符串名/原始 UUID → 编辑器 reimport 后规范化 + 清空 refs」的坑,
// 在每个 op 的 handler 开头把 componentType 统一转成压缩 classId。
//
// 规则:
// - 空/非字符串:原样返回(让 handler 各自报参数错)
// - 以 'cc.' / 'sp.' / 'dragonBones.' 开头:引擎类,不可能是 className,原样
// - 已经是 23 字符压缩格式:原样
// - 原始 UUID 格式(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx):压缩为 classId
// - 其他(视作 @ccclass 名):扫 assets/scripts 反查 → 压缩 classId
// 找不到时**直接抛错**cocos 反序列化看到 className 字符串会报 MissingScript
// 与其降级写入留个坑不如让 cli 当场失败,告诉调用方真实原因(meta 未生成 / class 名拼错 / 没加 @ccclass)。
//
// 这确保 add-component / set-component-ref / remove-component 无论传哪种形式
// 都能 lookup 到同一 __type__ 字符串,避免同 batch 内 add+ref 类型不一致。
const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function normalizeComponentType(componentType, resolverStartPath) {
if (typeof componentType !== 'string' || componentType.length === 0) {
return componentType;
}
if (/^(cc|sp|dragonBones)\./.test(componentType)) return componentType;
if (isCompressedClassId(componentType)) return componentType;
// 原始 UUID 格式 → 压缩为 classId,与 @ccclass 名查表结果统一
if (_UUID_RE.test(componentType)) {
try { return compressUuid(componentType); } catch (_) {}
}
// 视作 @ccclass 名:必须查表得到压缩 classId,否则写入会造成 cocos MissingScript
if (!resolverStartPath) {
throw new Error(
`normalizeComponentType: className "${componentType}" 无法解析——缺少 prefab 路径用于定位项目根。` +
`\n 通常因 prefab 在项目目录外(如 /tmp/)时未传 --project-root。`
);
}
const classId = resolveClassIdByName(componentType, resolverStartPath);
if (!classId) {
throw new Error(
`normalizeComponentType: className "${componentType}" 在 assets/scripts 下找不到对应 .ts.meta。` +
`\n 常见原因:` +
`\n 1) .ts 文件刚新建,cocos 编辑器尚未生成 .ts.meta(等编辑器自动 import 后重跑);` +
`\n 2) class 未加 @ccclass('${componentType}') 装饰器;` +
`\n 3) @ccclass 参数与 className 拼写不一致。`
);
}
return classId;
}
// ─── 判断节点是否是 stub(嵌套 prefab 根节点)────────────────
function isStub(elements, node) {
if (!node || node.__type__ !== 'cc.Node') return false;
const prefabRef = node._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return false;
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return false;
const instanceRef = prefabInfo.instance;
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return false;
const instance = elements[instanceRef.__id__];
return !!(instance && instance.__type__ === 'cc.PrefabInstance');
}
// ─── 引用相等查 __id__ ───────────────────────────────────────
function indexOfNode(elements, node) {
for (let i = 0; i < elements.length; i++) {
if (elements[i] === node) return i;
}
return -1;
}
// ─── 节点定位(按名字串 或 {id:N})──────────────────────────
function resolveNode(prefabData, nodeSelector, opDesc) {
const { elements } = prefabData;
if (typeof nodeSelector === 'string') {
const node = prefabData.findNodeByName(nodeSelector);
if (!node) {
throw new Error(`editPrefab [${opDesc}]: 找不到节点 "${nodeSelector}"`);
}
const nodeId = indexOfNode(elements, node);
if (nodeId < 0) {
throw new Error(`editPrefab [${opDesc}]: 节点 "${nodeSelector}" 找到但索引失败(内部错误)`);
}
return { node, nodeId };
}
if (nodeSelector && typeof nodeSelector === 'object') {
if (typeof nodeSelector.id === 'number') {
const nodeId = nodeSelector.id;
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node') {
throw new Error(`editPrefab [${opDesc}]: __id__ ${nodeId} 不是有效 cc.Node`);
}
return { node, nodeId };
}
if (typeof nodeSelector.path === 'string' && nodeSelector.path.length > 0) {
return resolveNodeByPath(prefabData, nodeSelector.path, opDesc);
}
}
throw new Error(
`editPrefab [${opDesc}]: node 参数必须是字符串名称、{ id: N } 或 { path: 'A/B/C' },收到: ${JSON.stringify(nodeSelector)}`
);
}
// 按路径定位节点(DOM-like
// path 形如 "Canvas/Main/itemList",从根节点开始按 _name 逐级下钻
// 每段必须命中 _children 中某个节点的 _name
// 遇到 stub 节点时不下钻(stub _name 在 propertyOverrides 里,超出 cli 范围)
function resolveNodeByPath(prefabData, pathStr, opDesc) {
const { elements, rootId, getRoot } = prefabData;
const segments = pathStr.split('/').filter((s) => s.length > 0);
if (segments.length === 0) {
throw new Error(`editPrefab [${opDesc}]: path 段为空`);
}
let curId = rootId;
let cur = getRoot();
// 第一段对齐根节点名(如 "Canvas"),允许省略
if (cur._name === segments[0]) {
segments.shift();
}
for (const seg of segments) {
if (!Array.isArray(cur._children)) {
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在节点 "${cur._name}" 下没有子节点,无法继续下钻到 "${seg}"`);
}
const matches = [];
for (const cref of cur._children) {
if (typeof cref.__id__ !== 'number') continue;
const child = elements[cref.__id__];
if (child && child._name === seg) {
matches.push(cref.__id__);
}
}
if (matches.length === 0) {
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下找不到子节点 "${seg}"`);
}
if (matches.length > 1) {
// 同名子节点 path 无法消歧,强制报错而非静默取首个
throw new Error(
`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下有 ${matches.length} 个同名子节点 "${seg}"__id__: ${matches.join(', ')}),` +
`path 选择器无法消歧。请改用 {id: N} 精确定位,或对父节点用 path、对该层用 id 组合`
);
}
curId = matches[0];
cur = elements[curId];
}
return { node: cur, nodeId: curId };
}
// ─── 找节点上指定类型的组件 ──────────────────────────────────
function findComponent(elements, node, compType) {
if (!Array.isArray(node._components)) return null;
for (const compRef of node._components) {
if (typeof compRef.__id__ !== 'number') continue;
const comp = elements[compRef.__id__];
if (comp && comp.__type__ === compType) return comp;
}
return null;
}
// ─── 找根节点的 PrefabInfo(持有 nestedPrefabInstanceRoots / targetOverrides)──
function findRootPrefabInfo(elements, rootNodeId) {
// 根节点直接持有其 PrefabInfo 的引用——沿 rootNode._prefab.__id__ 跳一步即可。
// 不遍历:prefab 内每个节点都有自己的 PrefabInforoot/__id__ 均指向根节点),
// 迭代会优先命中遇到的第一个非根节点 PrefabInfo,导致 targetOverrides 写错位置。
const rootNode = elements[rootNodeId];
if (!rootNode || rootNode.__type__ !== 'cc.Node') return null;
const prefabRef = rootNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return null;
const pi = elements[prefabRef.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') return null;
if (pi.instance !== null && pi.instance !== undefined) return null;
return pi;
}
module.exports = {
normalizeComponentType,
isStub,
indexOfNode,
resolveNode,
findComponent,
findRootPrefabInfo,
};
+292
View File
@@ -0,0 +1,292 @@
// ============================================================
// editor/id-utils.js — fileId 分配 / 子树断开 / __id__ 重映射
//
// 用于 add-node / clone-node / remove-node / dedupe-component 共用:
// - fileId 唯一性(deterministic + 冲突检测)
// - 删节点时递归断开 _parent
// - 删 elements 后所有 __id__ 引用收缩
// ============================================================
'use strict';
const { createFileIdGenerator } = require('../id.js');
// ─── 收集 elements 中所有现有 fileId ─────────────────────────
/**
* 遍历 elements,收集所有 cc.PrefabInfo / cc.CompPrefabInfo / cc.PrefabInstance 的 fileId。
* @param {object[]} elements
* @returns {Set<string>}
*/
function collectExistingFileIds(elements) {
const ids = new Set();
for (const el of elements) {
if (!el) continue;
if (
(el.__type__ === 'cc.PrefabInfo' ||
el.__type__ === 'cc.CompPrefabInfo' ||
el.__type__ === 'cc.PrefabInstance') &&
typeof el.fileId === 'string' &&
el.fileId.length > 0
) {
ids.add(el.fileId);
}
}
return ids;
}
/**
* 生成不与 existingIds 冲突的 fileId。
* 先用 baseSeed 生成,若冲突则追加 #1、#2 … 直到不冲突。
* deterministic:相同 baseSeed + 相同现有集合 → 相同结果。
*/
function uniqueFileId(baseSeed, existingIds) {
let candidate = createFileIdGenerator(baseSeed)();
if (!existingIds.has(candidate)) {
existingIds.add(candidate);
return candidate;
}
let counter = 1;
while (true) {
candidate = createFileIdGenerator(`${baseSeed}#${counter}`)();
if (!existingIds.has(candidate)) {
existingIds.add(candidate);
return candidate;
}
counter++;
}
}
// ─── 断开子树(remove-node 用)────────────────────────────────
/**
* 递归断开子树中所有节点及其关联对象的 _parent 引用(置 null)。
* 元素本身保留在数组,只让它们成为真正的孤儿。
*/
function disconnectSubtree(elements, nodeId) {
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node') return;
if (Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ === 'number') {
disconnectSubtree(elements, childRef.__id__);
}
}
}
node._parent = null;
if (node._prefab && typeof node._prefab.__id__ === 'number') {
const pi = elements[node._prefab.__id__];
if (pi && pi.__type__ === 'cc.PrefabInfo') {
pi._parent = null;
if (pi.instance && typeof pi.instance.__id__ === 'number') {
const prefabInst = elements[pi.instance.__id__];
if (prefabInst && prefabInst.__type__ === 'cc.PrefabInstance') {
if (Array.isArray(prefabInst.mountedChildren)) {
for (const mcRef of prefabInst.mountedChildren) {
if (typeof mcRef.__id__ === 'number') {
disconnectSubtree(elements, mcRef.__id__);
}
}
prefabInst.mountedChildren = [];
}
prefabInst.propertyOverrides = [];
if (Array.isArray(prefabInst.mountedComponents)) {
prefabInst.mountedComponents = [];
}
pi.instance = null;
}
}
}
}
if (Array.isArray(node._components)) {
for (const compRef of node._components) {
if (typeof compRef.__id__ !== 'number') continue;
const comp = elements[compRef.__id__];
if (!comp) continue;
comp._parent = null;
if (comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
const cpi = elements[comp.__prefab.__id__];
if (cpi && cpi.__type__ === 'cc.CompPrefabInfo') {
cpi._parent = null;
}
}
}
}
}
// ─── elements 重排:__id__ 引用映射 / 收缩 ───────────────────
//
// 用于 dedupe-component:合并删除组件后,所有 __id__ 指向被删/被合并对象的
// 引用要重定向到 keeper 或按缩减后的下标 shift。
/** @property 字段非 null 计数(粗略打分,挑 keeper */
function countPropertyRefs(comp) {
let n = 0;
for (const [k, v] of Object.entries(comp)) {
if (isReservedCompField(k)) continue;
if (v === null || v === undefined) continue;
if (typeof v === 'object' || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
n++;
}
}
return n;
}
/** 合并时不触碰的核心字段 */
function isReservedCompField(key) {
return (
key === '__type__' ||
key === '_name' ||
key === '_objFlags' ||
key === '__editorExtras__' ||
key === 'node' ||
key === '_enabled' ||
key === '__prefab' ||
key === '_id'
);
}
/** 所有节点的 _components / mountedComponents 去掉指向 deleteSet 的 ref */
function filterCompRefsInElements(elements, deleteSet) {
for (const el of elements) {
if (!el || typeof el !== 'object') continue;
if (Array.isArray(el._components)) {
el._components = el._components.filter(
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
);
}
if (Array.isArray(el.mountedComponents)) {
el.mountedComponents = el.mountedComponents.filter(
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
);
}
}
}
/** 把所有 __id__ 指向 redirect.keys 的 ref 改成指向 redirect.get(...) */
function redirectIdsAcrossElements(elements, redirect) {
if (redirect.size === 0) return;
const visit = (obj) => {
if (obj === null || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
for (const v of obj) visit(v);
return;
}
if (typeof obj.__id__ === 'number' && redirect.has(obj.__id__)) {
obj.__id__ = redirect.get(obj.__id__);
}
for (const k of Object.keys(obj)) visit(obj[k]);
};
visit(elements);
}
function buildShiftMap(total, deleteSet) {
const map = new Array(total);
let removed = 0;
for (let i = 0; i < total; i++) {
if (deleteSet.has(i)) {
map[i] = null;
removed++;
} else {
map[i] = i - removed;
}
}
return map;
}
function shiftIdsAcrossElements(elements, shiftMap) {
const visit = (obj) => {
if (obj === null || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
for (const v of obj) visit(v);
return;
}
if (typeof obj.__id__ === 'number') {
const nv = shiftMap[obj.__id__];
if (nv != null) obj.__id__ = nv;
}
for (const k of Object.keys(obj)) visit(obj[k]);
};
visit(elements);
}
// ─── 清理根 PrefabInfo.targetOverrides 中悬空条目 ─────────────
//
// 从根 PrefabInfo.targetOverrides 移除 source/target 落入 removedIds 的条目。
// 被移除的 cc.TargetOverrideInfo / cc.TargetInfo 对象本身保留为孤儿
// (软删策略,保持其他 __id__ 稳定)。
//
// 调用方:
// - remove-node:删 stub 后清「外层脚本 → stub 内部组件/节点」的悬空 override
// - remove-component:删组件后清「该组件 → 嵌套 stub 内部组件/节点」的悬空 override
//
// 不传 removedIds 则不做任何事;rootId 必须传(指向根 cc.Node 在 elements 数组中的 __id__)。
function cleanupRootTargetOverrides(elements, rootId, removedIds) {
if (!removedIds || removedIds.size === 0) return;
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab || typeof rootNode._prefab.__id__ !== 'number') return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
if (!Array.isArray(rootPrefabInfo.targetOverrides)) return;
rootPrefabInfo.targetOverrides = rootPrefabInfo.targetOverrides.filter((ref) => {
if (!ref || typeof ref.__id__ !== 'number') return false;
const ov = elements[ref.__id__];
if (!ov) return false;
const t = ov.target;
const s = ov.source;
if (t && typeof t.__id__ === 'number' && removedIds.has(t.__id__)) return false;
if (s && typeof s.__id__ === 'number' && removedIds.has(s.__id__)) return false;
return true;
});
}
// ─── 同步根 PrefabInfo.nestedPrefabInstanceRoots ─────────────
//
// 重建根节点 PrefabInfo.nestedPrefabInstanceRoots = 当前所有「有父 + 有 PrefabInfo +
// PrefabInfo.instance 指向 cc.PrefabInstance」的嵌套 stub 节点 __id__。
//
// 调用方:
// - remove-node:软删 stub 后,被删节点 _parent 已置 null,扫描时自动出局,
// 其登记从 nestedPrefabInstanceRoots 剔除。
// - sync-nested-roots op:单独修「删了一半」残留的悬空嵌套实例根(父引用已被移除
// 但根 PrefabInfo 登记残留 → 残留 asset 仍被当依赖加载)。
function syncNestedRoots(elements, rootId) {
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab) return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance) continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
}
module.exports = {
collectExistingFileIds,
uniqueFileId,
disconnectSubtree,
countPropertyRefs,
isReservedCompField,
filterCompRefsInElements,
redirectIdsAcrossElements,
buildShiftMap,
shiftIdsAcrossElements,
cleanupRootTargetOverrides,
syncNestedRoots,
};
+174
View File
@@ -0,0 +1,174 @@
// ============================================================
// editor/index.js — 声明式批量编辑 prefab 主入口
//
// editPrefab(filePath, ops[], options?)
// - 内存内依次执行所有 op
// - 自动判别 stub vs 普通节点
// - 任一 op 失败抛错、不落盘
// - dryRun: 跑完不写盘,返回字段级 diff
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { writePrefab } = require('../write.js');
const { computeDiff } = require('./diff.js');
// 各 op handler
const { execSetPosition } = require('./ops/set-position.js');
const { execSetLabelText } = require('./ops/set-label-text.js');
const { execSetSpriteFrame } = require('./ops/set-sprite-frame.js');
const { execSetActive } = require('./ops/set-active.js');
const { execSetComponentField } = require('./ops/set-component-field.js');
const { execSetComponentEnabled } = require('./ops/set-component-enabled.js');
const { execSetAnchor } = require('./ops/set-anchor.js');
const { execSetSize } = require('./ops/set-size.js');
const { execAdjustPosition } = require('./ops/adjust-position.js');
const { execRenameNode } = require('./ops/rename-node.js');
const { execReparent } = require('./ops/reparent.js');
const { execReorderChildren } = require('./ops/reorder-children.js');
const { execAddNode } = require('./ops/add-node.js');
const { execRemoveNode } = require('./ops/remove-node.js');
const { execCloneNode } = require('./ops/clone-node.js');
const { execAddComponent } = require('./ops/add-component.js');
const { execRemoveComponent } = require('./ops/remove-component.js');
const { execSetComponentRef } = require('./ops/set-component-ref.js');
const { execSetNestedComponentField } = require('./ops/set-nested-component-field.js');
const { execBulkSet } = require('./ops/bulk-set.js');
const { execDedupeComponent } = require('./ops/dedupe-component.js');
const { execSetEditBox } = require('./ops/set-editbox.js');
const { execSetLabel } = require('./ops/set-label.js');
const { execSetButton } = require('./ops/set-button.js');
const { execSetLayout } = require('./ops/set-layout.js');
const { execSetRichText } = require('./ops/set-richtext.js');
const { execSetSprite } = require('./ops/set-sprite.js');
const { execSetNodeColor } = require('./ops/set-node-color.js');
const { execReplaceNestedPrefab } = require('./ops/replace-nested-prefab.js');
const { execAddNestedPrefab } = require('./ops/add-nested-prefab.js');
const { execResetOverrides } = require('./ops/reset-overrides.js');
const { execEnsureMeta } = require('./ops/ensure-meta.js');
const { execSyncNestedRoots } = require('./ops/sync-nested-roots.js');
const { validateOps } = require('./op-schema.js');
const OP_HANDLERS = {
'set-position': execSetPosition,
'set-label-text': execSetLabelText,
'set-sprite-frame': execSetSpriteFrame,
'set-active': execSetActive,
'set-component-field': execSetComponentField,
'set-component-enabled': execSetComponentEnabled,
'set-anchor': execSetAnchor,
'set-size': execSetSize,
'adjust-position': execAdjustPosition,
'rename-node': execRenameNode,
'reparent': execReparent,
'reorder-children': execReorderChildren,
'add-node': execAddNode,
'remove-node': execRemoveNode,
'clone-node': execCloneNode,
'add-component': execAddComponent,
'remove-component': execRemoveComponent,
'set-component-ref': execSetComponentRef,
'set-nested-component-field': execSetNestedComponentField,
'bulk-set': execBulkSet,
'dedupe-component': execDedupeComponent,
'set-editbox': execSetEditBox,
'set-label': execSetLabel,
'set-button': execSetButton,
'set-layout': execSetLayout,
'set-richtext': execSetRichText,
'set-sprite': execSetSprite,
'set-node-color': execSetNodeColor,
'replace-nested-prefab': execReplaceNestedPrefab,
'add-nested-prefab': execAddNestedPrefab,
'reset-overrides': execResetOverrides,
'ensure-meta': execEnsureMeta,
'sync-nested-roots': execSyncNestedRoots,
};
/**
* 声明式批量编辑 prefab
*
* @param {string} filePath prefab 文件路径(读取 + 写回同一路径)
* @param {object[]} ops op 描述数组
* @param {object} [options]
* @param {string} [options.projectRoot] 项目根目录(含 assets/),默认从 filePath 向上推断。
* @param {boolean} [options.dryRun] true 时不写盘,仅返回模拟结果(含 diff)。
* @returns {{ changed: boolean, opsApplied: number, nodesAffected: (string|number)[], dryRun?: boolean, diff?: object[] }}
*
* @throws 任一 op 失败时抛错,不落盘
*/
function editPrefab(filePath, ops, options) {
if (typeof filePath !== 'string') {
throw new Error('editPrefab: filePath 必须是字符串');
}
if (!Array.isArray(ops) || ops.length === 0) {
throw new Error('editPrefab: ops 必须是非空数组');
}
// schema 预校验:跑前发现拼错的字段(comp / ref / 拼漏 op 等),不到 handler 才报错
validateOps(ops, Object.keys(OP_HANDLERS));
const opts = options || {};
const prefabData = parsePrefab(filePath);
prefabData.resolverStartPath = opts.projectRoot || filePath;
const dryRun = !!opts.dryRun;
prefabData.dryRun = dryRun;
const beforeSnapshot = dryRun
? JSON.parse(JSON.stringify(prefabData.elements))
: null;
const affectedIds = new Set();
const affectedNames = [];
let opsApplied = 0;
for (const op of ops) {
if (!op || typeof op.op !== 'string') {
throw new Error(`editPrefab: op 格式错误(缺少 op 字段): ${JSON.stringify(op)}`);
}
const handler = OP_HANDLERS[op.op];
if (!handler) {
throw new Error(
`editPrefab: 不支持的 op 类型 "${op.op}",支持: ${Object.keys(OP_HANDLERS).join(', ')}`
);
}
const nodeId = handler(prefabData, op);
if (typeof nodeId === 'number' && nodeId >= 0) {
affectedIds.add(nodeId);
}
// bulk-set 0 匹配时返回 -1,跳过 affectedIds
opsApplied++;
}
if (!dryRun) {
writePrefab(filePath, prefabData.elements, prefabData.raw);
}
for (const id of affectedIds) {
const node = prefabData.elements[id];
if (node && node._name) {
affectedNames.push(node._name);
} else {
affectedNames.push(id);
}
}
const result = {
changed: !dryRun,
opsApplied,
nodesAffected: affectedNames,
};
if (dryRun) {
result.dryRun = true;
result.diff = computeDiff(beforeSnapshot, prefabData.elements);
}
return result;
}
module.exports = { editPrefab, OP_HANDLERS };
+440
View File
@@ -0,0 +1,440 @@
// ============================================================
// editor/nested.js — stub 节点(嵌套 prefab)相关协议
//
// 涵盖:
// - 从嵌套 prefab 反查 CompPrefabInfo.fileId / Node PrefabInfo.fileId
// - 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 override
// - cc.TargetOverrideInfo 跨 nested @property 挂载
// 协议背景见 prefab-schema.md §4 与 set-component-ref op 上方注释。
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { resolveUuidToPath } = require('../uuid-resolver.js');
const { findRootPrefabInfo } = require('./helpers.js');
// ─── 嵌套 prefab:找指定组件的 CompPrefabInfo.fileId ─────────
/**
* @param {string} hostPrefabPath 宿主 prefab 文件路径(用于 UuidResolver 推断项目根)
* @param {object[]} elements 宿主 prefab elements 数组
* @param {number} stubNodeId stub 节点的 __id__
* @param {string} compType 组件类型,如 'cc.Label' / 'cc.Sprite'
* @param {string|null} nodeName 可选:指定嵌套 prefab 内的节点名(null = 第一个匹配)
* @returns {string} CompPrefabInfo.fileId
*/
function getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, nodeName) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`getNestedCompFileId: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(
`getNestedCompFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
);
}
const nestedUuid = assetRef.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
let nestedData;
try {
nestedData = parsePrefab(nestedPath);
} catch (e) {
throw new Error(
`getNestedCompFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}: ${e.message}`
);
}
const nEls = nestedData.elements;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== compType) continue;
if (nodeName !== null && nodeName !== undefined) {
if (!el.node || typeof el.node.__id__ !== 'number') continue;
const ownerNode = nEls[el.node.__id__];
if (!ownerNode || ownerNode._name !== nodeName) continue;
}
if (!el.__prefab || typeof el.__prefab.__id__ !== 'number') continue;
const cpi = nEls[el.__prefab.__id__];
if (!cpi || cpi.__type__ !== 'cc.CompPrefabInfo') continue;
if (typeof cpi.fileId !== 'string' || cpi.fileId.length === 0) continue;
return cpi.fileId;
}
const nodeHint = nodeName ? `(节点名: "${nodeName}"` : '';
throw new Error(
`getNestedCompFileId: 在嵌套 prefab "${nestedPath}" 中找不到 ${compType} 组件${nodeHint}` +
`或该组件没有 cc.CompPrefabInfo.fileId。`
);
}
// ─── 嵌套 prefab:找目标节点的 PrefabInfo.fileId ──────────────
/**
* @param {string} hostPrefabPath 宿主 prefab 路径
* @param {object[]} elements 宿主 prefab elements
* @param {number} stubNodeId stub 节点 __id__
* @param {string|null} nodeName 目标节点名(null = 嵌套 prefab 根节点)
* @returns {string} 目标节点 cc.PrefabInfo.fileId
*/
function getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, nodeName) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`getNestedNodeFileId: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(
`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
);
}
const nestedUuid = assetRef.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
let nestedData;
try {
nestedData = parsePrefab(nestedPath);
} catch (e) {
throw new Error(
`getNestedNodeFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}: ${e.message}`
);
}
const nEls = nestedData.elements;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = nEls[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (typeof pi.fileId !== 'string' || pi.fileId.length === 0) continue;
if (nodeName === null || nodeName === undefined) {
// 根节点:_parent 为 null
if (el._parent === null || el._parent === undefined) {
return pi.fileId;
}
} else {
if (el._name === nodeName) {
return pi.fileId;
}
}
}
const nodeHint = nodeName ? `(节点名: "${nodeName}"` : '(根节点)';
throw new Error(
`getNestedNodeFileId: 在嵌套 prefab "${nestedPath}" 中找不到目标节点${nodeHint}` +
`或该节点没有 cc.PrefabInfo.fileId。`
);
}
// ─── 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 ─
/**
* 在 stub 节点的 PrefabInstance.propertyOverrides 中写入一条组件属性 override。
* TargetInfo.localID 使用 compFileId(嵌套 prefab 内该组件的 CompPrefabInfo.fileId)。
*
* @param {object} prefabData parsePrefab 返回值
* @param {number} stubNodeId stub 节点 __id__
* @param {string} compFileId 嵌套 prefab 内目标组件的 CompPrefabInfo.fileId
* @param {string[]} propertyPath 属性路径,如 ['_string']
* @param {*} value 要写入的值
*/
function setStubCompOverride(prefabData, stubNodeId, compFileId, propertyPath, value) {
const { elements } = prefabData;
const stubNode = elements[stubNodeId];
const prefabInfo = elements[stubNode._prefab.__id__];
const prefabInstance = elements[prefabInfo.instance.__id__];
if (!prefabInstance || prefabInstance.__type__ !== 'cc.PrefabInstance') {
throw new Error(`setStubCompOverride: stub ${stubNodeId} 没有有效 PrefabInstance`);
}
if (Array.isArray(prefabInstance.propertyOverrides)) {
for (const overrideRef of prefabInstance.propertyOverrides) {
if (typeof overrideRef.__id__ !== 'number') continue;
const info = elements[overrideRef.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
const tiRef = info.targetInfo;
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
const ti = elements[tiRef.__id__];
if (!ti || ti.__type__ !== 'cc.TargetInfo') continue;
if (!Array.isArray(ti.localID) || ti.localID[0] !== compFileId) continue;
if (
Array.isArray(info.propertyPath) &&
info.propertyPath.length === propertyPath.length &&
info.propertyPath.every((p, i) => p === propertyPath[i])
) {
info.value = value;
return;
}
}
}
const targetInfo = {
__type__: 'cc.TargetInfo',
localID: [compFileId],
};
const targetInfoId = elements.length;
elements.push(targetInfo);
const overrideInfo = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: targetInfoId },
propertyPath: [...propertyPath],
value,
};
const overrideInfoId = elements.length;
elements.push(overrideInfo);
if (!Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
prefabInstance.propertyOverrides.push({ __id__: overrideInfoId });
}
// ─── 跨 nested @property 挂载(cc.TargetOverrideInfo)─────────
//
// 背景:主 prefab 里 BottomView.prefab 的某个脚本组件(如 BottomView)有
// @property _btnStore: cc.ButtonbtnStore 节点在主 prefab 里是 stub 代理
// PrefabInstance),真正的 cc.Button 组件在子 prefab StoreBtn.prefab 里。
// 正确协议:在主 prefab root PrefabInfo.targetOverrides 里写一条
// cc.TargetOverrideInfotarget 指向 stub 节点,targetInfo.localID 是子
// prefab 里目标组件的 __prefab.fileId。
//
// localID 为数组支持多层 nested:每过一层 PrefabInstance 边界新开子 map
// 每个元素是该层某节点/组件的 fileId。当前 cli 实现只支持 1 层;多层场景由
// 上游 tools/step-3-script/bind-prefab-components 兜底。
/**
* 在子 prefab 里按 compType + subNode 找目标组件 / 节点 fileId
* 返回 localID 数组。支持多层嵌套:
*
* subNode = null | string → 单层(在子 prefab 根上找 compType
* subNode = ['name1', 'name2'] → 多层(每段是嵌套 stub 节点名,
* 最后一段 + compType 决定终点)
*
* 多层链:path=['A','B'], compType='cc.Label'
* = 主 prefab stub → A.prefab 内的 stub 'A' → B.prefab 内的 cc.Label
* 返回 [stub-A 在 A.prefab 内的 fileId, B.prefab 内 cc.Label 的 fileId]
* 注意每跨一层 PrefabInstance 边界,链 push 一个 fileId。
*/
function resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode) {
// 单层:subNode 为 null 或字符串
if (subNode === null || subNode === undefined || typeof subNode === 'string') {
if (compType === 'cc.Node') {
const nodeFileId = getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, subNode);
return [nodeFileId];
}
const compFileId = getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, subNode);
return [compFileId];
}
// 多层:subNode 是字符串数组(路径)
if (!Array.isArray(subNode) || !subNode.every((s) => typeof s === 'string' && s.length > 0)) {
throw new Error(`resolveLocalIdChain: subNode 必须是 null / 字符串 / 字符串数组,收到 ${JSON.stringify(subNode)}`);
}
if (subNode.length === 0) {
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, null);
}
if (subNode.length === 1) {
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode[0]);
}
// 多层:从当前 stub 进入第一层嵌套,找名字 = subNode[0] 的内嵌 stub
// 拿到它在嵌套 prefab 内的 fileId,递归走剩下的路径
const [firstSeg, ...restPath] = subNode;
const { nestedPath, nestedData } = _loadNestedPrefab(hostPrefabPath, elements, stubNodeId);
const nEls = nestedData.elements;
let innerStubId = -1;
let innerStubFileId = null;
for (let i = 0; i < nEls.length; i++) {
const el = nEls[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (el._name !== firstSeg) continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const innerPi = nEls[el._prefab.__id__];
if (!innerPi || innerPi.__type__ !== 'cc.PrefabInfo') continue;
if (!innerPi.instance) continue; // 不是 stub
if (typeof innerPi.fileId !== 'string' || innerPi.fileId.length === 0) continue;
innerStubId = i;
innerStubFileId = innerPi.fileId;
break;
}
if (innerStubId < 0) {
throw new Error(
`resolveLocalIdChain: 嵌套 prefab "${nestedPath}" 中找不到名为 "${firstSeg}" 的 stub 节点`
);
}
// 递归到下一层(用嵌套 prefab 自身作为 hostPrefabPath
const innerChain = resolveLocalIdChain(nestedPath, nEls, innerStubId, compType, restPath);
return [innerStubFileId, ...innerChain];
}
/** 加载 stub 指向的嵌套 prefab,返回路径 + parsed data */
function _loadNestedPrefab(hostPrefabPath, elements, stubNodeId) {
const stubNode = elements[stubNodeId];
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
throw new Error(`_loadNestedPrefab: ${stubNodeId} 不是有效 cc.Node`);
}
const prefabRef = stubNode._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
}
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
}
const assetRef = prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(`_loadNestedPrefab: stub ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`);
}
const nestedPath = resolveUuidToPath(assetRef.__uuid__, hostPrefabPath);
const nestedData = parsePrefab(nestedPath);
return { nestedPath, nestedData };
}
// ─── propertyPath 数组索引 normalize ─────────────────────────────────────────
//
// Cocos 编辑器加载 prefab 时按 JSON 类型区分属性名(string)与数组索引(number)。
// 若数组索引以 string 形式写入(如 "0" 代替 0),编辑器无法匹配对应数组槽,
// TargetOverrideInfo 静默失效(inspector 显示空)。
//
// 使用方法:
// addRootTargetOverride 在写入前调用 normalizePropertyPath
// 保证任何经由字符串解析("_items.0"、"_items[0]")或直接传入的数字 string
// 都被转换为 number 类型的数组索引。
//
// 例:["_items", "0"] → ["_items", 0]
// ["_items", 0 ] → ["_items", 0] (已是 number,不变)
// ["_role" ] → ["_role" ] (无下标,不变)
function normalizePropertyPath(path) {
return path.map(function(seg) {
if (typeof seg === 'string' && /^\d+$/.test(seg)) {
return parseInt(seg, 10);
}
return seg;
});
}
/**
* 给主 prefab root PrefabInfo.targetOverrides 追加一条 cc.TargetOverrideInfo
* + cc.TargetInfo,实现跨 stub @property 挂载。
*
* @param {(string|number)[]} propertyPath 属性路径数组,普通字段如 ["_role"]
* 数组字段元素如 ["_items", 0](索引用数字而非字符串)。
* 传入字符串形式的数字索引(如 "0")会被内部自动转为 number,调用方无需预处理。
*/
function addRootTargetOverride(prefabData, rootId, sourceCompId, propertyPath, targetStubId, localIdChain) {
const { elements } = prefabData;
const rootPrefabInfo = findRootPrefabInfo(elements, rootId);
if (!rootPrefabInfo) {
throw new Error(`addRootTargetOverride: 找不到主 prefab root PrefabInforootId=${rootId}`);
}
// 确保数组索引为 number 类型(Cocos 编辑器按类型匹配,string "0" ≠ number 0
const normalizedPath = normalizePropertyPath(propertyPath);
// 幂等:已存在同 source/propertyPath/target/localID 的 override 直接返回
// 注意:dedupe key 使用完整 propertyPath 数组比对,
// 允许同一字段名但不同索引(如 ["_items",0] vs ["_items",1])共存。
const existingRefs = Array.isArray(rootPrefabInfo.targetOverrides) ? rootPrefabInfo.targetOverrides : [];
for (const r of existingRefs) {
if (typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (!ov.source || ov.source.__id__ !== sourceCompId) continue;
if (!Array.isArray(ov.propertyPath) || ov.propertyPath.length !== normalizedPath.length) continue;
if (!ov.propertyPath.every((p, i) => p === normalizedPath[i])) continue;
if (!ov.target || ov.target.__id__ !== targetStubId) continue;
const tiRef = ov.targetInfo;
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
const ti = elements[tiRef.__id__];
if (!ti || !Array.isArray(ti.localID)) continue;
if (ti.localID.length !== localIdChain.length) continue;
if (ti.localID.every((v, i) => v === localIdChain[i])) return;
}
const targetInfoId = elements.length;
elements.push({
__type__: 'cc.TargetInfo',
localID: localIdChain.slice(),
});
const overrideId = elements.length;
elements.push({
__type__: 'cc.TargetOverrideInfo',
source: { __id__: sourceCompId },
sourceInfo: null,
propertyPath: normalizedPath.slice(),
target: { __id__: targetStubId },
targetInfo: { __id__: targetInfoId },
});
if (!Array.isArray(rootPrefabInfo.targetOverrides)) {
rootPrefabInfo.targetOverrides = [];
}
// 插入策略:
// - 单字段 overridepropertyPath.length === 1,如 ["_btnClose"]):插到所有
// 数组字段 override 之前。
// - 数组字段 overridepropertyPath.length > 1,如 ["_items", 0]):追加到末尾。
//
// 为什么:Cocos 加载 prefab 时,若 rootTargetOverrides 数组里前面有数组字段
// override,后面位置的单字段 override 会被静默跳过(实测 cocos 3.8.x 行为,
// 见 forest/extensions/cc-3-8-x-mcp/doc/cli.md 坑 14)。单字段插前面规避此 bug。
const newRef = { __id__: overrideId };
const isSingleField = normalizedPath.length === 1;
if (isSingleField) {
const arr = rootPrefabInfo.targetOverrides;
let firstArrayIdx = arr.length;
for (let i = 0; i < arr.length; i++) {
const r = arr[i];
if (!r || typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (Array.isArray(ov.propertyPath) && ov.propertyPath.length > 1) {
firstArrayIdx = i;
break;
}
}
arr.splice(firstArrayIdx, 0, newRef);
} else {
rootPrefabInfo.targetOverrides.push(newRef);
}
}
module.exports = {
getNestedCompFileId,
getNestedNodeFileId,
setStubCompOverride,
resolveLocalIdChain,
addRootTargetOverride,
normalizePropertyPath,
};
+239
View File
@@ -0,0 +1,239 @@
// ============================================================
// editor/op-schema.js — ops 跑前 schema 校验
//
// 价值:
// - 字段拼错(comp / ref / propery)一次性报齐,不用一条条 op 跑到才发现
// - 未知 op 类型 / 必填字段缺失,跑前就报,避免部分写入后回滚浪费时间
// - 字段类型错(`width: "100"`)跑前就报,避免运行时崩
//
// 校验粒度:必填字段名 + 已知字段拼写白名单 + 字段类型;
// 业务约束(值域、互斥)留给 handler(更易给出场景化错误信息)
// ============================================================
'use strict';
// 类型令牌:
// 'number' | 'string' | 'boolean' | 'object' | 'array' | 'any'
// 'node-selector' — 字符串 / 数字 / { id, path } 三选一
// 'string|array' — property 类支持嵌套路径数组
// 'string|object' — refSubNode 支持字符串或字符串数组(这里只做粗校验)
//
// 'any' 不做类型断言(覆盖 value / props 一类 raw JSON)。
const T = {
node: 'node-selector',
parent: 'node-selector',
target: 'node-selector',
source: 'node-selector',
refNode: 'node-selector',
componentType: 'string',
property: 'string|array',
refType: 'string',
refSubNode: 'any', // string | string[]
value: 'any',
props: 'object',
selector: 'object',
order: 'array',
text: 'string',
name: 'string',
uuid: 'string',
prefabUuid: 'string',
active: 'boolean',
enabled: 'boolean',
compensatePosition: 'boolean',
clearOverrides: 'boolean',
bold: 'boolean',
italic: 'boolean',
underline: 'boolean',
enableWrapText: 'boolean',
grayscale: 'boolean',
trim: 'boolean',
affectedByScale: 'boolean',
interactable: 'boolean',
all: 'boolean',
x: 'number',
y: 'number',
z: 'number',
dx: 'number',
dy: 'number',
dz: 'number',
width: 'number',
height: 'number',
r: 'number',
g: 'number',
b: 'number',
a: 'number',
fontSize: 'number',
lineHeight: 'number',
maxWidth: 'number',
maxLength: 'number',
inputMode: 'number',
inputFlag: 'number',
zoomScale: 'number',
duration: 'number',
type: 'number',
sizeMode: 'number',
overflow: 'number',
horizontalAlign: 'number',
verticalAlign: 'number',
transition: 'number',
resizeMode: 'number',
paddingLeft: 'number',
paddingRight: 'number',
paddingTop: 'number',
paddingBottom: 'number',
spacingX: 'number',
spacingY: 'number',
startAxis: 'number',
constraint: 'number',
constraintNum: 'number',
placeholder: 'string',
string: 'string',
labelNode: 'string',
spriteNode: 'string',
subNode: 'any', // string | string[]
};
// 每个 op 的字段白名单(含必填 + 可选;'op' 隐含必填)
// typeOverrides:对全局 T 表的字段类型做局部覆盖(同名字段在不同 op 里语义不同时用)
const SCHEMAS = {
'set-position': { required: ['node', 'x', 'y'], optional: ['z'] },
'set-label-text': { required: ['node', 'text'], optional: ['labelNode'] },
'set-sprite-frame': { required: ['node', 'uuid'], optional: ['spriteNode'] },
'set-active': { required: ['node', 'active'], optional: [] },
'set-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: [] },
'set-component-enabled': { required: ['node', 'componentType', 'enabled'], optional: ['subNode'] },
'set-anchor': { required: ['node'], optional: ['x', 'y', 'compensatePosition'] },
'set-size': { required: ['node'], optional: ['width', 'height'] },
'adjust-position': { required: ['node'], optional: ['dx', 'dy', 'dz'] },
'rename-node': { required: ['node', 'name'], optional: [] },
// reparent: 把节点搬到另一个父节点下(不复制;普通 inline 节点;自带循环检测)
'reparent': { required: ['node', 'parent'], optional: ['index'] },
'reorder-children': { required: ['node', 'order'], optional: [] },
// add-node 的 node 是「新节点描述对象」而非 selector
'add-node': { required: ['parent', 'node'], optional: [], typeOverrides: { node: 'object' } },
'remove-node': { required: ['target'], optional: [] },
'clone-node': { required: ['source', 'parent', 'name'], optional: [] },
'add-component': { required: ['node', 'componentType'], optional: ['props'] },
'remove-component': { required: ['node', 'componentType'], optional: [] },
'set-component-ref': { required: ['node', 'componentType', 'property', 'refNode'], optional: ['refType', 'refSubNode'] },
'set-nested-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: ['subNode'] },
// bulk-set 的 target 是 "node" 或 "component:<type>" 字符串模式,不是 selector
'bulk-set': { required: ['selector', 'target', 'property', 'value'], optional: [], typeOverrides: { target: 'string' } },
'dedupe-component': { required: [], optional: ['node'] },
'set-editbox': { required: ['node'], optional: ['inputMode', 'maxLength', 'placeholder', 'string', 'inputFlag', 'fontSize'] },
'set-label': { required: ['node'], optional: ['text', 'fontSize', 'lineHeight', 'overflow', 'horizontalAlign', 'verticalAlign', 'bold', 'italic', 'underline', 'enableWrapText'] },
'set-button': { required: ['node'], optional: ['interactable', 'transition', 'zoomScale', 'duration'] },
'set-layout': { required: ['node'], optional: ['type', 'resizeMode', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'spacingX', 'spacingY', 'startAxis', 'constraint', 'constraintNum', 'affectedByScale'] },
'set-richtext': { required: ['node'], optional: ['text', 'maxWidth', 'fontSize', 'lineHeight'] },
'set-sprite': { required: ['node'], optional: ['sizeMode', 'type', 'grayscale', 'trim'] },
'set-node-color': { required: ['node'], optional: ['r', 'g', 'b', 'a'] },
'replace-nested-prefab': { required: ['target', 'prefabUuid'], optional: ['clearOverrides'] },
'add-nested-prefab': { required: ['parent', 'prefabUuid'], optional: ['name', 'lpos'] },
'reset-overrides': { required: ['node'], optional: ['property', 'componentType', 'subNode', 'all'] },
// ensure-meta: 给 .ts/.json 文件创建 .metav4 uuid),让后续 className → classId 查表能命中
'ensure-meta': { required: ['path'], optional: [], typeOverrides: { path: 'string' } },
'sync-nested-roots': { required: [], optional: [] },
};
// 已知拼错 → 正确字段映射(友好提示)
const COMMON_TYPOS = {
'comp': 'componentType',
'compType': 'componentType',
'ref': 'refNode',
'propery': 'property',
'val': 'value',
'newName': 'name',
'nodeName': 'node',
};
function _formatType(token) {
switch (token) {
case 'node-selector': return '字符串/数字/{id}/{path}';
case 'string|array': return '字符串或数组';
default: return token;
}
}
function _checkType(value, token) {
if (token === 'any') return true;
if (token === 'node-selector') {
if (typeof value === 'string') return value.length > 0;
if (typeof value === 'number') return Number.isInteger(value) && value >= 0;
if (value && typeof value === 'object' && !Array.isArray(value)) {
return typeof value.id === 'number' || typeof value.path === 'string';
}
return false;
}
if (token === 'string|array') {
return typeof value === 'string' || Array.isArray(value);
}
if (token === 'array') return Array.isArray(value);
if (token === 'object') {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
return typeof value === token; // number / string / boolean
}
function validateOps(ops, knownOpTypes) {
const errors = [];
for (let i = 0; i < ops.length; i++) {
const op = ops[i];
const prefix = `ops[${i}]`;
if (!op || typeof op !== 'object' || Array.isArray(op)) {
errors.push(`${prefix}: 不是对象`);
continue;
}
if (typeof op.op !== 'string') {
errors.push(`${prefix}: 缺 'op' 字段`);
continue;
}
if (!knownOpTypes.includes(op.op)) {
errors.push(`${prefix}: 不支持的 op 类型 "${op.op}",已知: ${knownOpTypes.join(', ')}`);
continue;
}
const schema = SCHEMAS[op.op];
if (!schema) continue; // 没登记 schema 的 op 跳过
const known = new Set(['op', ...schema.required, ...schema.optional]);
// 必填检查
for (const r of schema.required) {
if (!(r in op)) {
errors.push(`${prefix} (${op.op}): 缺必填字段 "${r}"`);
}
}
// 多余字段 + 类型检查
for (const k of Object.keys(op)) {
if (k === 'op') continue;
if (!known.has(k)) {
const suggest = COMMON_TYPOS[k];
if (suggest && known.has(suggest)) {
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",可能想写 "${suggest}"`);
} else {
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",已知: ${[...known].join(', ')}`);
}
continue;
}
// 类型检查(only 在 T 中登记的字段;未登记的留给 handler)
// op 的 typeOverrides 优先级高于全局 T
const token = (schema.typeOverrides && schema.typeOverrides[k]) || T[k];
if (!token) continue;
if (!_checkType(op[k], token)) {
const got = Array.isArray(op[k]) ? 'array' : (op[k] === null ? 'null' : typeof op[k]);
errors.push(
`${prefix} (${op.op}): 字段 "${k}" 类型应为 ${_formatType(token)},实际是 ${got}(值: ${JSON.stringify(op[k])}`
);
}
}
}
if (errors.length > 0) {
throw new Error(`editPrefab: ops schema 校验失败:\n ${errors.join('\n ')}`);
}
}
module.exports = { validateOps, SCHEMAS };
+75
View File
@@ -0,0 +1,75 @@
// add-component: 在节点 _components 数组里加一个指向指定 ccclass 的组件条目
// + 配套的 cc.CompPrefabInfo(含 deterministic fileId
// op: { op: 'add-component', node, componentType, props? }
//
// - componentType: 组件 ccclass 名(如 'TaskBtn' / 'cc.Sprite'
// - props: 可选,初始 @property 字段值,会浅合并到组件对象上
//
// 限制:
// - stub 节点暂不支持
// - 同节点同类型组件已存在时抛错
'use strict';
const { ref, makeCompPrefabInfo } = require('../../primitives.js');
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execAddComponent(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawComponentType, props } = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [add-component]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'add-component');
if (isStub(elements, node)) {
throw new Error(`editPrefab [add-component]: stub 节点挂自定义组件暂未实现(需 PrefabInstance.mountedComponents`);
}
if (findComponent(elements, node, componentType)) {
throw new Error(`editPrefab [add-component]: 节点 "${node._name}" 已挂 "${componentType}" 组件`);
}
let seed = 'unknown';
if (node._prefab && typeof node._prefab.__id__ === 'number') {
const pi = elements[node._prefab.__id__];
if (pi && pi.fileId) seed = pi.fileId;
}
const existingFileIds = collectExistingFileIds(elements);
const compFileId = uniqueFileId(`${seed}#addComp#${componentType}`, existingFileIds);
const compId = elements.length;
const cpiId = compId + 1;
const compObj = Object.assign(
{
__type__: componentType,
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
__prefab: ref(cpiId),
_id: '',
},
props && typeof props === 'object' ? props : {}
);
const cpiObj = makeCompPrefabInfo(compFileId);
elements.push(compObj);
elements.push(cpiObj);
if (!Array.isArray(node._components)) {
node._components = [];
}
node._components.push(ref(compId));
return nodeId;
}
module.exports = { execAddComponent };
+149
View File
@@ -0,0 +1,149 @@
// add-nested-prefab: 在指定父节点下嵌入一个外部 prefab 实例(stub)。
//
// 等效于在 Cocos 编辑器把某个 prefab 文件拖入当前 prefab 树。生成三个对象:
// - 一个 stub cc.Node_name/_active 留空,由子 prefab 默认或 override 决定)
// - 一个 cc.PrefabInfoasset.__uuid__ = prefabUuidinstance 指向 PrefabInstance
// - 一个 cc.PrefabInstanceprefabRootNode 指向外层 prefab 根 = rootId
//
// 可选 name / lpos 通过 propertyOverrides 写到 PrefabInstance 上(targetInfo.localID
// 用子 prefab 内根节点的 PrefabInfo.fileId,需读外部 prefab 文件解析)。
//
// op: { op: 'add-nested-prefab', parent: string|{id:N}, prefabUuid: string, name?: string, lpos?: [x,y,z] }
//
// 协议背景:参 doc/nested-prefab-protocol.md;与 replace-nested-prefab 互补
// (replace 替换 asset uuid 不动节点结构,add 是从零生成嵌套实例)。
'use strict';
const { resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execAddNestedPrefab(prefabData, op) {
const { elements, rootId } = prefabData;
const { parent: parentSelector, prefabUuid, name, lpos } = op;
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
throw new Error(`editPrefab [add-nested-prefab]: prefabUuid 必须是非空字符串`);
}
const cleanUuid = prefabUuid.trim();
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-nested-prefab');
// 父 prefab fileId 作 deterministic 种子
let parentFileId = 'unknown';
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
const parentPi = elements[parentNode._prefab.__id__];
if (parentPi && parentPi.fileId) parentFileId = parentPi.fileId;
}
const existingFileIds = collectExistingFileIds(elements);
const baseSeed = `${parentFileId}#addNested#${cleanUuid}#${name ?? ''}`;
const stubFileId = uniqueFileId(baseSeed, existingFileIds);
const instanceFileId = uniqueFileId(`${baseSeed}#instance`, existingFileIds);
// 分配 idstubNode → prefabInfo → prefabInstance → [TargetInfo + OverrideInfo] × N
const stubNodeId = elements.length;
const prefabInfoId = stubNodeId + 1;
const instanceId = stubNodeId + 2;
let nextId = instanceId + 1;
const propertyOverrideRefs = [];
const overrideElements = [];
// PropertyOverride 的 targetInfo.localID 用 stub 自己在外层 prefab 内的 PrefabInfo.fileId
// (而不是子 prefab 内根节点 fileId)。CC3 协议:targetInfo 定位 override 应用的「目标对象」,
// 对于 stub Node 自己的 _name/_lpos 这类字段,目标对象就是 stub 在外层 prefab 内的标识。
function pushOverride(propertyPath, value) {
const tiId = nextId++;
const oiId = nextId++;
overrideElements.push({
__type__: 'cc.TargetInfo',
localID: [stubFileId],
});
overrideElements.push({
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: tiId },
propertyPath,
value,
});
propertyOverrideRefs.push({ __id__: oiId });
}
if (name !== undefined) pushOverride(['_name'], name);
if (lpos !== undefined) {
pushOverride(['_lpos'], {
__type__: 'cc.Vec3',
x: lpos[0] || 0,
y: lpos[1] || 0,
z: lpos[2] || 0,
});
}
const stubNode = {
__type__: 'cc.Node',
_objFlags: 0,
_parent: { __id__: parentId },
_prefab: { __id__: prefabInfoId },
__editorExtras__: {},
};
const stubPrefabInfo = {
__type__: 'cc.PrefabInfo',
root: { __id__: stubNodeId },
asset: { __uuid__: cleanUuid, __expectedType__: 'cc.Prefab' },
fileId: stubFileId,
instance: { __id__: instanceId },
targetOverrides: null,
};
const prefabInstance = {
__type__: 'cc.PrefabInstance',
fileId: instanceFileId,
prefabRootNode: { __id__: rootId },
mountedChildren: [],
mountedComponents: [],
propertyOverrides: propertyOverrideRefs,
removedComponents: [],
};
elements.push(stubNode);
elements.push(stubPrefabInfo);
elements.push(prefabInstance);
for (const o of overrideElements) elements.push(o);
if (!Array.isArray(parentNode._children)) parentNode._children = [];
parentNode._children.push({ __id__: stubNodeId });
// 同步外层 prefab 根 PrefabInfo.nestedPrefabInstanceRootscocos 加载嵌套实例的入口列表)。
// 缺这一步运行时 stub 节点不会被解析渲染,子 prefab 内容看不到。
syncNestedRoots(elements, rootId);
return stubNodeId;
}
/**
* 重建外层 prefab 根 PrefabInfo.nestedPrefabInstanceRoots,包含所有 _parent 非 null 的活 stub 节点。
* 软删(remove-node)留下的孤儿 stub 自动排除。
*/
function syncNestedRoots(elements, rootId) {
const rootNode = elements[rootId];
if (!rootNode || !rootNode._prefab) return;
const rootPrefabInfo = elements[rootNode._prefab.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance) continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
}
module.exports = { execAddNestedPrefab };
+120
View File
@@ -0,0 +1,120 @@
// add-node: 在指定父节点下新增一个 cc.Node
// op: { op: 'add-node', parent: string|{id:N}, node: { name, lpos?, components? } }
//
// 支持:
// - 普通父节点:新节点进入 parent._children
// - stub 父节点(嵌套 prefab 实例):新节点进入 PrefabInstance.mountedChildren
// 若 node.components 包含 'UITransform',自动创建 cc.UITransform(默认 100×100
'use strict';
const { ref, makeNode, makePrefabInfo, makeCompPrefabInfo, makeUITransform } = require('../../primitives.js');
const { isStub, resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
const SUPPORTED_COMPONENTS = ['UITransform'];
function execAddNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { parent: parentSelector, node: nodeSpec } = op;
if (!nodeSpec || typeof nodeSpec.name !== 'string') {
throw new Error(`editPrefab [add-node]: node.name 必须是字符串`);
}
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-node');
if (Array.isArray(nodeSpec.components)) {
for (const comp of nodeSpec.components) {
if (typeof comp === 'string' && !SUPPORTED_COMPONENTS.includes(comp)) {
throw new Error(
`editPrefab [add-node]: unknown component type: ${comp}(已支持: ${SUPPORTED_COMPONENTS.join(', ')}`
);
}
}
}
const newNodeId = elements.length;
// 父节点 fileId 用作 deterministic 种子
let parentFileId = 'unknown';
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
const parentPrefabInfo = elements[parentNode._prefab.__id__];
if (parentPrefabInfo && parentPrefabInfo.fileId) {
parentFileId = parentPrefabInfo.fileId;
}
}
const baseSeed = `${parentFileId}#addNode#${nodeSpec.name}`;
const existingFileIds = collectExistingFileIds(elements);
const nodeFileId = uniqueFileId(baseSeed, existingFileIds);
const uitFileId = uniqueFileId(`${baseSeed}#uit`, existingFileIds);
const prefabInfoId = newNodeId + 1;
let componentIds = [];
const newObjects = [];
if (Array.isArray(nodeSpec.components) && nodeSpec.components.includes('UITransform')) {
const uitId = newNodeId + 2;
const uitPrefabInfoId = newNodeId + 3;
componentIds = [uitId];
const uitObj = makeUITransform({
nodeId: newNodeId,
width: nodeSpec.width || 100,
height: nodeSpec.height || 100,
anchor: nodeSpec.anchor || [0.5, 0.5],
prefabInfoId: uitPrefabInfoId,
});
const uitCpi = makeCompPrefabInfo(uitFileId);
newObjects.push(uitObj);
newObjects.push(uitCpi);
}
const lpos = nodeSpec.lpos || [0, 0, 0];
const newNodeObj = makeNode({
name: nodeSpec.name,
pos: lpos,
active: nodeSpec.active !== undefined ? nodeSpec.active : true,
parentId,
childIds: [],
componentIds,
prefabId: prefabInfoId,
});
const newPrefabInfoObj = makePrefabInfo({
rootId,
fileId: nodeFileId,
assetId: 0,
nestedPrefabInstanceRoots: null,
});
elements.push(newNodeObj);
elements.push(newPrefabInfoObj);
for (const o of newObjects) elements.push(o);
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (!Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = [];
}
prefabInstance.mountedChildren.push({ __id__: newNodeId });
} else {
if (!Array.isArray(parentNode._children)) {
parentNode._children = [];
}
parentNode._children.push({ __id__: newNodeId });
}
// ref 在此模块虽然没直接用,但保留 import 以便上层调试时一致;
// 实际节点对象的子引用全在 makeNode/makePrefabInfo/makeUITransform 内部生成。
void ref;
return newNodeId;
}
module.exports = { execAddNode };
+41
View File
@@ -0,0 +1,41 @@
// adjust-position: lpos 相对偏移
// op: { op:'adjust-position', node, dx?, dy?, dz? }
//
// 适合"在原位置基础上挪 N 像素"场景,免去先 query 取原值。
// 任一轴缺省视为 0。stub 节点走 setOverrideProperty,与 set-position 一致。
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execAdjustPosition(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, dx = 0, dy = 0, dz = 0 } = op;
if (typeof dx !== 'number' || typeof dy !== 'number' || typeof dz !== 'number') {
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 必须是数字`);
}
if (dx === 0 && dy === 0 && dz === 0) {
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 至少一个非零`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'adjust-position');
const lpos = node._lpos || { x: 0, y: 0, z: 0 };
const newLpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: (lpos.z || 0) + dz,
};
if (isStub(elements, node)) {
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
} else {
node._lpos = newLpos;
}
return nodeId;
}
module.exports = { execAdjustPosition };
+112
View File
@@ -0,0 +1,112 @@
// bulk-set: 按 selector 找一批节点,统一改字段(一条 op 顶 N 条)
// op: { op:'bulk-set', selector, target, property, value }
//
// selector:节点筛选条件
// { byComponent: 'cc.Label' } → 所有挂 cc.Label 的节点
// { byNamePrefix: 'btn' } → 所有 _name 以 'btn' 开头的节点
// { byNameRegex: '^icon_\\d+$' } → 正则匹配
// 多条件并存为 AND
//
// target:要改的对象层
// 'node' → 改节点字段,如 _active / _name
// 'component:<T>' → 改节点上 type=T 的组件字段(每个匹配节点都得有这个组件,否则跳过)
//
// property:字符串或字符串数组(嵌套路径)
// value:写入值
//
// 行为:
// - 匹配 0 个不算错(返回 [] 但 opsApplied 仍 +1
// - stub 节点跳过(bulk-set 不处理 stub,避免不同代码路径混用)
// - 返回所有受影响的 nodeId 数组(editPrefab 主循环会聚合到 affectedNodes
'use strict';
const { isStub, findComponent } = require('../helpers.js');
function _matchSelector(elements, node, selector) {
if (selector.byComponent) {
if (!findComponent(elements, node, selector.byComponent)) return false;
}
if (selector.byNamePrefix) {
if (typeof node._name !== 'string' || !node._name.startsWith(selector.byNamePrefix)) return false;
}
if (selector.byNameRegex) {
if (typeof node._name !== 'string') return false;
const re = new RegExp(selector.byNameRegex);
if (!re.test(node._name)) return false;
}
return true;
}
function _setNested(obj, path, value) {
if (typeof path === 'string') {
obj[path] = value;
return;
}
let cur = obj;
for (let i = 0; i < path.length - 1; i++) {
const k = path[i];
if (cur[k] === null || cur[k] === undefined || typeof cur[k] !== 'object') {
throw new Error(
`bulk-set: 路径 ${path.slice(0, i + 1).join('.')} 不是对象(${JSON.stringify(cur[k])}),无法继续下钻`
);
}
cur = cur[k];
}
cur[path[path.length - 1]] = value;
}
function execBulkSet(prefabData, op) {
const { elements } = prefabData;
const { selector, target, property, value } = op;
if (!selector || typeof selector !== 'object' || Object.keys(selector).length === 0) {
throw new Error(`editPrefab [bulk-set]: selector 必须是非空对象`);
}
if (typeof target !== 'string' || target.length === 0) {
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>'`);
}
if (
!(typeof property === 'string' && property.length > 0) &&
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string'))
) {
throw new Error(`editPrefab [bulk-set]: property 必须是非空字符串或字符串数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [bulk-set]: value 不能是 undefined`);
}
let targetKind = target;
let targetCompType = null;
if (target.startsWith('component:')) {
targetCompType = target.slice('component:'.length);
if (targetCompType.length === 0) {
throw new Error(`editPrefab [bulk-set]: target='component:' 后必须跟组件类型`);
}
targetKind = 'component';
} else if (target !== 'node') {
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>',收到 "${target}"`);
}
const affected = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (isStub(elements, el)) continue;
if (!_matchSelector(elements, el, selector)) continue;
if (targetKind === 'node') {
_setNested(el, property, value);
} else {
const comp = findComponent(elements, el, targetCompType);
if (!comp) continue; // 节点匹配但没这个组件,跳过
_setNested(comp, property, value);
}
affected.push(i);
}
// 至少返回一个 id 让 affectedNodes 不报错(即使 0 匹配也不算 op fail)
return affected.length > 0 ? affected[0] : -1;
}
module.exports = { execBulkSet };
+150
View File
@@ -0,0 +1,150 @@
// clone-node: 深拷贝 source 及其整棵子树,挂到 parent 下
// op: { op: 'clone-node', source: string|{id:N}, parent: string|{id:N}, name: string }
//
// - 为每个新节点/组件分配新 __id__(push 到数组末尾)
// - 为每个新节点和组件生成新 fileIddeterministic,种子基于 source fileId + newName
// - 更新所有内部 _parent 引用指向新副本
// - 新树挂到 parent._children(若 parent 是 stub 则走 mountedChildren
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
function execCloneNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { source: sourceSelector, parent: parentSelector, name: newName } = op;
if (typeof newName !== 'string') {
throw new Error(`editPrefab [clone-node]: name 必须是字符串`);
}
const { node: sourceNode, nodeId: sourceId } = resolveNode(prefabData, sourceSelector, 'clone-node');
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'clone-node');
const oldToNew = new Map();
function collectSubtreeNodeIds(nodeId) {
const ids = [nodeId];
const node = elements[nodeId];
if (node && Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ === 'number') {
ids.push(...collectSubtreeNodeIds(childRef.__id__));
}
}
}
return ids;
}
const subtreeNodeIds = collectSubtreeNodeIds(sourceId);
const allSourceIds = [];
for (const nid of subtreeNodeIds) {
allSourceIds.push(nid);
const n = elements[nid];
if (!n) continue;
if (n._prefab && typeof n._prefab.__id__ === 'number') {
allSourceIds.push(n._prefab.__id__);
}
if (Array.isArray(n._components)) {
for (const cRef of n._components) {
if (typeof cRef.__id__ === 'number') {
const compId = cRef.__id__;
allSourceIds.push(compId);
const comp = elements[compId];
if (comp && comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
allSourceIds.push(comp.__prefab.__id__);
}
}
}
}
}
const uniqueSourceIds = [...new Set(allSourceIds)];
const insertStart = elements.length;
for (let i = 0; i < uniqueSourceIds.length; i++) {
oldToNew.set(uniqueSourceIds[i], insertStart + i);
elements.push(null);
}
let sourceFileId = 'unknown';
if (sourceNode._prefab && typeof sourceNode._prefab.__id__ === 'number') {
const srcPInfo = elements[sourceNode._prefab.__id__];
if (srcPInfo && srcPInfo.fileId) sourceFileId = srcPInfo.fileId;
}
const cloneBaseSeed = `${sourceFileId}#clone#${newName}`;
const cloneExistingFileIds = collectExistingFileIds(elements);
let cloneGenCounter = 0;
function cloneGen() {
const subSeed = `${cloneBaseSeed}#slot${cloneGenCounter++}`;
return uniqueFileId(subSeed, cloneExistingFileIds);
}
function cloneObj(obj) {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(cloneObj);
if (typeof obj.__id__ === 'number') {
const newId = oldToNew.get(obj.__id__);
if (newId !== undefined) return { __id__: newId };
return { ...obj };
}
const result = {};
for (const k of Object.keys(obj)) {
result[k] = cloneObj(obj[k]);
}
return result;
}
for (const oldId of uniqueSourceIds) {
const newId = oldToNew.get(oldId);
const srcObj = elements[oldId];
if (!srcObj) {
elements[newId] = null;
continue;
}
const cloned = cloneObj(srcObj);
if (cloned.__type__ === 'cc.PrefabInfo') {
cloned.fileId = cloneGen();
cloned.root = { __id__: rootId };
cloned.asset = { __id__: 0 };
cloned.instance = null;
cloned.targetOverrides = null;
cloned.nestedPrefabInstanceRoots = null;
}
if (cloned.__type__ === 'cc.CompPrefabInfo') {
cloned.fileId = cloneGen();
}
elements[newId] = cloned;
}
const newRootId = oldToNew.get(sourceId);
const newRootNode = elements[newRootId];
newRootNode._name = newName;
newRootNode._parent = { __id__: parentId };
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (!Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = [];
}
prefabInstance.mountedChildren.push({ __id__: newRootId });
} else {
if (!Array.isArray(parentNode._children)) {
parentNode._children = [];
}
parentNode._children.push({ __id__: newRootId });
}
return newRootId;
}
module.exports = { execCloneNode };
+132
View File
@@ -0,0 +1,132 @@
// dedupe-component: 合并同节点上同语义但重复挂载的组件条目
//
// 背景:cli 若用 className 写入 __type__(如 "GMUI"),而 Cocos 编辑器 reimport
// 时会把 __type__ 规范化为压缩 classId(如 "a57b6RRA21B5I70mCpu1pBP"),
// 在 TS 脚本尚未注册时 @property refs 会被丢弃,造成同节点出现「字符串版 +
// 压缩版」两份组件,其中一份 refs 完整、另一份全 null。本 op 把它们合并成一条。
//
// op: { op: 'dedupe-component', node? }
// - node: 仅扫指定节点;缺省 → 扫整个 prefab 所有普通节点
//
// 策略:
// 1. 按 normalizeComponentType() 后的 compType 分组
// 2. 同 compType >=2 命中时,选非空 @property 字段最多的作为 keeper
// 3. 把 losers 的非空字段合并进 keeperkeeper 为 null/undefined 才填)
// 4. keeper.__type__ 写成规范化后的 compType
// 5. losers 的 comp idx 和 __prefab CompPrefabInfo idx 进入删除集
// 6. _components 数组过滤被删的引用
// 7. 其他 elements 里 __id__ 指向被删组件的引用映射到 keeper 新 id
// 8. 全部 __id__ 按缩减后的索引重映射 + splice 实际删除
// 限制:stub 节点暂不处理。
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const {
countPropertyRefs,
isReservedCompField,
filterCompRefsInElements,
redirectIdsAcrossElements,
buildShiftMap,
shiftIdsAcrossElements,
} = require('../id-utils.js');
function execDedupeComponent(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op || {};
// ── 1. 决定扫哪些节点
const targets = [];
if (nodeSelector == null) {
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el && el.__type__ === 'cc.Node') targets.push({ node: el, nodeId: i });
}
} else {
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'dedupe-component');
targets.push({ node, nodeId });
}
// ── 2. 逐节点分组,找到所有要合并的 group
const merges = [];
const affectedNodes = new Set();
for (const { node, nodeId } of targets) {
if (isStub(elements, node)) continue;
if (!Array.isArray(node._components)) continue;
const groups = new Map();
for (const cref of node._components) {
if (!cref || typeof cref.__id__ !== 'number') continue;
const comp = elements[cref.__id__];
if (!comp || typeof comp.__type__ !== 'string') continue;
const normalized = normalizeComponentType(comp.__type__, prefabData.resolverStartPath);
if (!groups.has(normalized)) groups.set(normalized, []);
groups.get(normalized).push({ compId: cref.__id__, comp });
}
for (const [normalized, list] of groups.entries()) {
if (list.length < 2) continue;
const scored = list.map((x) => ({ ...x, score: countPropertyRefs(x.comp) }));
scored.sort((a, b) => b.score - a.score || a.compId - b.compId);
const keeper = scored[0];
const losers = scored.slice(1);
for (const loser of losers) {
for (const [k, v] of Object.entries(loser.comp)) {
if (isReservedCompField(k)) continue;
if ((keeper.comp[k] === null || keeper.comp[k] === undefined) && v !== null && v !== undefined) {
keeper.comp[k] = v;
}
}
}
keeper.comp.__type__ = normalized;
merges.push({
keeperCompId: keeper.compId,
loserCompIds: losers.map((x) => x.compId),
normalizedType: normalized,
nodeId,
});
affectedNodes.add(nodeId);
}
}
if (merges.length === 0) return [];
// ── 3. 收集要删除的 elements id 与「loser→keeper」重定向
const deleteSet = new Set();
const redirect = new Map();
for (const m of merges) {
for (const loserId of m.loserCompIds) {
deleteSet.add(loserId);
redirect.set(loserId, m.keeperCompId);
const loserComp = elements[loserId];
const pref = loserComp && loserComp.__prefab;
if (pref && typeof pref.__id__ === 'number') {
deleteSet.add(pref.__id__);
}
}
}
// ── 4. 先把所有节点的 _components / mountedComponents 数组过滤掉被删的引用
filterCompRefsInElements(elements, deleteSet);
// ── 5. __id__ 重定向(loser → keeper
redirectIdsAcrossElements(elements, redirect);
// ── 6. 构建 shift 映射 + 执行删除
const shiftMap = buildShiftMap(elements.length, deleteSet);
shiftIdsAcrossElements(elements, shiftMap);
const sortedToDel = Array.from(deleteSet).sort((a, b) => b - a);
for (const idx of sortedToDel) elements.splice(idx, 1);
// ── 7. 更新 prefabData.rootId
if (typeof prefabData.rootId === 'number' && shiftMap[prefabData.rootId] != null) {
prefabData.rootId = shiftMap[prefabData.rootId];
}
return Array.from(affectedNodes).map((id) => shiftMap[id] ?? id);
}
module.exports = { execDedupeComponent };
+109
View File
@@ -0,0 +1,109 @@
// ensure-meta: 给指定 .ts / .json 文件创建 .meta(如果不存在)
// op: { op:'ensure-meta', path }
//
// 用途:新建 .ts / .ctrl.json 后 cocos 编辑器尚未生成 .meta,但 cli 后续要用
// className → classId 查表(add-component 等)。这时在 add-component 之前插一条
// ensure-meta,主动写一个标准 .meta(v4 uuid + 按扩展名选模板),让 cli 当场能查到表,
// 而不必等 cocos 编辑器异步 import。
//
// 路径规则:path 是绝对路径,或相对项目根(如 'assets/scripts/.../X.ts')。
// 已存在 .meta 时幂等不动。
// dry-run 时不写盘(让 --dry-run 语义一致)。
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { clearCache } = require('../../classid-resolver.js');
function _v4Uuid() {
const b = crypto.randomBytes(16);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = b.toString('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
}
const _META_TEMPLATES = {
'.ts': (uuid) => ({
ver: '4.0.24',
importer: 'typescript',
imported: true,
uuid,
files: [],
subMetas: {},
userData: { simulateGlobals: [] },
}),
'.json': (uuid) => ({
ver: '2.0.1',
importer: 'json',
imported: true,
uuid,
files: ['.json'],
subMetas: {},
userData: {},
}),
};
function _resolveProjectRoot(startPath) {
let dir = fs.statSync(startPath).isDirectory() ? startPath : path.dirname(startPath);
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, 'package.json')) && fs.existsSync(path.join(dir, 'assets'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function execEnsureMeta(prefabData, op) {
if (typeof op.path !== 'string' || op.path.length === 0) {
throw new Error("ensure-meta: 缺必填字段 'path'");
}
let filePath = op.path;
if (!path.isAbsolute(filePath)) {
const projectRoot = _resolveProjectRoot(prefabData.resolverStartPath);
if (!projectRoot) {
throw new Error(
`ensure-meta: 无法定位项目根(含 assets/+package.json),请用绝对 path`
);
}
filePath = path.resolve(projectRoot, op.path);
}
if (!fs.existsSync(filePath)) {
throw new Error(`ensure-meta: 文件不存在: ${filePath}`);
}
const metaPath = filePath + '.meta';
if (fs.existsSync(metaPath)) {
// 幂等:已存在不动
return -1;
}
const ext = path.extname(filePath).toLowerCase();
const template = _META_TEMPLATES[ext];
if (!template) {
throw new Error(
`ensure-meta: 不支持的文件扩展名 "${ext}"(当前支持: ${Object.keys(_META_TEMPLATES).join(' / ')}`
);
}
if (prefabData.dryRun) {
// dry-run 模式不落盘
return -1;
}
const meta = template(_v4Uuid());
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf8');
// 同 batch 后续 op(如 add-component)会调 resolveClassIdByName 查表,
// resolver 有进程内 cache,必须 invalidate 让下次重扫覆盖新建的 meta
clearCache();
return -1;
}
module.exports = { execEnsureMeta };
+78
View File
@@ -0,0 +1,78 @@
// remove-component: 从普通节点 `_components` 数组移除指定组件的引用,
// 组件元素本身保留为 orphan(保持其他 __id__ 稳定,与 remove-node 同策略)。
// 关联的 cc.CompPrefabInfo 也随之 orphan(它只被 component._ _prefab 引用)。
//
// 同步清根 PrefabInfo.targetOverrides 中 source 指向被删组件的悬空条目:
// 外层脚本通过 targetOverride 把嵌套 stub 内部组件/节点挂到自己 @property 字段时,
// 删组件后这些 override 仍被根 PrefabInfo 引用 → 可达悬空引用 → cocos 解析时
// 反序列化 source.__id__ 触发 missing-class 报错。
//
// op: { op: 'remove-component', node, componentType }
//
// 不支持 stub 节点:嵌套 prefab 的组件由子 prefab 拥有,外层无法删除,
// 只能 set-component-enabled 禁用。stub 上调用本 op 会抛错。
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const { cleanupRootTargetOverrides } = require('../id-utils.js');
function execRemoveComponent(prefabData, op) {
const { elements, rootId } = prefabData;
const { node: nodeSelector, componentType: rawCompType } = op;
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
throw new Error(`editPrefab [remove-component]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'remove-component');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 是 stub(嵌套 prefab 根),无法删除其内部组件;改用 set-component-enabled 禁用`
);
}
if (!Array.isArray(node._components) || node._components.length === 0) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 没有 _components 数组`
);
}
let matchedCompId = -1;
const next = [];
for (const ref of node._components) {
if (!ref || typeof ref.__id__ !== 'number') {
next.push(ref);
continue;
}
const comp = elements[ref.__id__];
if (matchedCompId < 0 && comp && comp.__type__ === componentType) {
matchedCompId = ref.__id__;
continue; // 丢弃这条引用
}
next.push(ref);
}
if (matchedCompId < 0) {
throw new Error(
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 上找不到 ${rawCompType} 组件`
);
}
node._components = next;
// 收集被删组件相关 __id__:组件本身 + 它的 cc.CompPrefabInfo__prefab 字段)。
// targetOverride 的 source 一般指向组件本身;带上 CompPrefabInfo 为防御性兜底。
const removedIds = new Set([matchedCompId]);
const matchedComp = elements[matchedCompId];
if (matchedComp && matchedComp.__prefab && typeof matchedComp.__prefab.__id__ === 'number') {
removedIds.add(matchedComp.__prefab.__id__);
}
cleanupRootTargetOverrides(elements, rootId, removedIds);
return nodeId;
}
module.exports = { execRemoveComponent };
+98
View File
@@ -0,0 +1,98 @@
// remove-node: 从父 _children(或 stub 的 mountedChildren)移除节点引用,
// 并递归断开整棵子树所有节点/组件的 _parent 引用。
// 节点元素本身保留在数组(保持其他 __id__ 稳定)。
// op: { op: 'remove-node', target: string|{id:N} }
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
const { disconnectSubtree, cleanupRootTargetOverrides, syncNestedRoots } = require('../id-utils.js');
function execRemoveNode(prefabData, op) {
const { elements, rootId } = prefabData;
const { target: targetSelector } = op;
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'remove-node');
if (!targetNode._parent || typeof targetNode._parent.__id__ !== 'number') {
throw new Error(`editPrefab [remove-node]: 目标节点没有父节点,无法移除(根节点不能删除)`);
}
const parentId = targetNode._parent.__id__;
const parentNode = elements[parentId];
if (!parentNode || parentNode.__type__ !== 'cc.Node') {
throw new Error(`editPrefab [remove-node]: 父节点 __id__=${parentId} 不是有效 cc.Node`);
}
if (isStub(elements, parentNode)) {
const prefabRef = parentNode._prefab;
const parentPrefabInfo = elements[prefabRef.__id__];
const instanceRef = parentPrefabInfo.instance;
const prefabInstance = elements[instanceRef.__id__];
if (Array.isArray(prefabInstance.mountedChildren)) {
prefabInstance.mountedChildren = prefabInstance.mountedChildren.filter(
(r) => r.__id__ !== targetId
);
}
} else {
if (Array.isArray(parentNode._children)) {
parentNode._children = parentNode._children.filter(
(r) => r.__id__ !== targetId
);
}
}
// 收集整棵子树的所有 __id__(节点/组件/PrefabInfo/PrefabInstance)。
// 必须在 disconnectSubtree 之前——后者会清空 mountedChildren、置 pi.instance=null
// 之后就拿不到嵌套实例的关联对象了。
const subtreeIds = collectSubtreeIds(elements, targetId);
disconnectSubtree(elements, targetId);
// 软删后同步外层 PrefabInfo.nestedPrefabInstanceRoots,清掉孤儿 stub 引用
syncNestedRoots(elements, rootId);
// 清掉根 PrefabInfo.targetOverrides 中 source/target 指向被删子树的悬空条目。
// 外层脚本对嵌套 stub 内部组件/节点的引用(如 _passScoreView → scoreView)走 targetOverride
// 删了 stub 后这条 override 仍被根 PrefabInfo 引用 → 可达悬空引用,运行时解析会报错。
cleanupRootTargetOverrides(elements, rootId, subtreeIds);
return targetId;
}
// 收集子树所有相关 __id__:节点、其组件、_prefab(PrefabInfo)、instance(PrefabInstance)、
// 以及 mountedChildren 指向的嵌套子树。供 targetOverride 悬空判断用。
function collectSubtreeIds(elements, nodeId, acc) {
acc = acc || new Set();
const node = elements[nodeId];
if (!node || node.__type__ !== 'cc.Node' || acc.has(nodeId)) return acc;
acc.add(nodeId);
if (Array.isArray(node._children)) {
for (const c of node._children) {
if (c && typeof c.__id__ === 'number') collectSubtreeIds(elements, c.__id__, acc);
}
}
if (node._prefab && typeof node._prefab.__id__ === 'number') {
acc.add(node._prefab.__id__);
const pi = elements[node._prefab.__id__];
if (pi && pi.instance && typeof pi.instance.__id__ === 'number') {
acc.add(pi.instance.__id__);
const inst = elements[pi.instance.__id__];
if (inst && Array.isArray(inst.mountedChildren)) {
for (const mc of inst.mountedChildren) {
if (mc && typeof mc.__id__ === 'number') collectSubtreeIds(elements, mc.__id__, acc);
}
}
}
}
if (Array.isArray(node._components)) {
for (const c of node._components) {
if (c && typeof c.__id__ === 'number') acc.add(c.__id__);
}
}
return acc;
}
module.exports = { execRemoveNode };
+32
View File
@@ -0,0 +1,32 @@
// rename-node: 改节点 _name
// op: { op:'rename-node', node, name }
//
// 普通节点:直接改 node._name
// stub 节点:name 存在 PrefabInstance.propertyOverrides 而不是 node._name
// 走 setOverrideProperty(['_name']) 与 set-active 同模式
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execRenameNode(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, name } = op;
if (typeof name !== 'string' || name.length === 0) {
throw new Error(`editPrefab [rename-node]: name 必须是非空字符串`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'rename-node');
if (isStub(elements, node)) {
setOverrideProperty(prefabData, nodeId, ['_name'], name);
} else {
node._name = name;
}
return nodeId;
}
module.exports = { execRenameNode };
+75
View File
@@ -0,0 +1,75 @@
// reorder-children: 调整节点的 _children 顺序(影响 UI 渲染层级)
// op: { op:'reorder-children', node, order }
//
// order:子节点名字数组(必须包含全部 _children 的 name),按这个顺序重排
// 或 __id__ 数组:[{id:N}, {id:M}, ...]
//
// stub 节点暂不支持(mountedChildren 顺序场景少见)
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
function execReorderChildren(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, order } = op;
if (!Array.isArray(order) || order.length === 0) {
throw new Error(`editPrefab [reorder-children]: order 必须是非空数组`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reorder-children');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [reorder-children]: 节点 "${node._name}" 是 stubstub 子节点重排暂不支持`
);
}
if (!Array.isArray(node._children)) {
throw new Error(`editPrefab [reorder-children]: 节点 "${node._name}" 没有 _children`);
}
const childMap = new Map(); // key (name 或 id) -> child ref
for (const cref of node._children) {
if (typeof cref.__id__ !== 'number') continue;
const child = elements[cref.__id__];
if (!child) continue;
childMap.set(cref.__id__, cref);
if (typeof child._name === 'string' && child._name.length > 0) {
childMap.set(child._name, cref);
}
}
if (order.length !== node._children.length) {
throw new Error(
`editPrefab [reorder-children]: order 长度 ${order.length} ≠ _children 长度 ${node._children.length}(必须包含所有子节点)`
);
}
const newChildren = [];
const seen = new Set();
for (const item of order) {
let key;
if (typeof item === 'string') {
key = item;
} else if (item && typeof item.id === 'number') {
key = item.id;
} else {
throw new Error(`editPrefab [reorder-children]: order 元素必须是字符串名或 {id:N},收到 ${JSON.stringify(item)}`);
}
const ref = childMap.get(key);
if (!ref) {
throw new Error(`editPrefab [reorder-children]: order 中的 "${key}" 不在 _children 内`);
}
if (seen.has(ref.__id__)) {
throw new Error(`editPrefab [reorder-children]: order 中重复出现 "${key}"`);
}
seen.add(ref.__id__);
newChildren.push(ref);
}
node._children = newChildren;
return nodeId;
}
module.exports = { execReorderChildren };
+91
View File
@@ -0,0 +1,91 @@
// reparent: 把节点从原父节点下移到新父节点下(不复制,原节点搬家)
// op: { op:'reparent', node, parent, index? }
//
// 行为:
// 1. 从原 parent._children 数组里移除 node 引用
// 2. 把 node 引用 push 到新 parent._children(或按 index 插入指定位置)
// 3. 改 node._parent 指向新 parent
//
// 限制:
// - 不支持 stub 节点(嵌套 prefab 实例)作为 source 或 target
// stub 的父子关系存在 PrefabInstance.mountedChildren / nestedPrefabInstanceRoots
// 需要独立的 nested-reparent op,本 op 仅处理普通 inline 节点
// - node 不能是 prefab 根节点(rootId=1),根节点 _parent 必须为 null
// - parent 不能是 node 的后代(避免循环)
// - 不修改 PrefabInfo.fileId(节点身份不变,外部引用仍然有效)
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
/** node 是否是 parent(或其后代)的祖先 → 循环检测 */
function isAncestorOf(elements, ancestorId, candidateId) {
let cur = candidateId;
let safety = 0;
while (cur != null && safety++ < 10000) {
if (cur === ancestorId) return true;
const node = elements[cur];
if (!node || !node._parent) return false;
cur = node._parent.__id__;
}
return false;
}
function execReparent(prefabData, op) {
const { elements, rootId } = prefabData;
const { node: nodeSelector, parent: parentSelector, index } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reparent');
const { node: newParent, nodeId: newParentId } = resolveNode(prefabData, parentSelector, 'reparent');
// 根节点不能搬家
if (nodeId === rootId) {
throw new Error(`editPrefab [reparent]: 根节点(id=${rootId})不能 reparent,其 _parent 必须为 null`);
}
// stub 检查
if (isStub(elements, node)) {
throw new Error(`editPrefab [reparent]: source 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
}
if (isStub(elements, newParent)) {
throw new Error(`editPrefab [reparent]: target parent 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
}
// 同一节点不动
const oldParentId = node._parent ? node._parent.__id__ : null;
if (oldParentId === newParentId) {
// 仅 index 调整 → 走 reorder-children 更清晰;这里允许只换位(reorder 调整)
if (index === undefined) return nodeId;
}
// 循环检测:newParent 不能是 node 的后代
if (isAncestorOf(elements, nodeId, newParentId)) {
throw new Error(`editPrefab [reparent]: 循环引用——新父节点(id=${newParentId})是源节点(id=${nodeId})的后代`);
}
// 1. 从原 parent._children 移除
if (oldParentId != null) {
const oldParent = elements[oldParentId];
if (oldParent && Array.isArray(oldParent._children)) {
oldParent._children = oldParent._children.filter(
(c) => !c || c.__id__ !== nodeId
);
}
}
// 2. 加到新 parent._children
if (!Array.isArray(newParent._children)) newParent._children = [];
const ref = { __id__: nodeId };
if (typeof index === 'number' && index >= 0 && index < newParent._children.length) {
newParent._children.splice(index, 0, ref);
} else {
newParent._children.push(ref);
}
// 3. 改 node._parent
node._parent = { __id__: newParentId };
return nodeId;
}
module.exports = { execReparent };
@@ -0,0 +1,57 @@
// replace-nested-prefab: 替换 stub 节点(嵌套 prefab 实例)引用的外部 prefab asset。
// 改 PrefabInfo.asset.__uuid__;可选清空 PrefabInstance.propertyOverrides。
//
// 适用场景:
// 想把 ListItem.prefab 里某个嵌套子 prefab 从 OldPrefab 换成 NewPrefab,但
// 保留 stub 节点的父子关系、_prefab fileId 不变(即 ListItem 内的 __id__
// 引用稳定)。
//
// 注意:
// - propertyOverrides 里的 targetFileId 是按老 prefab 内部 fileId 写的,新
// prefab 通常没有对应 fileId。默认保留 overrides(编辑器加载时 skip 找不
// 到的 override,不报错);clearOverrides=true 显式清空更干净。
// - 不修改 PrefabInstance.fileId(这个是 stub 在外层 prefab 内的稳定标识,
// 跟外部 prefab 的 fileId 无关)。
//
// op: { op: 'replace-nested-prefab', target: string|{id:N}, prefabUuid: string, clearOverrides?: boolean }
'use strict';
const { isStub, resolveNode } = require('../helpers.js');
function execReplaceNestedPrefab(prefabData, op) {
const { elements } = prefabData;
const { target: targetSelector, prefabUuid, clearOverrides } = op;
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
throw new Error(`editPrefab [replace-nested-prefab]: prefabUuid 必须是非空字符串`);
}
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'replace-nested-prefab');
if (!isStub(elements, targetNode)) {
throw new Error(`editPrefab [replace-nested-prefab]: 目标节点 [${targetId}] 不是嵌套 prefab stub(无 _prefab.instance`);
}
if (!targetNode._prefab || typeof targetNode._prefab.__id__ !== 'number') {
throw new Error(`editPrefab [replace-nested-prefab]: stub 节点 [${targetId}] 没有 _prefab 引用`);
}
const prefabInfo = elements[targetNode._prefab.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error(`editPrefab [replace-nested-prefab]: _prefab 指向的不是 cc.PrefabInfo`);
}
if (!prefabInfo.asset || typeof prefabInfo.asset !== 'object') {
throw new Error(`editPrefab [replace-nested-prefab]: PrefabInfo 缺 asset 字段`);
}
prefabInfo.asset.__uuid__ = prefabUuid.trim();
if (clearOverrides === true && prefabInfo.instance && typeof prefabInfo.instance.__id__ === 'number') {
const prefabInstance = elements[prefabInfo.instance.__id__];
if (prefabInstance && Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
}
}
module.exports = { execReplaceNestedPrefab };
+127
View File
@@ -0,0 +1,127 @@
// reset-overrides: 清除 stub 节点的 propertyOverrides(回滚到嵌套 prefab 默认值)
// op: { op:'reset-overrides', node, property?, componentType?, subNode?, all? }
//
// 调用形态:
// 1) all=true:清空 stub 的整个 propertyOverrides 数组(一键回滚)
// 不能同时指定 property / componentType
// 2) property(无 componentType):清匹配 stub 节点字段 override
// target = stub 自身 fileIdpropertyPath = [property]
// 常见字段 _lpos / _name / _active / _lscale 等
// 3) property + componentType:清嵌套内某组件字段 override
// subNode 用于嵌套 prefab 内同类型组件消歧(同 set-nested-component-field
//
// 移除的 CCPropertyOverrideInfo / TargetInfo 作为 orphan 留在 elements
// 保持其他 __id__ 稳定(与 remove-node / remove-component 同策略)。
//
// 幂等:未找到匹配 override 不报错(缺省静默,CC3_MCP_DEBUG=1 时打 warn)。
'use strict';
const { isStub, resolveNode, normalizeComponentType } = require('../helpers.js');
const { getNestedCompFileId, getNestedNodeFileId } = require('../nested.js');
function execResetOverrides(prefabData, op) {
const { elements } = prefabData;
const {
node: nodeSelector,
property,
componentType: rawComponentType,
subNode = null,
all = false,
} = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reset-overrides');
if (!isStub(elements, node)) {
throw new Error(
`editPrefab [reset-overrides]: 节点 "${node._name || nodeId}" 不是 stub,普通节点没有 propertyOverrides`
);
}
const prefabInfo = elements[node._prefab.__id__];
const prefabInstance = elements[prefabInfo.instance.__id__];
const stubFileId = prefabInfo.fileId;
// 模式 1:清空全部
if (all) {
if (property !== undefined || rawComponentType !== undefined) {
throw new Error(
`editPrefab [reset-overrides]: all=true 时禁止同时提供 property / componentType`
);
}
if (Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
return nodeId;
}
// 模式 2/3:按 propertyPath 匹配单条
if (property === undefined) {
throw new Error(
`editPrefab [reset-overrides]: 必须提供 property,或显式 all=true 清空全部`
);
}
if (typeof property !== 'string' && !Array.isArray(property)) {
throw new Error(`editPrefab [reset-overrides]: property 必须是字符串或数组`);
}
const propertyPath = Array.isArray(property) ? property : [property];
// 决定要匹配的 localID[0]
// 节点字段 override(无 componentType):嵌套 prefab 内根节点 fileId 是 Cocos 运行时
// 实际识别的 key(见 overrides.js 地雷 3)。
let targetFileId;
if (rawComponentType) {
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
targetFileId = getNestedCompFileId(prefabData.resolverStartPath, elements, nodeId, componentType, subNode);
} else {
if (subNode !== null && subNode !== undefined) {
throw new Error(
`editPrefab [reset-overrides]: subNode 必须与 componentType 一起用(节点字段 override 无嵌套子节点定位)`
);
}
targetFileId = getNestedNodeFileId(prefabData.resolverStartPath, elements, nodeId, null);
}
if (!Array.isArray(prefabInstance.propertyOverrides) || prefabInstance.propertyOverrides.length === 0) {
return nodeId; // 无 override 数组,幂等返回
}
const remaining = [];
let removed = 0;
for (const ref of prefabInstance.propertyOverrides) {
if (!ref || typeof ref.__id__ !== 'number') {
remaining.push(ref);
continue;
}
const info = elements[ref.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') {
remaining.push(ref);
continue;
}
const tiRef = info.targetInfo;
const ti = tiRef && typeof tiRef.__id__ === 'number' ? elements[tiRef.__id__] : null;
if (!ti || !Array.isArray(ti.localID) || ti.localID[0] !== targetFileId) {
remaining.push(ref);
continue;
}
if (!Array.isArray(info.propertyPath) || info.propertyPath.length !== propertyPath.length) {
remaining.push(ref);
continue;
}
if (info.propertyPath.every((p, i) => p === propertyPath[i])) {
removed++;
continue;
}
remaining.push(ref);
}
if (removed === 0 && process.env.CC3_MCP_DEBUG) {
console.warn(
`[reset-overrides] stub [${nodeId}]: 未找到匹配 propertyPath=${JSON.stringify(propertyPath)} target=${targetFileId} 的 override(无操作)`
);
}
prefabInstance.propertyOverrides = remaining;
return nodeId;
}
module.exports = { execResetOverrides };
+28
View File
@@ -0,0 +1,28 @@
// set-active: 设置节点 _active
// op: { op, node, active }
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execSetActive(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, active } = op;
if (typeof active !== 'boolean') {
throw new Error(`editPrefab [set-active]: active 必须是布尔值`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-active');
if (isStub(elements, node)) {
setOverrideProperty(prefabData, id, ['_active'], active);
} else {
node._active = active;
}
return id;
}
module.exports = { execSetActive };
+117
View File
@@ -0,0 +1,117 @@
// set-anchor: cc.UITransform 锚点便捷写法 + 自动补偿 lpos
// op: { op:'set-anchor', node, x?, y?, compensatePosition? }
//
// - x / y 为新 anchor 值(0~1),任一缺省则保留原值
// - compensatePosition: true 时按 anchor 差值 * 节点 size 自动补偿 lpos
// 补偿公式:lpos.x += width * (newAnchorX - oldAnchorX)
// lpos.y += height * (newAnchorY - oldAnchorY)
// 场景:改 anchor 又想保持节点视觉位置不动
// - stub 节点:_anchorPoint 走 PrefabInstance.propertyOverrides 写嵌套 UITransform
// compensate 时 _lpos 走 stub 节点自身的 propertyOverrides(节点字段)。
// oldA / size 从嵌套 prefab 默认值读(不查 propertyOverrides 历史值)。
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
const { setOverrideProperty } = require('../../overrides.js');
const { parsePrefab } = require('../../parse.js');
const { resolveUuidToPath } = require('../../uuid-resolver.js');
// 从嵌套 prefab 内读 root UITransform 的 _anchorPoint / _contentSize(默认值)
function _readNestedUITransform(hostPath, elements, stubNodeId) {
const stub = elements[stubNodeId];
const pi = elements[stub._prefab.__id__];
const nestedUuid = pi.asset.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (el && el.__type__ === 'cc.UITransform') {
const a = el._anchorPoint || { x: 0.5, y: 0.5 };
const s = el._contentSize || { width: 0, height: 0 };
return {
anchor: { x: a.x || 0, y: a.y || 0 },
size: { width: s.width || 0, height: s.height || 0 },
};
}
}
return { anchor: { x: 0.5, y: 0.5 }, size: { width: 0, height: 0 } };
}
function execSetAnchor(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, x, y, compensatePosition = false } = op;
if (x === undefined && y === undefined) {
throw new Error(`editPrefab [set-anchor]: 至少提供 x 或 y 之一`);
}
if (x !== undefined && (typeof x !== 'number' || x < 0 || x > 1)) {
throw new Error(`editPrefab [set-anchor]: x 必须是 0~1 数字`);
}
if (y !== undefined && (typeof y !== 'number' || y < 0 || y > 1)) {
throw new Error(`editPrefab [set-anchor]: y 必须是 0~1 数字`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-anchor');
if (isStub(elements, node)) {
const { anchor: oldA, size } = _readNestedUITransform(
prefabData.resolverStartPath, elements, nodeId
);
const newA = {
__type__: 'cc.Vec2',
x: x === undefined ? oldA.x : x,
y: y === undefined ? oldA.y : y,
};
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_anchorPoint'], newA);
if (compensatePosition) {
// stub 节点 _lpos 改值走自身 propertyOverrides(节点字段,不是组件字段)
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
const dx = size.width * (newA.x - oldA.x);
const dy = size.height * (newA.y - oldA.y);
const newLpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: lpos.z || 0,
};
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
}
return nodeId;
}
const ut = findComponent(elements, node, 'cc.UITransform');
if (!ut) {
throw new Error(`editPrefab [set-anchor]: 节点 "${node._name}" 上没有 cc.UITransform`);
}
const oldA = ut._anchorPoint || { x: 0.5, y: 0.5 };
const newA = {
__type__: 'cc.Vec2',
x: x === undefined ? oldA.x : x,
y: y === undefined ? oldA.y : y,
};
ut._anchorPoint = newA;
if (compensatePosition) {
const size = ut._contentSize || { width: 0, height: 0 };
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
const dx = (size.width || 0) * (newA.x - oldA.x);
const dy = (size.height || 0) * (newA.y - oldA.y);
node._lpos = {
__type__: 'cc.Vec3',
x: (lpos.x || 0) + dx,
y: (lpos.y || 0) + dy,
z: lpos.z || 0,
};
}
return nodeId;
}
module.exports = { execSetAnchor };
+52
View File
@@ -0,0 +1,52 @@
// set-button: 批量设置节点上 cc.Button 的常用字段
// op: {
// op: 'set-button',
// node,
// interactable?: boolean
// transition?: 0=NONE 1=COLOR 2=SPRITE 3=SCALE
// zoomScale?: numbertransition=SCALE 时的缩放比例)
// duration?: number(过渡动画时长,秒)
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
interactable: '_interactable',
transition: '_transition',
zoomScale: '_zoomScale',
duration: '_duration',
};
function execSetButton(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-button');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-button]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Button');
if (!comp) {
throw new Error(`editPrefab [set-button]: 节点 "${node._name}" 上找不到 cc.Button 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-button]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetButton };
@@ -0,0 +1,44 @@
// set-component-enabled: 改组件 _enabled
// op: { op:'set-component-enabled', node, componentType, enabled }
//
// 普通节点直接改 comp._enabled
// stub 节点:写 PrefabInstance.propertyOverrides(与 set-nested-component-field 同模式)
'use strict';
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetComponentEnabled(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawCompType, enabled, subNode = null } = op;
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
throw new Error(`editPrefab [set-component-enabled]: componentType 必须是非空字符串`);
}
if (typeof enabled !== 'boolean') {
throw new Error(`editPrefab [set-component-enabled]: enabled 必须是布尔值`);
}
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-enabled');
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_enabled'], enabled);
} else {
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(
`editPrefab [set-component-enabled]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
);
}
comp._enabled = enabled;
}
return nodeId;
}
module.exports = { execSetComponentEnabled };
+68
View File
@@ -0,0 +1,68 @@
// set-component-field: 普通节点改任意组件字段
// op: { op:'set-component-field', node, componentType, property, value }
//
// set-nested-component-field 只覆盖 stub 节点;本 op 是普通节点版本。
//
// - node: 普通节点选择器(不能是 stub)
// - property: 字段名,可以是字符串(顶层字段)或字符串数组(嵌套路径)
// 例:'_string' / ['_color', 'r'] / ['_anchorPoint', 'x']
// - value: 任意 JSON-serializable 值;改 cc.Vec2 / cc.Vec3 / cc.Size 时需带 __type__
'use strict';
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
function execSetComponentField(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, componentType: rawComponentType, property, value } = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-component-field]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
if (
!(typeof property === 'string' && property.length > 0) &&
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string' && p.length > 0))
) {
throw new Error(`editPrefab [set-component-field]: property 必须是非空字符串或非空字符串数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [set-component-field]: value 不能是 undefined`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-field');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [set-component-field]: 节点 "${node._name}" 是 stub 代理,请用 set-nested-component-field`
);
}
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(
`editPrefab [set-component-field]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
);
}
// 单层 property
if (typeof property === 'string') {
comp[property] = value;
return nodeId;
}
// 嵌套 property 路径:逐层下钻,路径中断时报错(不自动建中间对象,避免悄悄改坏结构)
let cursor = comp;
for (let i = 0; i < property.length - 1; i++) {
const k = property[i];
if (cursor[k] === null || cursor[k] === undefined || typeof cursor[k] !== 'object') {
throw new Error(
`editPrefab [set-component-field]: 路径 ${property.slice(0, i + 1).join('.')} 不是对象(实际值 ${JSON.stringify(cursor[k])}),无法继续下钻`
);
}
cursor = cursor[k];
}
cursor[property[property.length - 1]] = value;
return nodeId;
}
module.exports = { execSetComponentField };
+138
View File
@@ -0,0 +1,138 @@
// set-component-ref: 把节点上指定组件的 @property 字段序列化指向另一节点/组件
// op: { op: 'set-component-ref', node, componentType, property, refNode, refType?, refSubNode? }
//
// - node: 持有目标组件的节点
// - componentType: 目标组件 ccclass 名
// - property: @property 字段名,支持以下格式:
// "_role" → 普通字段,propertyPath: ["_role"]
// "_items.0" → 数组字段第 0 项,propertyPath: ["_items", 0]
// "_items[0]" → 同上,[] 写法等价
// - refNode: 引用指向的节点(字符串名或 {id:N})
// - refType: 缺省或 'cc.Node' 表示字段指向节点本身;否则指向 refNode 上该类型的第一个组件
// - refSubNode: refNode 是 stub 时指定嵌套 prefab 内的子节点名(可选)
'use strict';
const { ref } = require('../../primitives.js');
const { normalizeComponentType, isStub, indexOfNode, resolveNode, findComponent } = require('../helpers.js');
const { resolveLocalIdChain, addRootTargetOverride } = require('../nested.js');
// ─── propertyPath 解析 ────────────────────────────────────────
//
// 把 property 字符串拆成 propertyPath 数组,传给 addRootTargetOverride
// 和 setByPropertyPath,统一用数字表示数组索引(Cocos 引擎序列化格式)。
//
// 例:
// "_role" → ["_role"]
// "_items.0" → ["_items", 0]
// "_items[2]" → ["_items", 2]
function parsePropertyPath(property) {
// [] 下标 → . 分隔:_items[0] → _items.0
const normalized = property.replace(/\[(\d+)\]/g, '.$1');
const parts = normalized.split('.').filter(p => p.length > 0);
return parts.map(p => {
const n = Number(p);
// 纯整数字符串(不含前导零的,如 "0" "1" "10")→ 数字索引
return Number.isInteger(n) && String(n) === p ? n : p;
});
}
// ─── 按 propertyPath 多层路径赋值 ─────────────────────────────
//
// 支持数组索引(数字 key)和对象属性(字符串 key)的任意组合。
// 中间层不存在时:下一段是数字 → 创建数组;否则 → 创建对象。
function setByPropertyPath(obj, pathParts, value) {
if (pathParts.length === 1) {
obj[pathParts[0]] = value;
return;
}
const head = pathParts[0];
const tail = pathParts.slice(1);
if (!obj[head] || typeof obj[head] !== 'object') {
obj[head] = typeof tail[0] === 'number' ? [] : {};
}
setByPropertyPath(obj[head], tail, value);
}
function execSetComponentRef(prefabData, op) {
const { elements, rootId } = prefabData;
const {
node: nodeSelector,
componentType: rawComponentType,
property,
refNode: refNodeSelector,
refType: rawRefType,
refSubNode = null,
} = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-component-ref]: componentType 必须是非空字符串`);
}
if (typeof property !== 'string' || property.length === 0) {
throw new Error(`editPrefab [set-component-ref]: property 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
const refType = rawRefType
? normalizeComponentType(rawRefType, prefabData.resolverStartPath)
: rawRefType;
// 解析 property 为 propertyPath 数组(支持 "_items.0" / "_items[0]" 数组写法)
const propertyPath = parsePropertyPath(property);
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-ref');
if (isStub(elements, node)) {
throw new Error(
`editPrefab [set-component-ref]: 源节点 "${node._name}" 是 stub(嵌套 prefab 代理),` +
`对 stub 自身组件挂 @property 字段的场景(需要 TargetOverrideInfo.sourceInfo)暂不支持`
);
}
const comp = findComponent(elements, node, componentType);
if (!comp) {
throw new Error(`editPrefab [set-component-ref]: 节点 "${node._name}" 未挂 "${componentType}" 组件`);
}
const { node: refNode, nodeId: refNodeId } = resolveNode(prefabData, refNodeSelector, 'set-component-ref');
// refNode 是 stub 代理 → 走 cc.TargetOverrideInfo 跨 nested 挂载
if (isStub(elements, refNode)) {
const targetCompType = !refType || refType === 'cc.Node' ? 'cc.Node' : refType;
const localIdChain = resolveLocalIdChain(
prefabData.resolverStartPath,
elements,
refNodeId,
targetCompType,
refSubNode
);
const compId = indexOfNode(elements, comp);
if (compId < 0) {
throw new Error(`editPrefab [set-component-ref]: 源组件索引失败(内部错误)`);
}
// 传入 propertyPath 数组(支持 ["_items", 0] 数组元素挂载)
addRootTargetOverride(prefabData, rootId, compId, propertyPath, refNodeId, localIdChain);
return nodeId;
}
// 普通节点:按 propertyPath 多层路径赋值(支持数组字段 _items[0]/_items[1]...
if (!refType || refType === 'cc.Node') {
setByPropertyPath(comp, propertyPath, ref(refNodeId));
} else {
const refComp = findComponent(elements, refNode, refType);
if (!refComp) {
throw new Error(`editPrefab [set-component-ref]: 引用节点 "${refNode._name}" 未挂 "${refType}" 组件`);
}
const refCompId = indexOfNode(elements, refComp);
if (refCompId < 0) {
throw new Error(`editPrefab [set-component-ref]: 引用组件索引失败(内部错误)`);
}
setByPropertyPath(comp, propertyPath, ref(refCompId));
}
return nodeId;
}
module.exports = { execSetComponentRef };
+56
View File
@@ -0,0 +1,56 @@
// set-editbox: 批量设置节点上 cc.EditBox 的常用字段
// op: {
// op: 'set-editbox',
// node,
// inputMode?: 0=ANY 1=EMAIL_ADDR 2=NUMERIC 3=PHONE_NUMBER 4=URL 5=DECIMAL 6=SINGLE_LINE
// maxLength?: number-1 无限制)
// placeholder?: string
// string?: string(当前文字值)
// inputFlag?: 0=DEFAULT 1=PASSWORD 2=SENSITIVE 3=INITIAL_CAPS_WORD 4=INITIAL_CAPS_SENTENCE 5=INITIAL_CAPS_ALL_CHARACTERS
// fontSize?: number
// }
//
// 至少提供一个可选字段,否则 op 无意义。
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
inputMode: '_inputMode',
maxLength: '_maxLength',
placeholder: '_placeholder',
string: '_string',
inputFlag: '_inputFlag',
fontSize: '_fontSize',
};
function execSetEditBox(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-editbox');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-editbox]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.EditBox');
if (!comp) {
throw new Error(`editPrefab [set-editbox]: 节点 "${node._name}" 上找不到 cc.EditBox 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(`editPrefab [set-editbox]: 至少需要提供一个字段(inputMode/maxLength/placeholder/string/inputFlag/fontSize`);
}
return nodeId;
}
module.exports = { execSetEditBox };
+39
View File
@@ -0,0 +1,39 @@
// set-label-text: 设置节点上 cc.Label 的 _string
// op: { op, node, text, labelNode? }
//
// 普通节点:直接修改 cc.Label._string
// stub 节点:从嵌套 prefab 中找 cc.Label 的 CompPrefabInfo.fileId
// 写入 PrefabInstance.propertyOverrides
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetLabelText(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, text, labelNode = null } = op;
if (typeof text !== 'string') {
throw new Error(`editPrefab [set-label-text]: text 必须是字符串`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-label-text');
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, id, 'cc.Label', labelNode
);
setStubCompOverride(prefabData, id, compFileId, ['_string'], text);
} else {
const comp = findComponent(elements, node, 'cc.Label');
if (!comp) {
throw new Error(`editPrefab [set-label-text]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Label 组件`);
}
comp._string = text;
}
return id;
}
module.exports = { execSetLabelText };
+64
View File
@@ -0,0 +1,64 @@
// set-label: 批量设置节点上 cc.Label 的常用字段
// op: {
// op: 'set-label',
// node,
// text?: string_string
// fontSize?: number
// lineHeight?: number0 = auto
// overflow?: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE
// horizontalAlign?: 0=LEFT 1=CENTER 2=RIGHT
// verticalAlign?: 0=TOP 1=CENTER 2=BOTTOM
// bold?: boolean
// italic?: boolean
// underline?: boolean
// enableWrapText?: boolean_enableWrapText
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
text: '_string',
fontSize: '_fontSize',
lineHeight: '_lineHeight',
overflow: '_overflow',
horizontalAlign: '_horizontalAlign',
verticalAlign: '_verticalAlign',
bold: '_isBold',
italic: '_isItalic',
underline: '_isUnderline',
enableWrapText: '_enableWrapText',
};
function execSetLabel(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-label');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-label]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Label');
if (!comp) {
throw new Error(`editPrefab [set-label]: 节点 "${node._name}" 上找不到 cc.Label 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-label]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetLabel };
+68
View File
@@ -0,0 +1,68 @@
// set-layout: 批量设置节点上 cc.Layout 的常用字段
// op: {
// op: 'set-layout',
// node,
// type?: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID
// resizeMode?: 0=NONE 1=CHILDREN 2=CONTAINER
// paddingLeft?: number
// paddingRight?: number
// paddingTop?: number
// paddingBottom?: number
// spacingX?: number
// spacingY?: number
// startAxis?: 0=HORIZONTAL 1=VERTICALGRID 模式)
// constraint?: 0=NONE 1=FIXED_ROW 2=FIXED_COLGRID 模式)
// constraintNum?: numberconstraint 对应的行数/列数)
// affectedByScale?: boolean
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
type: '_layoutType',
resizeMode: '_resizeMode',
paddingLeft: '_paddingLeft',
paddingRight: '_paddingRight',
paddingTop: '_paddingTop',
paddingBottom: '_paddingBottom',
spacingX: '_spacingX',
spacingY: '_spacingY',
startAxis: '_startAxis',
constraint: '_constraint',
constraintNum: '_constraintNum',
affectedByScale: '_affectedByScale',
};
function execSetLayout(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-layout');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-layout]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Layout');
if (!comp) {
throw new Error(`editPrefab [set-layout]: 节点 "${node._name}" 上找不到 cc.Layout 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-layout]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetLayout };
@@ -0,0 +1,52 @@
// set-nested-component-field: 改 stub 节点展开后内部某组件的字段
// op: { op, node, componentType, property, value, subNode? }
//
// - node: stub 节点名或 {id}(必须是 stub 代理)
// - componentType: 子 prefab 里目标组件类型(如 'cc.Label'
// - property: 字段名(如 '_string' / '_spriteFrame' / 'interactable');支持嵌套路径数组
// - value: 要写入的值(raw JSON
// - subNode: 子 prefab 内部节点名(可选,默认 null = 子 prefab root 上第一个匹配组件)
'use strict';
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetNestedComponentField(prefabData, op) {
const { elements } = prefabData;
const {
node: nodeSelector,
componentType: rawComponentType,
property,
value,
subNode = null,
} = op;
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
throw new Error(`editPrefab [set-nested-component-field]: componentType 必须是非空字符串`);
}
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
if (!property || (typeof property !== 'string' && !Array.isArray(property))) {
throw new Error(`editPrefab [set-nested-component-field]: property 必须是字符串或数组`);
}
if (value === undefined) {
throw new Error(`editPrefab [set-nested-component-field]: value 不能是 undefined`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-nested-component-field');
if (!isStub(elements, node)) {
throw new Error(
`editPrefab [set-nested-component-field]: 节点 "${node._name}" 不是 stub 代理——` +
`普通节点直接用 set-label-text/set-sprite-frame 或在代码里改组件字段`
);
}
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
);
const propertyPath = Array.isArray(property) ? property : [property];
setStubCompOverride(prefabData, nodeId, compFileId, propertyPath, value);
return nodeId;
}
module.exports = { execSetNestedComponentField };
+42
View File
@@ -0,0 +1,42 @@
// set-node-color: 设置节点的 _colorcc.Node 自身颜色,影响整棵子树透明度和染色)
// op: {
// op: 'set-node-color',
// node,
// r?: number (0-255)
// g?: number (0-255)
// b?: number (0-255)
// a?: number (0-255)
// }
//
// 示例:{ op:'set-node-color', node:'btnClose', a:0 } // 全透明
// { op:'set-node-color', node:'bg', r:255, g:200, b:100, a:255 }
'use strict';
const { resolveNode, isStub } = require('../helpers.js');
function execSetNodeColor(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, r, g, b, a } = op;
if (r === undefined && g === undefined && b === undefined && a === undefined) {
throw new Error(`editPrefab [set-node-color]: 至少提供一个分量(r/g/b/a)`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-node-color');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-node-color]: 节点是 stub,请用 set-nested-component-field 改节点颜色分量`);
}
if (!node._color || typeof node._color !== 'object') {
node._color = { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 };
}
if (r !== undefined) node._color.r = r;
if (g !== undefined) node._color.g = g;
if (b !== undefined) node._color.b = b;
if (a !== undefined) node._color.a = a;
return nodeId;
}
module.exports = { execSetNodeColor };
+29
View File
@@ -0,0 +1,29 @@
// set-position: 设置节点本地位置
// op: { op, node, x, y, z? }
'use strict';
const { setOverrideProperty } = require('../../overrides.js');
const { isStub, resolveNode } = require('../helpers.js');
function execSetPosition(prefabData, op) {
const { elements } = prefabData;
const { node: nodeId, x, y, z = 0 } = op;
if (typeof x !== 'number' || typeof y !== 'number') {
throw new Error(`editPrefab [set-position]: x/y 必须是数字`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeId, 'set-position');
const newLpos = { __type__: 'cc.Vec3', x, y, z };
if (isStub(elements, node)) {
setOverrideProperty(prefabData, id, ['_lpos'], newLpos);
} else {
node._lpos = newLpos;
}
return id;
}
module.exports = { execSetPosition };
+52
View File
@@ -0,0 +1,52 @@
// set-richtext: 批量设置节点上 cc.RichText 的常用字段
// op: {
// op: 'set-richtext',
// node,
// text?: string_string,支持 BBCode 标签)
// maxWidth?: number0 = 不限制)
// fontSize?: number
// lineHeight?: number
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
text: '_string',
maxWidth: '_maxWidth',
fontSize: '_fontSize',
lineHeight: '_lineHeight',
};
function execSetRichText(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-richtext');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-richtext]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.RichText');
if (!comp) {
throw new Error(`editPrefab [set-richtext]: 节点 "${node._name}" 上找不到 cc.RichText 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-richtext]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')}`
);
}
return nodeId;
}
module.exports = { execSetRichText };
+78
View File
@@ -0,0 +1,78 @@
// set-size: 改 cc.UITransform 内容尺寸
// op: { op:'set-size', node, width?, height? }
//
// width / height 任一缺省则保留原值
// stub 节点:走 PrefabInstance.propertyOverrides 写嵌套 UITransform._contentSize
// - 任一缺省时从嵌套 prefab 读默认值补齐
// - 不读 propertyOverrides 里的历史 override(少见且增加复杂度)
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
const { parsePrefab } = require('../../parse.js');
const { resolveUuidToPath } = require('../../uuid-resolver.js');
// 从嵌套 prefab 内读 root UITransform 默认 _contentSize(用作 stub set-size 的缺省补齐)
function _readNestedUITransformSize(hostPath, elements, stubNodeId) {
const stub = elements[stubNodeId];
const pi = elements[stub._prefab.__id__];
const nestedUuid = pi.asset.__uuid__;
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (el && el.__type__ === 'cc.UITransform') {
const s = el._contentSize || { width: 0, height: 0 };
return { width: s.width || 0, height: s.height || 0 };
}
}
return { width: 0, height: 0 };
}
function execSetSize(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, width, height } = op;
if (width === undefined && height === undefined) {
throw new Error(`editPrefab [set-size]: 至少提供 width 或 height 之一`);
}
if (width !== undefined && (typeof width !== 'number' || width < 0)) {
throw new Error(`editPrefab [set-size]: width 必须是非负数字`);
}
if (height !== undefined && (typeof height !== 'number' || height < 0)) {
throw new Error(`editPrefab [set-size]: height 必须是非负数字`);
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-size');
if (isStub(elements, node)) {
const oldSize = _readNestedUITransformSize(prefabData.resolverStartPath, elements, nodeId);
const newSize = {
__type__: 'cc.Size',
width: width === undefined ? oldSize.width : width,
height: height === undefined ? oldSize.height : height,
};
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
);
setStubCompOverride(prefabData, nodeId, compFileId, ['_contentSize'], newSize);
return nodeId;
}
const ut = findComponent(elements, node, 'cc.UITransform');
if (!ut) {
throw new Error(`editPrefab [set-size]: 节点 "${node._name}" 上没有 cc.UITransform`);
}
const oldSize = ut._contentSize || { width: 0, height: 0 };
ut._contentSize = {
__type__: 'cc.Size',
width: width === undefined ? oldSize.width : width,
height: height === undefined ? oldSize.height : height,
};
return nodeId;
}
module.exports = { execSetSize };
+36
View File
@@ -0,0 +1,36 @@
// set-sprite-frame: 设置节点上 cc.Sprite 的 _spriteFrame uuid
// op: { op, node, uuid, spriteNode? }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
function execSetSpriteFrame(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector, uuid, spriteNode = null } = op;
if (typeof uuid !== 'string') {
throw new Error(`editPrefab [set-sprite-frame]: uuid 必须是字符串`);
}
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-sprite-frame');
const newFrame = { __uuid__: uuid, __expectedType__: 'cc.SpriteFrame' };
if (isStub(elements, node)) {
const compFileId = getNestedCompFileId(
prefabData.resolverStartPath, elements, id, 'cc.Sprite', spriteNode
);
setStubCompOverride(prefabData, id, compFileId, ['_spriteFrame'], newFrame);
} else {
const comp = findComponent(elements, node, 'cc.Sprite');
if (!comp) {
throw new Error(`editPrefab [set-sprite-frame]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Sprite 组件`);
}
comp._spriteFrame = newFrame;
}
return id;
}
module.exports = { execSetSpriteFrame };
+52
View File
@@ -0,0 +1,52 @@
// set-sprite: 批量设置节点上 cc.Sprite 的常用字段(不含 spriteFrame,用 set-sprite-frame
// op: {
// op: 'set-sprite',
// node,
// sizeMode?: 0=CUSTOM 1=TRIMMED 2=RAW
// type?: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH
// grayscale?: boolean_useGrayscale
// trim?: boolean_isTrimmedMode
// }
'use strict';
const { isStub, resolveNode, findComponent } = require('../helpers.js');
const FIELD_MAP = {
sizeMode: '_sizeMode',
type: '_type',
grayscale: '_useGrayscale',
trim: '_isTrimmedMode',
};
function execSetSprite(prefabData, op) {
const { elements } = prefabData;
const { node: nodeSelector } = op;
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-sprite');
if (isStub(elements, node)) {
throw new Error(`editPrefab [set-sprite]: 节点是 stub,请用 set-nested-component-field`);
}
const comp = findComponent(elements, node, 'cc.Sprite');
if (!comp) {
throw new Error(`editPrefab [set-sprite]: 节点 "${node._name}" 上找不到 cc.Sprite 组件`);
}
let applied = 0;
for (const [key, field] of Object.entries(FIELD_MAP)) {
if (key in op) {
comp[field] = op[key];
applied++;
}
}
if (applied === 0) {
throw new Error(
`editPrefab [set-sprite]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})。更换图片用 set-sprite-frame`
);
}
return nodeId;
}
module.exports = { execSetSprite };
+20
View File
@@ -0,0 +1,20 @@
// sync-nested-roots: 重建根 PrefabInfo.nestedPrefabInstanceRoots,剔除「删了一半」
// 残留的悬空嵌套实例根——节点的父引用已被移除(_parent=null)但根 PrefabInfo 里
// 对它的登记还在,导致残留嵌套 prefab 的 asset 仍被当依赖加载(运行时 404 / 加载失败)。
//
// 只重写 nestedPrefabInstanceRoots 数组(依据当前「有父 + 有 PrefabInfo + instance」的
// 实际 stub 节点),不删 elements、不动其他 __id__、不产生 null 槽;被孤立的残留对象
// 成为不可达 orphan(软删策略,无害)。
//
// op: { op: 'sync-nested-roots' } 无参数,作用于 prefab 根。
'use strict';
const { syncNestedRoots } = require('../id-utils.js');
function execSyncNestedRoots(prefabData) {
const { elements, rootId } = prefabData;
syncNestedRoots(elements, rootId);
return rootId;
}
module.exports = { execSyncNestedRoots };
+97
View File
@@ -0,0 +1,97 @@
// ============================================================
// 确定性 UUID / fileId 生成器(纯 CJS,零三方依赖)
// 与 tools/fgui2cc3/src/utils/DeterministicId.ts byte-for-byte 对齐
// ============================================================
'use strict';
const { createHash } = require('node:crypto');
/**
* 基于种子字符串生成确定性 UUID v4 格式
* 使用 SHA-256 哈希前 16 字节设置 version=4 variant=RFC4122
* @param {string} seed
* @returns {string}
*/
function deterministicUUID(seed) {
const hash = createHash('sha256').update(seed).digest();
// 设置 version (4) 和 variant (RFC 4122)
hash[6] = (hash[6] & 0x0f) | 0x40;
hash[8] = (hash[8] & 0x3f) | 0x80;
const hex = hash.subarray(0, 16).toString('hex');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
/**
* 基于种子字符串生成确定性 fileIdCC3 使用 base64 编码的 16 字节
* @param {string} seed
* @returns {string}
*/
function deterministicFileId(seed) {
const hash = createHash('sha256').update(seed).digest();
return hash.subarray(0, 16).toString('base64').replace(/=+$/, '');
}
/**
* 创建一个带自增计数器的 fileId 生成器
* 同一组件内每个节点/组件使用递增的序号保证唯一性和稳定性
* @param {string} baseSeed
* @returns {() => string}
*/
function createFileIdGenerator(baseSeed) {
let counter = 0;
return () => deterministicFileId(`${baseSeed}#fid#${counter++}`);
}
// ============================================================
// Cocos Creator 压缩 classId 编解码
// 标准 base64A-Z a-z 0-9 + /),前 5 hex 保留,每 3 hex → 2 base64
// 产物格式:23 字符 = 5 hex + 18 base64
// ============================================================
const _BASE64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/**
* 32/36 uuid 压缩为 Cocos ccclass id23 字符
*
* @param {string} uuid 标准 uuid含或不含 dash
* @returns {string} 23 字符压缩 classId
*/
function compressUuid(uuid) {
if (typeof uuid !== 'string') {
throw new Error(`compressUuid: uuid 必须是字符串,收到 ${typeof uuid}`);
}
const hex = uuid.replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(hex)) {
throw new Error(`compressUuid: 输入不是合法 uuid${uuid}`);
}
let out = hex.slice(0, 5);
for (let i = 5; i < 32; i += 3) {
const code = parseInt(hex.substr(i, 3), 16);
out += _BASE64_STD[(code >> 6) & 0x3f];
out += _BASE64_STD[code & 0x3f];
}
return out;
}
/**
* 判断字符串是否是 Cocos 压缩 classId 格式23 字符 5 hex + 18 base64
* 不做语义合法性校验不查 classId 是否对应真实类
*/
function isCompressedClassId(str) {
return typeof str === 'string' && /^[0-9a-f]{5}[A-Za-z0-9+/]{18}$/.test(str);
}
module.exports = {
deterministicUUID,
deterministicFileId,
createFileIdGenerator,
compressUuid,
isCompressedClassId,
};
+27
View File
@@ -0,0 +1,27 @@
'use strict';
// 统一入口:re-export 所有公开 API
const { parsePrefab } = require('./parse.js');
const { writePrefab } = require('./write.js');
const { editPrefab } = require('./editor/index.js');
const { queryPrefab } = require('./query/index.js');
const { setOverrideProperty, listOverrides } = require('./overrides.js');
const { deterministicUUID, deterministicFileId, createFileIdGenerator } = require('./id.js');
const primitives = require('./primitives.js');
const animPrimitives = require('./anim-primitives.js');
module.exports = {
parsePrefab,
writePrefab,
editPrefab,
queryPrefab,
setOverrideProperty,
listOverrides,
deterministicUUID,
deterministicFileId,
createFileIdGenerator,
...primitives,
// .anim 文件对象构建原语(AnimationClip / Track / Curve / Channel),
// parse/write 复用 parsePrefab / writePrefab(同为 JSON 数组 + __id__ 引用格式)
anim: animPrimitives,
};
+311
View File
@@ -0,0 +1,311 @@
// ============================================================
// CC3 Prefab PrefabInstance Override 读写(纯 CJS,零三方依赖)
//
// 三个已知地雷:
// 地雷 1stub 节点(嵌套 prefab 根节点)本身的字段写入无效。
// 必须走 PrefabInstance.propertyOverrides,以 CCPropertyOverrideInfo
// + TargetInfo 结构写入,才能被 Cocos 编辑器识别。
// 地雷 2:新增嵌套 stub 节点后,宿主 prefab 根节点的 cc.PrefabInfo
// 的 nestedPrefabInstanceRoots 必须同步追加该 stub 节点的 __id__
// 否则 Cocos 加载时会忽略该嵌套实例的 override。
// 地雷 3propertyOverride.targetInfo.localID 必须是
// 「嵌套 prefab 内根节点的 cc.PrefabInfo.fileId」,
// 不是「外层 stub 的 cc.PrefabInfo.fileId」。
// Cocos 运行时按嵌套 prefab 内部 fileId 建 targetMap
// 外层 stub fileId 在 targetMap 里查不到。
// 早期 fgui→cc3 转出的 prefab 设计上让这两个 fileId 一致,
// 所以本工具早期版本用 stubFileId 巧合工作;新写或手编的
// 嵌套 prefab 一般两个 fileId 不同,必须读嵌套 prefab 拿真值。
//
// 本工具当前行为(2026-05-20 修后):
// - 写入:强制读嵌套 prefab 拿真值,解析失败抛错。
// - 自动矫正:识别旧 cli 写入的 stubFileId 条目,命中同
// propertyPath 时把 localID 改写为真值(一次性迁移)。
// 迁移完成后旧条目不复存在,listOverrides / reset-overrides
// 只识别真值,不再兼容历史格式。
// ============================================================
'use strict';
const { parsePrefab } = require('./parse.js');
const { resolveUuidToPath } = require('./uuid-resolver.js');
/**
* 查找 stub 节点对应的 PrefabInstance 对象
*
* stub 节点__type__ = cc.Node_prefab 指向一个 cc.PrefabInfo
* PrefabInfo.instance 指向 cc.PrefabInstance
*
* @param {object[]} elements prefab 数组
* @param {number} stubId stub 节点的 __id__数组下标
* @returns {{ prefabInstance: object, prefabInstanceId: number, prefabInfo: object, prefabInfoId: number } | null}
*/
function _findStubPrefabInstance(elements, stubId) {
const stub = elements[stubId];
if (!stub || stub.__type__ !== 'cc.Node') return null;
const prefabRef = stub._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return null;
const prefabInfoId = prefabRef.__id__;
const prefabInfo = elements[prefabInfoId];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return null;
const instanceRef = prefabInfo.instance;
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return null;
const prefabInstanceId = instanceRef.__id__;
const prefabInstance = elements[prefabInstanceId];
if (!prefabInstance || prefabInstance.__type__ !== 'cc.PrefabInstance') return null;
return { prefabInstance, prefabInstanceId, prefabInfo, prefabInfoId };
}
/**
* 加载嵌套 prefab 拿其根节点的 cc.PrefabInfo.fileId
* 这是 propertyOverride.targetInfo.localID 正确值见地雷 3
*
* @param {object} prefabData parsePrefab 返回值外层 prefab需含 resolverStartPath
* @param {object} prefabInfo stub 节点的 cc.PrefabInfo包含 asset.__uuid__
* @returns {string} 嵌套 prefab 根节点 PrefabInfo.fileId
*
* @throws 嵌套 prefab UUID 缺失 / 解析失败 / 找不到根节点 fileId 时抛错
*/
function _getStubInnerRootFileId(prefabData, prefabInfo) {
if (!prefabData || !prefabData.resolverStartPath) {
throw new Error(`setOverrideProperty: prefabData 缺 resolverStartPath,无法解析嵌套 prefab`);
}
const assetRef = prefabInfo && prefabInfo.asset;
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
throw new Error(`setOverrideProperty: stub PrefabInfo.asset 不是 UUID 引用,无法定位嵌套 prefab`);
}
const nestedPath = resolveUuidToPath(assetRef.__uuid__, prefabData.resolverStartPath);
if (typeof nestedPath !== 'string' || nestedPath.length === 0) {
throw new Error(`setOverrideProperty: UUID "${assetRef.__uuid__}" 找不到对应 prefab 路径`);
}
const nestedData = parsePrefab(nestedPath);
const nEls = nestedData.elements;
for (const el of nEls) {
if (!el || el.__type__ !== 'cc.Node') continue;
// 嵌套 prefab 内根节点 _parent === null
if (el._parent !== null && el._parent !== undefined) continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = nEls[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (typeof pi.fileId === 'string' && pi.fileId.length > 0) {
return pi.fileId;
}
}
throw new Error(`setOverrideProperty: 嵌套 prefab "${nestedPath}" 找不到根节点 PrefabInfo.fileId`);
}
/**
* PrefabInstance 中已有的 CCPropertyOverrideInfo
* 条件targetInfo localID[0] === targetLocalIdpropertyPath[0] === propertyPath
*
* @param {object[]} elements
* @param {object} prefabInstance
* @param {string} targetLocalId 目标 fileId嵌套 prefab 内根节点 fileId
* @param {string[]} propertyPath
* @param {string=} legacyLocalId 兼容旧 prefab 写入的 stubFileId一起匹配
* @returns {{ info: object, infoId: number } | null}
*/
function _findExistingOverride(elements, prefabInstance, targetLocalId, propertyPath, legacyLocalId) {
if (!Array.isArray(prefabInstance.propertyOverrides)) return null;
for (const overrideRef of prefabInstance.propertyOverrides) {
if (typeof overrideRef.__id__ !== 'number') continue;
const info = elements[overrideRef.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
// 匹配 targetInfo.localID[0]targetLocalId 优先;legacyLocalId 用于兼容旧版 cli 写入的 stubFileId
const targetInfoRef = info.targetInfo;
if (!targetInfoRef || typeof targetInfoRef.__id__ !== 'number') continue;
const targetInfo = elements[targetInfoRef.__id__];
if (!targetInfo || targetInfo.__type__ !== 'cc.TargetInfo') continue;
if (!Array.isArray(targetInfo.localID)) continue;
const lid = targetInfo.localID[0];
if (lid !== targetLocalId && lid !== legacyLocalId) continue;
// 匹配 propertyPath
if (!Array.isArray(info.propertyPath)) continue;
if (info.propertyPath.length !== propertyPath.length) continue;
if (info.propertyPath.every((p, i) => p === propertyPath[i])) {
return { info, infoId: overrideRef.__id__, targetInfo };
}
}
return null;
}
/**
* 设置 stub 节点嵌套 prefab 根节点的属性 override
*
* 地雷 1直接写 stub 节点字段无效必须通过 PrefabInstance.propertyOverrides
*
* @param {object} prefabData parsePrefab 的返回值
* @param {number} stubNodeId stub 节点的数组下标__id__
* @param {string[]} propertyPath 属性路径 ['_lpos'] ['_name']
* @param {*} value 要写入的值
* @returns {void}
*
* @throws 如果 stubNodeId 不是有效 stub 节点
*/
function setOverrideProperty(prefabData, stubNodeId, propertyPath, value) {
const { elements } = prefabData;
const stubResult = _findStubPrefabInstance(elements, stubNodeId);
if (!stubResult) {
throw new Error(
`setOverrideProperty: index ${stubNodeId} 不是有效的 stub 节点(需要 _prefab → PrefabInfo.instance → PrefabInstance`
);
}
const { prefabInstance, prefabInfo } = stubResult;
const stubFileId = prefabInfo.fileId;
// 嵌套 prefab 内根节点 fileId 是 Cocos 运行时 targetMap 的正确 key(见地雷 3)。
// 解析失败抛错(不再 fallback),调用方需保证嵌套 prefab 可用。
const targetLocalId = _getStubInnerRootFileId(prefabData, prefabInfo);
// 查找是否已有对应 override
// 自动矫正:当 stubFileId !== targetLocalId 时,识别旧版 cli 写入的 stubFileId 条目,
// 命中后把 localID 改写为真值(一次性迁移历史脏数据)。
const legacyLocalId = stubFileId !== targetLocalId ? stubFileId : null;
const existing = _findExistingOverride(elements, prefabInstance, targetLocalId, propertyPath, legacyLocalId);
if (existing) {
existing.info.value = value;
if (legacyLocalId && existing.targetInfo && Array.isArray(existing.targetInfo.localID)) {
if (existing.targetInfo.localID[0] === legacyLocalId) {
existing.targetInfo.localID[0] = targetLocalId;
}
}
return;
}
// 新建 TargetInfo
const targetInfo = {
__type__: 'cc.TargetInfo',
localID: [targetLocalId],
};
const targetInfoId = elements.length;
elements.push(targetInfo);
// 新建 CCPropertyOverrideInfo
const overrideInfo = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: targetInfoId },
propertyPath: [...propertyPath],
value,
};
const overrideInfoId = elements.length;
elements.push(overrideInfo);
// 追加到 PrefabInstance.propertyOverrides
if (!Array.isArray(prefabInstance.propertyOverrides)) {
prefabInstance.propertyOverrides = [];
}
prefabInstance.propertyOverrides.push({ __id__: overrideInfoId });
}
/**
* 列出 stub 节点的所有 propertyOverrides
*
* @param {object} prefabData parsePrefab 的返回值
* @param {number} stubNodeId stub 节点的数组下标
* @returns {Array<{ propertyPath: string[], value: *, targetFileId: string }>}
*/
function listOverrides(prefabData, stubNodeId) {
const { elements } = prefabData;
const stubResult = _findStubPrefabInstance(elements, stubNodeId);
if (!stubResult) {
throw new Error(
`listOverrides: index ${stubNodeId} 不是有效的 stub 节点`
);
}
const { prefabInstance, prefabInfo } = stubResult;
// stub-node-field override 的 localID 是嵌套 prefab 内根节点 fileId(见地雷 3)。
const targetLocalId = _getStubInnerRootFileId(prefabData, prefabInfo);
const result = [];
if (!Array.isArray(prefabInstance.propertyOverrides)) return result;
for (const overrideRef of prefabInstance.propertyOverrides) {
if (typeof overrideRef.__id__ !== 'number') continue;
const info = elements[overrideRef.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
const targetInfoRef = info.targetInfo;
if (!targetInfoRef || typeof targetInfoRef.__id__ !== 'number') continue;
const targetInfo = elements[targetInfoRef.__id__];
if (!targetInfo || targetInfo.__type__ !== 'cc.TargetInfo') continue;
if (!Array.isArray(targetInfo.localID) || targetInfo.localID[0] !== targetLocalId) continue;
result.push({
propertyPath: [...(info.propertyPath || [])],
value: info.value,
targetFileId: targetInfo.localID[0],
});
}
return result;
}
/**
* 同步 nestedPrefabInstanceRoots地雷 2
*
* 宿主 prefab 的根节点 PrefabInfo.nestedPrefabInstanceRoots 必须包含所有嵌套 stub 节点
* 调用此函数后会从 elements 中自动扫描所有 cc.PrefabInstance
* 找到对应的 stub 节点 __id__并更新根节点 PrefabInfo nestedPrefabInstanceRoots
*
* 通常在新增 stub 节点后调用一次即可
*
* @param {object} prefabData parsePrefab 的返回值
*/
function syncNestedPrefabInstanceRoots(prefabData) {
const { elements, rootId } = prefabData;
// 找根节点 PrefabInforoot 指向自己的那个)
const rootNode = elements[rootId];
if (!rootNode) throw new Error('syncNestedPrefabInstanceRoots: 根节点不存在');
const rootPrefabRef = rootNode._prefab;
if (!rootPrefabRef || typeof rootPrefabRef.__id__ !== 'number') {
throw new Error('syncNestedPrefabInstanceRoots: 根节点没有 _prefab 引用');
}
const rootPrefabInfo = elements[rootPrefabRef.__id__];
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') {
throw new Error('syncNestedPrefabInstanceRoots: 根节点 _prefab 不是 cc.PrefabInfo');
}
// 收集所有 stub 节点 __id__(有 PrefabInstance 的 cc.Node
const stubIds = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
const pi = elements[el._prefab.__id__];
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
if (!pi.instance || typeof pi.instance.__id__ !== 'number') continue;
const inst = elements[pi.instance.__id__];
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
// 这是一个 stub 节点
stubIds.push(i);
}
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.length > 0
? stubIds.map((id) => ({ __id__: id }))
: null;
}
module.exports = {
setOverrideProperty,
listOverrides,
syncNestedPrefabInstanceRoots,
};
+140
View File
@@ -0,0 +1,140 @@
// ============================================================
// CC3 Prefab 解析器(纯 CJS,零三方依赖)
// 读取 prefab JSON → 构建 __id__ 索引 → 暴露查询接口
// ============================================================
'use strict';
const fs = require('fs');
/**
* 解析 prefab 文件
*
* 返回对象结构
* {
* raw: string, // 原始文件内容(供 write.js 保留格式用)
* elements: object[], // 顶层数组(原始引用,可直接修改)
* rootId: number, // cc.Prefab data 指向的根节点 __id__
* findNodeByName(name), // 递归按 _name 查首个匹配节点(返回 element)
* findNodesByType(type), // 按 __type__ 查所有匹配 element
* getRoot(), // 返回根 cc.Node element
* resolveRef(refObj), // { __id__: N } → element
* }
*
* @param {string} filePath
* @returns {PrefabData}
*/
function parsePrefab(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
let elements;
try {
elements = JSON.parse(raw);
} catch (e) {
throw new Error(`parsePrefab: JSON 解析失败(${filePath}: ${e.message}`);
}
if (!Array.isArray(elements)) {
throw new Error(`parsePrefab: 顶层不是数组(${filePath}`);
}
// 构建 __id__ → element 的 O(1) 索引
// CC3 prefab 数组下标即 __id__,无需额外映射,但封装成函数方便维护
// 同时校验数组长度
const idIndex = elements; // 直接按下标访问即可
// 找 cc.Prefab 资产头(通常在 index 0),取 rootId
let rootId = -1;
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el && typeof el === 'object' && el.__type__ === 'cc.Prefab') {
if (el.data && typeof el.data.__id__ === 'number') {
rootId = el.data.__id__;
}
break;
}
}
if (rootId < 0) {
throw new Error(`parsePrefab: 未找到 cc.Prefab 头或 data.__id__${filePath}`);
}
// ─── 辅助:按 __id__ 解引用 ───────────────────────────────
function resolveRef(refObj) {
if (!refObj || typeof refObj.__id__ !== 'number') {
throw new Error(`resolveRef: 参数不是有效引用对象: ${JSON.stringify(refObj)}`);
}
const el = idIndex[refObj.__id__];
if (el === undefined) {
throw new Error(`resolveRef: __id__ ${refObj.__id__} 超出数组范围`);
}
return el;
}
// ─── 辅助:收集节点的所有子节点(递归) ─────────────────
function _collectChildren(node, visited) {
if (!node || !Array.isArray(node._children)) return [];
const result = [];
for (const childRef of node._children) {
if (typeof childRef.__id__ !== 'number') continue;
const id = childRef.__id__;
if (visited.has(id)) continue; // 防环
visited.add(id);
const child = idIndex[id];
if (child) {
result.push(child);
result.push(..._collectChildren(child, visited));
}
}
return result;
}
// ─── getRoot ─────────────────────────────────────────────
function getRoot() {
return idIndex[rootId];
}
// ─── findNodeByName ───────────────────────────────────────
// 从根节点递归 DFS,返回第一个 _name 匹配的 cc.Node
function findNodeByName(name) {
const root = getRoot();
if (!root) return null;
return _findByName(root, name, new Set([rootId]));
}
function _findByName(node, name, visited) {
if (node._name === name) return node;
if (!Array.isArray(node._children)) return null;
for (const childRef of node._children) {
if (typeof childRef.__id__ !== 'number') continue;
const id = childRef.__id__;
if (visited.has(id)) continue;
visited.add(id);
const child = idIndex[id];
if (!child) continue;
const found = _findByName(child, name, visited);
if (found) return found;
}
return null;
}
// ─── findNodesByType ──────────────────────────────────────
// 遍历整个数组,按 __type__ 过滤(不限于节点树)
function findNodesByType(type) {
return elements.filter(
(el) => el && typeof el === 'object' && el.__type__ === type
);
}
return {
raw,
elements,
rootId,
findNodeByName,
findNodesByType,
getRoot,
resolveRef,
};
}
module.exports = { parsePrefab };
+476
View File
@@ -0,0 +1,476 @@
// ============================================================
// CC3 Prefab 对象构建原语(纯 CJS,零三方依赖)
// 输入:朴素参数(name/pos/size 等)
// 输出:可直接插入 prefab 数组的裸对象
// ============================================================
'use strict';
// ─────────────────────────────────────────────
// 基础数据类型工厂
// ─────────────────────────────────────────────
/** @param {number} x @param {number} y @param {number} [z] */
function vec3(x, y, z = 0) {
return { __type__: 'cc.Vec3', x, y, z };
}
/** @param {number} w @param {number} h */
function ccSize(w, h) {
return { __type__: 'cc.Size', width: w, height: h };
}
/** @param {number} x @param {number} y */
function vec2(x, y) {
return { __type__: 'cc.Vec2', x, y };
}
/** @param {number} r @param {number} g @param {number} b @param {number} [a] */
function ccColor(r, g, b, a = 255) {
return { __type__: 'cc.Color', r, g, b, a };
}
/** @param {number} id */
function ref(id) {
return { __id__: id };
}
// ─────────────────────────────────────────────
// cc.Node
// ─────────────────────────────────────────────
/**
* 构造 cc.Node 裸对象
* @param {object} opts
* @param {string} opts.name - 节点名称
* @param {number[]} [opts.pos] - 本地位置 [x, y, z]默认 [0,0,0]
* @param {number[]} [opts.scale] - 本地缩放 [x, y, z]默认 [1,1,1]
* @param {boolean} [opts.active] - 是否激活默认 true
* @param {number} [opts.layer] - 渲染层默认 33554432UI
* @param {number|null} [opts.parentId] - 父节点 __id__null 表示根节点
* @param {number[]} [opts.childIds] - 子节点 __id__ 数组
* @param {number[]} [opts.componentIds] - 组件 __id__ 数组
* @param {number|null} [opts.prefabId] - cc.PrefabInfo __id__
* @returns {object}
*/
function makeNode(opts) {
const {
name,
pos = [0, 0, 0],
scale = [1, 1, 1],
active = true,
layer = 33554432,
parentId = null,
childIds = [],
componentIds = [],
prefabId = null,
} = opts;
return {
__type__: 'cc.Node',
_name: name,
_objFlags: 0,
__editorExtras__: {},
_parent: parentId !== null ? ref(parentId) : null,
_children: childIds.map(ref),
_active: active,
_components: componentIds.map(ref),
_prefab: prefabId !== null ? ref(prefabId) : null,
_lpos: vec3(pos[0] ?? 0, pos[1] ?? 0, pos[2] ?? 0),
_lrot: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 },
_lscale: vec3(scale[0] ?? 1, scale[1] ?? 1, scale[2] ?? 1),
_mobility: 0,
_layer: layer,
_euler: vec3(0, 0, 0),
_id: '',
};
}
// ─────────────────────────────────────────────
// cc.UITransform
// ─────────────────────────────────────────────
/**
* 构造 cc.UITransform 裸对象
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {number} opts.width - 宽度
* @param {number} opts.height - 高度
* @param {number[]} [opts.anchor] - 锚点 [x, y]默认 [0.5, 0.5]
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo __id__
* @returns {object}
*/
function makeUITransform(opts) {
const {
nodeId,
width,
height,
anchor = [0.5, 0.5],
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.UITransform',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_contentSize: ccSize(width, height),
_anchorPoint: vec2(anchor[0] ?? 0.5, anchor[1] ?? 0.5),
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.Sprite
// ─────────────────────────────────────────────
/**
* 构造 cc.Sprite 裸对象
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {string} [opts.spriteFrameUuid] - 图片 UUID格式 "uuid@f9941"null 表示无图
* @param {number} [opts.type] - 0=SIMPLE(默认)/1=SLICED/2=TILED/3=FILLED
* @param {number[]} [opts.color] - [r,g,b,a]默认白色不透明
* @param {boolean} [opts.isTrimmedMode] - 默认 true
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo __id__
* @returns {object}
*/
function makeSprite(opts) {
const {
nodeId,
spriteFrameUuid = null,
type = 0,
color = [255, 255, 255, 255],
isTrimmedMode = true,
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.Sprite',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: ccColor(color[0] ?? 255, color[1] ?? 255, color[2] ?? 255, color[3] ?? 255),
_spriteFrame: spriteFrameUuid
? { __uuid__: spriteFrameUuid, __expectedType__: 'cc.SpriteFrame' }
: null,
_type: type,
_fillType: 0,
_sizeMode: 0,
_fillCenter: vec2(0, 0),
_fillStart: 0,
_fillRange: 0,
_isTrimmedMode: isTrimmedMode,
_useGrayscale: false,
_atlas: null,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.Label
// ─────────────────────────────────────────────
/**
* 构造 cc.Label 裸对象
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {string} [opts.string] - 文字内容默认空字符串
* @param {number} [opts.fontSize] - 字号默认 20
* @param {number} [opts.lineHeight] - 行高0 表示自动
* @param {number} [opts.horizontalAlign] - 0=LEFT/1=CENTER/2=RIGHT默认 1(CENTER)
* @param {number} [opts.verticalAlign] - 0=TOP/1=CENTER/2=BOTTOM默认 1(CENTER)
* @param {number} [opts.overflow] - 0=NONE/1=CLAMP/2=SHRINK/3=RESIZE_HEIGHT默认 0
* @param {string|null} [opts.fontUuid] - 字体资产 UUIDnull 使用系统字体
* @param {number[]} [opts.color] - [r,g,b,a]默认黑色不透明
* @param {boolean} [opts.enableOutline] - 是否启用描边默认 false
* @param {number[]} [opts.outlineColor] - 描边颜色 [r,g,b,a]
* @param {number} [opts.outlineWidth] - 描边宽度默认 2
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo __id__
* @returns {object}
*/
function makeLabel(opts) {
const {
nodeId,
string = '',
fontSize = 20,
lineHeight = 0,
horizontalAlign = 1,
verticalAlign = 1,
overflow = 0,
fontUuid = null,
color = [0, 0, 0, 255],
enableOutline = false,
outlineColor = [0, 0, 0, 255],
outlineWidth = 2,
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.Label',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: ccColor(color[0] ?? 0, color[1] ?? 0, color[2] ?? 0, color[3] ?? 255),
_string: string,
_horizontalAlign: horizontalAlign,
_verticalAlign: verticalAlign,
_actualFontSize: fontSize,
_fontSize: fontSize,
_fontFamily: 'Arial',
_lineHeight: lineHeight,
_overflow: overflow,
_enableWrapText: true,
_font: fontUuid ? { __uuid__: fontUuid, __expectedType__: 'cc.Font' } : null,
_isSystemFontUsed: fontUuid === null,
_isItalic: false,
_isBold: false,
_isUnderline: false,
_cacheMode: 0,
_enableOutline: enableOutline,
_outlineColor: ccColor(
outlineColor[0] ?? 0,
outlineColor[1] ?? 0,
outlineColor[2] ?? 0,
outlineColor[3] ?? 255
),
_outlineWidth: outlineWidth,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.Widget
// ─────────────────────────────────────────────
/**
* 构造 cc.Widget 裸对象
*
* alignFlags 位掩码可组合
* LEFT=1, RIGHT=2, TOP=4, BOTTOM=8, HORIZONTAL_CENTER=16, VERTICAL_CENTER=32
*
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {number} [opts.alignFlags] - 对齐标志位掩码默认 0无对齐
* @param {number} [opts.left] - 左边距
* @param {number} [opts.right] - 右边距
* @param {number} [opts.top] - 上边距
* @param {number} [opts.bottom] - 下边距
* @param {boolean} [opts.isAbsLeft] - left 是否为绝对像素默认 true
* @param {boolean} [opts.isAbsRight] - right 是否为绝对像素默认 true
* @param {boolean} [opts.isAbsTop] - top 是否为绝对像素默认 true
* @param {boolean} [opts.isAbsBottom] - bottom 是否为绝对像素默认 true
* @param {boolean} [opts.isAbsHorizontalCenter] - horizontalCenter 是否为绝对像素默认 true
* @param {boolean} [opts.isAbsVerticalCenter] - verticalCenter 是否为绝对像素默认 true
* @param {number} [opts.horizontalCenter] - 水平居中偏移
* @param {number} [opts.verticalCenter] - 垂直居中偏移
* @param {number} [opts.alignMode] - 0=ONCE/1=ON_WINDOW_RESIZE/2=ALWAYS默认 1
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo __id__
* @returns {object}
*/
function makeWidget(opts) {
const {
nodeId,
alignFlags = 0,
left = 0,
right = 0,
top = 0,
bottom = 0,
isAbsLeft = true,
isAbsRight = true,
isAbsTop = true,
isAbsBottom = true,
isAbsHorizontalCenter = true,
isAbsVerticalCenter = true,
horizontalCenter = 0,
verticalCenter = 0,
alignMode = 1,
prefabInfoId = null,
} = opts;
const obj = {
__type__: 'cc.Widget',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_alignFlags: alignFlags,
_target: null,
_left: left,
_right: right,
_top: top,
_bottom: bottom,
_horizontalCenter: horizontalCenter,
_verticalCenter: verticalCenter,
_isAbsLeft: isAbsLeft,
_isAbsRight: isAbsRight,
_isAbsTop: isAbsTop,
_isAbsBottom: isAbsBottom,
_isAbsHorizontalCenter: isAbsHorizontalCenter,
_isAbsVerticalCenter: isAbsVerticalCenter,
_originalWidth: 0,
_originalHeight: 0,
_alignMode: alignMode,
_lockFlags: 0,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// sp.Skeleton
// ─────────────────────────────────────────────
/**
* 构造 sp.Skeleton 裸对象
*
* spine prefab 运行时通过 loadAsset<Prefab> + instantiate +
* getComponent(sp.Skeleton) 获取并播放动画
*
* 字段默认值与 Cocos 编辑器从右键菜单挂 sp.Skeleton 的产物保持一致
* 缓存策略 PRIVATE_CACHE(1)blend func 2/4_useTint/_premultipliedAlpha
* 等均为 false / 默认
*
* @param {object} opts
* @param {number} opts.nodeId - 所属节点 __id__
* @param {string} opts.skeletonUuid - .skel 资产 UUIDcc.AssetManager 注册的资源 ID
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo __id__
* @returns {object}
*/
function makeSpSkeleton(opts) {
const { nodeId, skeletonUuid, prefabInfoId = null } = opts;
const obj = {
__type__: 'sp.Skeleton',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: ref(nodeId),
_enabled: true,
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: ccColor(255, 255, 255, 255),
_skeletonData: { __uuid__: skeletonUuid, __expectedType__: 'sp.SkeletonData' },
defaultSkin: '',
defaultAnimation: '',
_premultipliedAlpha: false,
_timeScale: 1,
_preCacheMode: 1,
_cacheMode: 1,
_defaultCacheMode: 1,
_sockets: [],
_useTint: false,
_debugMesh: false,
_debugBones: false,
_debugSlots: false,
_enableBatch: false,
loop: false,
_id: '',
};
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
return obj;
}
// ─────────────────────────────────────────────
// cc.PrefabInfo / cc.CompPrefabInfo
// ─────────────────────────────────────────────
/**
* 构造节点的 cc.PrefabInfo 裸对象非嵌套普通节点用
* @param {object} opts
* @param {number} opts.rootId - 根节点 __id__通常为 1
* @param {string} opts.fileId - 该节点在 prefab 内的唯一 IDbase64 22-24字符
* @param {number} [opts.assetId] - cc.Prefab 资产 __id__默认 0
* @param {number[]|null} [opts.nestedPrefabInstanceRoots] - 嵌套 stub 节点索引根节点 PrefabInfo
* @returns {object}
*/
function makePrefabInfo(opts) {
const {
rootId,
fileId,
assetId = 0,
nestedPrefabInstanceRoots = null,
} = opts;
return {
__type__: 'cc.PrefabInfo',
root: ref(rootId),
asset: ref(assetId),
fileId,
instance: null,
targetOverrides: null,
nestedPrefabInstanceRoots: nestedPrefabInstanceRoots
? nestedPrefabInstanceRoots.map(ref)
: null,
};
}
/**
* 构造组件的 cc.CompPrefabInfo 裸对象
* @param {string} fileId - 该组件在 prefab 内的唯一 ID
* @returns {object}
*/
function makeCompPrefabInfo(fileId) {
return { __type__: 'cc.CompPrefabInfo', fileId };
}
/**
* 构造 cc.Prefab 文件头对象下标 0
* @param {object} opts
* @param {string} opts.name - prefab 名称
* @param {number} opts.rootId - 根节点 __id__通常为 1
* @returns {object}
*/
function makePrefabRoot(opts) {
const { name, rootId = 1 } = opts;
return {
__type__: 'cc.Prefab',
_name: name,
_objFlags: 0,
__editorExtras__: {},
_native: '',
data: ref(rootId),
optimizationPolicy: 0,
persistent: false,
};
}
module.exports = {
// 基础类型工厂(供外部使用)
vec3,
vec2,
ccSize,
ccColor,
ref,
// 节点/组件构建
makeNode,
makeUITransform,
makeSprite,
makeLabel,
makeWidget,
makeSpSkeleton,
// Prefab 元信息
makePrefabInfo,
makeCompPrefabInfo,
makePrefabRoot,
};
+76
View File
@@ -0,0 +1,76 @@
// ============================================================
// query/comp-fields.js — 组件字段提取(query 共用工具)
// ============================================================
'use strict';
// 系统级字段(每个 cc 组件都有,对调试无信息量)过滤掉
const RESERVED_COMP_FIELDS = new Set([
'__type__',
'_objFlags',
'__editorExtras__',
'node',
'_enabled',
'__prefab',
'_id',
]);
/** 提取组件的业务字段(过滤系统字段) */
function extractCompFields(comp) {
if (!comp || typeof comp !== 'object') return {};
const fields = {};
for (const key of Object.keys(comp)) {
if (RESERVED_COMP_FIELDS.has(key)) continue;
fields[key] = comp[key];
}
return fields;
}
/** 节点 _components 列表 → [{type, id, fields}] */
function componentDetails(elements, node) {
if (!Array.isArray(node._components)) return [];
const out = [];
for (const ref of node._components) {
if (typeof ref.__id__ !== 'number') continue;
const comp = elements[ref.__id__];
if (!comp) continue;
out.push({
type: comp.__type__,
id: ref.__id__,
fields: extractCompFields(comp),
});
}
return out;
}
/** 节点 _components 列表 → 类型名数组(轻量版,不展开字段) */
function componentTypes(elements, node) {
if (!Array.isArray(node._components)) return [];
const types = [];
for (const ref of node._components) {
if (typeof ref.__id__ !== 'number') continue;
const comp = elements[ref.__id__];
if (comp && comp.__type__) types.push(comp.__type__);
}
return types;
}
/** 判断节点是否是 stub(嵌套 prefab 根节点)— query 内部用 */
function isStub(elements, node) {
if (!node || node.__type__ !== 'cc.Node') return false;
const prefabRef = node._prefab;
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return false;
const prefabInfo = elements[prefabRef.__id__];
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return false;
const instanceRef = prefabInfo.instance;
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return false;
const instance = elements[instanceRef.__id__];
return !!(instance && instance.__type__ === 'cc.PrefabInstance');
}
module.exports = {
extractCompFields,
componentDetails,
componentTypes,
isStub,
};
+32
View File
@@ -0,0 +1,32 @@
// query/field.js — 单组件单字段值(脚本管道用)
'use strict';
function queryField(prefabData, args) {
const { elements } = prefabData;
const { name, componentType, field } = args;
if (!name) throw new Error('queryPrefab: selector.type="field" 必须提供 name');
if (!componentType) throw new Error('queryPrefab: selector.type="field" 必须提供 componentType');
if (!field) throw new Error('queryPrefab: selector.type="field" 必须提供 field');
const node = prefabData.findNodeByName(name);
if (!node) throw new Error(`queryPrefab[field]: 找不到节点 "${name}"`);
if (!Array.isArray(node._components)) {
throw new Error(`queryPrefab[field]: 节点 "${name}" 没有组件`);
}
for (const ref of node._components) {
if (typeof ref.__id__ !== 'number') continue;
const comp = elements[ref.__id__];
if (!comp || comp.__type__ !== componentType) continue;
if (!(field in comp)) {
throw new Error(
`queryPrefab[field]: 节点 "${name}" 的 ${componentType} 没有字段 "${field}"`
);
}
return comp[field];
}
throw new Error(`queryPrefab[field]: 节点 "${name}" 没有 ${componentType} 组件`);
}
module.exports = { queryField };
+17
View File
@@ -0,0 +1,17 @@
// query/find.js — 按 __type__ 列出所有匹配 element 的 id
'use strict';
function queryFind(prefabData, nodeType) {
const { elements } = prefabData;
const ids = [];
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el && typeof el === 'object' && el.__type__ === nodeType) {
ids.push(i);
}
}
return ids;
}
module.exports = { queryFind };
+56
View File
@@ -0,0 +1,56 @@
// ============================================================
// query/index.js — 只读查询主入口
//
// queryPrefab(filePath, selector)
// selector.type = 'tree' → 节点树(默认;selector.withComps 展开组件字段)
// selector.type = 'node' → 单节点详情(同上 withComps)
// selector.type = 'find' → 列所有 __type__ 匹配的 id
// selector.type = 'field' → 单组件单字段值
// selector.type = 'overrides' → 列 stub 节点 propertyOverrides + root targetOverrides
// ============================================================
'use strict';
const { parsePrefab } = require('../parse.js');
const { queryTree } = require('./tree.js');
const { queryNode } = require('./node.js');
const { queryFind } = require('./find.js');
const { queryField } = require('./field.js');
const { queryOverrides } = require('./overrides.js');
function queryPrefab(filePath, selector) {
const prefabData = parsePrefab(filePath);
// overrides 需要按 uuid 反查嵌套 prefab,必须知道 host path 用作 UuidResolver 起点
prefabData.resolverStartPath = filePath;
const type = selector && selector.type ? selector.type : 'tree';
const opts = { withComps: !!(selector && selector.withComps) };
if (type === 'tree') {
return queryTree(prefabData, opts);
}
if (type === 'node') {
const name = selector && selector.name;
if (!name) throw new Error('queryPrefab: selector.type="node" 时必须提供 selector.name');
return queryNode(prefabData, name, opts);
}
if (type === 'find') {
const nodeType = selector && selector.nodeType;
if (!nodeType) throw new Error('queryPrefab: selector.type="find" 时必须提供 selector.nodeType');
return queryFind(prefabData, nodeType);
}
if (type === 'field') {
return queryField(prefabData, selector);
}
if (type === 'overrides') {
return queryOverrides(prefabData, selector);
}
throw new Error(`queryPrefab: 未知 selector.type "${type}",支持 tree / node / find / field / overrides`);
}
module.exports = { queryPrefab };
+68
View File
@@ -0,0 +1,68 @@
// query/node.js — 按名称查单节点详情
'use strict';
const { listOverrides } = require('../overrides.js');
const { isStub, componentTypes, componentDetails } = require('./comp-fields.js');
function queryNode(prefabData, name, opts) {
const { elements } = prefabData;
const withComps = !!(opts && opts.withComps);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (!el || el.__type__ !== 'cc.Node') continue;
const stub = isStub(elements, el);
let resolvedName = el._name;
// stub 节点的实际名称可能存在 overrides 的 _name 字段
if (stub && resolvedName === undefined) {
try {
const ovs = listOverrides(prefabData, i);
const nameOv = ovs.find(
(o) => o.propertyPath.length === 1 && o.propertyPath[0] === '_name'
);
if (nameOv) resolvedName = nameOv.value;
} catch (_) {
// ignore
}
}
if (resolvedName !== name) continue;
// stub 节点:把身份信息(isStub/stubAsset/overrides)放在 raw 之前,
// 避免被 raw 大对象淹没。stub 节点的 _components / _children 字段在 raw 里是空的,
// 真实组件/子节点都属于被引用 prefab 内部,不在当前文件里。
const result = {
id: i,
name: resolvedName,
type: el.__type__,
active: el._active !== undefined ? el._active : null,
isStub: stub,
};
if (stub) {
const prefabInfo = elements[el._prefab.__id__];
result.stubAsset = prefabInfo && prefabInfo.asset && prefabInfo.asset.__uuid__
? prefabInfo.asset.__uuid__
: null;
try {
result.overrides = listOverrides(prefabData, i);
} catch (_) {
result.overrides = [];
}
result._note = 'stub 节点本身 _components/_children 为空;真实组件和子树在被引用 prefab 内(见 stubAsset),改 stub 内部字段用 set-nested-component-field / set-component-ref refSubNode';
}
result.componentTypes = componentTypes(elements, el);
if (withComps) {
result.components = componentDetails(elements, el);
}
result.raw = el;
return result;
}
return null;
}
module.exports = { queryNode };
+158
View File
@@ -0,0 +1,158 @@
// query/overrides.js — 列出 stub 节点当前所有 propertyOverrides + 关联的 root targetOverrides
//
// 输出:每条 override 标注落点(stub 自身节点字段 / 嵌套内某组件字段 / 嵌套内某节点字段),
// 配合 reset-overrides op 调试/回滚。
//
// args:
// - node: 节点 selectorname / id / {id} / {path});必须是 stub
//
// 输出结构:
// {
// stubNodeId, stubFileId, nestedPrefab,
// propertyOverrides: [
// { target: { kind, ...}, propertyPath, value }
// ],
// rootTargetOverrides: [
// { source: {id}, propertyPath, target: {...}, localIDChain }
// ]
// }
'use strict';
const { parsePrefab } = require('../parse.js');
const { resolveUuidToPath } = require('../uuid-resolver.js');
const { resolveNode, isStub } = require('../editor/helpers.js');
function _buildFileIdIndex(nestedElements) {
const index = new Map();
for (let i = 0; i < nestedElements.length; i++) {
const el = nestedElements[i];
if (!el) continue;
// 节点 PrefabInfo.fileId
if (el.__type__ === 'cc.Node' && el._prefab && typeof el._prefab.__id__ === 'number') {
const pi = nestedElements[el._prefab.__id__];
if (pi && pi.__type__ === 'cc.PrefabInfo' && typeof pi.fileId === 'string') {
index.set(pi.fileId, {
kind: 'nested-node',
nodeName: el._name || (el._parent ? null : '(root)'),
nodeId: i,
});
}
}
// 组件 CompPrefabInfo.fileId
if (el.__type__ && el.__prefab && typeof el.__prefab.__id__ === 'number') {
const cpi = nestedElements[el.__prefab.__id__];
if (cpi && cpi.__type__ === 'cc.CompPrefabInfo' && typeof cpi.fileId === 'string') {
const ownerNode = el.node && typeof el.node.__id__ === 'number'
? nestedElements[el.node.__id__] : null;
index.set(cpi.fileId, {
kind: 'nested-component',
componentType: el.__type__,
ownerNodeName: ownerNode ? (ownerNode._name || '(root)') : null,
ownerNodeId: ownerNode ? el.node.__id__ : null,
});
}
}
}
return index;
}
function queryOverrides(prefabData, args) {
const { elements } = prefabData;
const nodeSelector = args && args.node;
if (nodeSelector === undefined || nodeSelector === null) {
throw new Error('queryPrefab[overrides]: 必须提供 node');
}
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'overrides');
if (!isStub(elements, node)) {
throw new Error(`queryPrefab[overrides]: 节点 [${nodeId}] 不是 stub(无嵌套 prefab`);
}
const prefabInfo = elements[node._prefab.__id__];
const stubFileId = prefabInfo.fileId;
const prefabInstance = elements[prefabInfo.instance.__id__];
// 加载嵌套 prefab,建 fileId 反查索引
const nestedUuid = prefabInfo.asset && prefabInfo.asset.__uuid__;
let nestedPath = null;
let fileIdIndex = new Map();
if (typeof nestedUuid === 'string') {
try {
nestedPath = resolveUuidToPath(nestedUuid, prefabData.resolverStartPath);
const nestedData = parsePrefab(nestedPath);
fileIdIndex = _buildFileIdIndex(nestedData.elements);
} catch (_) {
// 嵌套 prefab 加载失败不阻断查询,target 会被标 unknown
}
}
const propertyOverrides = [];
if (Array.isArray(prefabInstance.propertyOverrides)) {
for (const ref of prefabInstance.propertyOverrides) {
if (!ref || typeof ref.__id__ !== 'number') continue;
const info = elements[ref.__id__];
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
const tiRef = info.targetInfo;
const ti = tiRef && typeof tiRef.__id__ === 'number' ? elements[tiRef.__id__] : null;
const localID = ti && Array.isArray(ti.localID) ? ti.localID : [];
const fid = localID[0];
let target;
if (fid === stubFileId) {
target = { kind: 'stub-node-field', nodeName: node._name || null };
} else {
const entry = fileIdIndex.get(fid);
target = entry ? { ...entry, fileId: fid } : { kind: 'unknown', fileId: fid };
}
if (localID.length > 1) {
target.localIDChain = [...localID];
}
propertyOverrides.push({
target,
propertyPath: [...(info.propertyPath || [])],
value: info.value,
});
}
}
// 关联此 stub 的 root targetOverridescc.TargetOverrideInfo
const rootTargetOverrides = [];
const rootNode = elements[prefabData.rootId];
if (rootNode && rootNode._prefab && typeof rootNode._prefab.__id__ === 'number') {
const rootPi = elements[rootNode._prefab.__id__];
if (rootPi && Array.isArray(rootPi.targetOverrides)) {
for (const r of rootPi.targetOverrides) {
if (!r || typeof r.__id__ !== 'number') continue;
const ov = elements[r.__id__];
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
if (!ov.target || ov.target.__id__ !== nodeId) continue;
const ti = ov.targetInfo && typeof ov.targetInfo.__id__ === 'number'
? elements[ov.targetInfo.__id__] : null;
const localID = ti && Array.isArray(ti.localID) ? [...ti.localID] : [];
const fid = localID[0];
const entry = fileIdIndex.get(fid);
const target = entry ? { ...entry, fileId: fid } : { kind: 'unknown', fileId: fid };
rootTargetOverrides.push({
source: { compId: ov.source ? ov.source.__id__ : null },
propertyPath: [...(ov.propertyPath || [])],
target,
localIDChain: localID,
});
}
}
}
return {
stubNodeId: nodeId,
stubFileId,
nestedPrefab: nestedPath,
propertyOverrides,
rootTargetOverrides,
};
}
module.exports = { queryOverrides };
+79
View File
@@ -0,0 +1,79 @@
// query/tree.js — 精简节点树(递归 DFS)
'use strict';
const { listOverrides } = require('../overrides.js');
const { isStub, componentTypes, componentDetails } = require('./comp-fields.js');
function buildTree(prefabData, nodeId, visited, opts) {
const { elements } = prefabData;
const node = elements[nodeId];
const stub = isStub(elements, node);
const withComps = !!(opts && opts.withComps);
// stub 节点真实 _name 字段为 null,需要从 propertyOverrides 里反查 _name override
let resolvedName = node._name !== undefined ? node._name : null;
if (stub) {
try {
const ovs = listOverrides(prefabData, nodeId);
const nameOv = ovs.find((o) => o.propertyPath.length === 1 && o.propertyPath[0] === '_name');
if (nameOv) resolvedName = nameOv.value;
} catch (_) {
// ignore
}
}
// stub 节点 name 加 (stub) 后缀,肉眼一眼区分嵌套实例和普通节点
// stub 无 _name override 时 resolvedName 为 null,显示纯 "(stub)" 字面值
let displayName;
if (stub) {
displayName = resolvedName !== null ? `${resolvedName} (stub)` : '(stub)';
} else {
displayName = resolvedName;
}
const treeNode = {
id: nodeId,
name: displayName,
type: node.__type__,
active: node._active !== undefined ? node._active : null,
isStub: stub,
};
if (stub) {
const prefabInfo = elements[node._prefab.__id__];
treeNode.stubAsset = prefabInfo && prefabInfo.asset && prefabInfo.asset.__uuid__
? prefabInfo.asset.__uuid__
: null;
try {
treeNode.overrides = listOverrides(prefabData, nodeId);
} catch (_) {
treeNode.overrides = [];
}
}
treeNode.children = [];
if (withComps) {
treeNode.components = componentDetails(elements, node);
} else {
treeNode.componentTypes = componentTypes(elements, node);
}
if (Array.isArray(node._children)) {
for (const childRef of node._children) {
if (typeof childRef.__id__ !== 'number') continue;
const cid = childRef.__id__;
if (visited.has(cid)) continue;
visited.add(cid);
treeNode.children.push(buildTree(prefabData, cid, visited, opts));
}
}
return treeNode;
}
function queryTree(prefabData, opts) {
const { rootId } = prefabData;
const visited = new Set([rootId]);
return buildTree(prefabData, rootId, visited, opts);
}
module.exports = { queryTree };
+162
View File
@@ -0,0 +1,162 @@
// ============================================================
// UuidResolver:惰性扫描 assets/ 下所有 .prefab.meta 文件
// 构建 uuid → prefab 磁盘路径 索引,仅扫描一次后缓存于内存。
//
// 设计约束:
// - 不引入第三方依赖(纯 Node.js fs + path + child_process
// - uuid 索引只扫一次,重复调用复用缓存
// - 解析失败时明确抛错,不静默降级
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ─── 模块级缓存 ───────────────────────────────────────────────
/**
* 缓存结构Map<projectRoot, Map<uuid, absolutePrefabPath>>
* 按项目根分隔支持同进程内多项目虽然实际上只会有一个
*/
const _cache = new Map();
// ─── 内部:定位 projectRoot ───────────────────────────────────
/**
* 从路径文件或目录往上查找项目根含有 assets/ 子目录 + package.json
*
* @param {string} startPath 绝对路径文件或目录均可
* @returns {string} 项目根绝对路径
* @throws 找不到时抛错
*/
function _findProjectRoot(startPath) {
const resolved = path.resolve(startPath);
// 若是文件取其目录,若是目录直接用
let dir;
try {
dir = fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
} catch (_) {
// 路径不存在(如 tmp 文件已被删除)也从 dirname 开始
dir = path.dirname(resolved);
}
// 最多向上 20 层
for (let i = 0; i < 20; i++) {
const hasAssets = fs.existsSync(path.join(dir, 'assets'));
const hasPkg = fs.existsSync(path.join(dir, 'package.json'));
if (hasAssets && hasPkg) return dir;
const parent = path.dirname(dir);
if (parent === dir) break; // 到达文件系统根
dir = parent;
}
throw new Error(
`UuidResolver: 无法从 "${startPath}" 向上找到项目根(含 assets/ + package.json 的目录)`
);
}
// ─── 内部:扫描并建立索引 ─────────────────────────────────────
/**
* 扫描 projectRoot/assets/ 下所有 .prefab.meta建立 uuid absolutePath 映射
*
* @param {string} projectRoot
* @returns {Map<string, string>}
*/
function _buildIndex(projectRoot) {
const assetsDir = path.join(projectRoot, 'assets');
if (!fs.existsSync(assetsDir)) {
throw new Error(`UuidResolver: assets 目录不存在: ${assetsDir}`);
}
// 用 find 命令比 Node 递归快,且无需手写 readdir 递归
let metaFiles;
try {
const raw = execSync(
`find "${assetsDir}" -name "*.prefab.meta" -type f`,
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
);
metaFiles = raw.trim().split('\n').filter(Boolean);
} catch (e) {
throw new Error(`UuidResolver: find 命令失败: ${e.message}`);
}
const index = new Map();
for (const metaFile of metaFiles) {
let meta;
try {
meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
} catch (_) {
// meta 损坏时跳过,不中断整体扫描
continue;
}
if (typeof meta.uuid !== 'string') continue;
// 对应的 prefab 路径 = 去掉 .meta 后缀
const prefabPath = metaFile.replace(/\.meta$/, '');
index.set(meta.uuid, prefabPath);
}
return index;
}
// ─── 公开 API ─────────────────────────────────────────────────
/**
* 根据路径推断项目根返回 uuid prefab 绝对路径 Map
* 多次调用同一 projectRoot 直接返回缓存不重复扫描
*
* @param {string} startPath 起点路径可以是宿主 prefab 的绝对路径从它向上找项目根
* 也可以直接是项目根目录 assets/ + package.json
* filePath /tmp/ 临时文件时应直接传入项目根目录
* @returns {Map<string, string>}
*/
function getUuidIndex(startPath) {
const projectRoot = _findProjectRoot(startPath);
if (_cache.has(projectRoot)) {
return _cache.get(projectRoot);
}
const index = _buildIndex(projectRoot);
_cache.set(projectRoot, index);
return index;
}
/**
* uuid 解析为 prefab 磁盘路径绝对路径
*
* @param {string} uuid 资产 UUID
* @param {string} startPath 起点路径宿主 prefab 路径 项目根目录用于推断项目根
* @returns {string} prefab 文件绝对路径
* @throws uuid 不存在时抛错
*/
function resolveUuidToPath(uuid, startPath) {
const index = getUuidIndex(startPath);
const result = index.get(uuid);
if (!result) {
throw new Error(
`UuidResolver: 找不到 uuid "${uuid}" 对应的 prefab 文件。` +
`已扫描项目内 ${index.size} 个 prefab。` +
`请确认该 uuid 对应的 prefab 存在于 assets/ 目录下。`
);
}
return result;
}
/**
* 清除缓存测试用正常使用不需要调用
*/
function clearCache() {
_cache.clear();
}
module.exports = { getUuidIndex, resolveUuidToPath, clearCache };
+57
View File
@@ -0,0 +1,57 @@
// ============================================================
// CC3 Prefab 格式保真写回(纯 CJS,零三方依赖)
// 探测原文件缩进 + 末尾换行字节,minimal-diff 写回
// ============================================================
'use strict';
const fs = require('fs');
/**
* 探测原始文件的缩进单位空格数
* 取所有以空格开头的行中最小缩进数
* @param {string} raw
* @returns {number} 缩进空格数默认 2
*/
function detectIndent(raw) {
const matches = [...raw.matchAll(/^( +)\S/gm)].map((m) => m[1].length);
if (matches.length === 0) return 2;
return Math.min(...matches);
}
/**
* 探测原始文件末尾是否有换行符
* @param {string} raw
* @returns {boolean}
*/
function detectTrailingNewline(raw) {
return raw.length > 0 && (raw[raw.length - 1] === '\n' || raw[raw.length - 1] === '\r');
}
/**
* 写回 prefab 文件保留原始格式缩进 + 末尾换行
*
* @param {string} filePath 写入目标路径可与读取路径不同T7 写临时路径
* @param {object[]} data 修改后的 elements 数组
* @param {string} originalRaw 原始文件内容用于探测格式
*/
function writePrefab(filePath, data, originalRaw) {
if (!Array.isArray(data)) {
throw new Error('writePrefab: data 必须是数组');
}
if (typeof originalRaw !== 'string') {
throw new Error('writePrefab: originalRaw 必须是字符串');
}
const indent = detectIndent(originalRaw);
const trailingNewline = detectTrailingNewline(originalRaw);
let newRaw = JSON.stringify(data, null, indent);
if (trailingNewline) {
newRaw += '\n';
}
fs.writeFileSync(filePath, newRaw, 'utf8');
}
module.exports = { writePrefab, detectIndent, detectTrailingNewline };