mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-06-10 17:56:47 +00:00
Initial public release: cc-3-8-x-mcp
Cocos Creator 3.8.x MCP bridge extension with a built-in offline CLI. Components: - Editor extension: in-process MCP server exposing scene / asset-db / preview / local / editor-process-control tools - stdio router: aggregates multiple editor instances on one machine, with shortName dedup - offline CLI (cocos-mcp-cli): headless prefab read/write + a wrapper around the Cocos CLI build Pure Node.js, zero third-party dependencies. Licensed under Apache-2.0.
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
// ============================================================
|
||||
// CC3 AnimationClip (.anim) 对象构建原语(纯 CJS,零三方依赖)
|
||||
//
|
||||
// .anim 文件和 .prefab 一样是 JSON 数组 + `__id__` 交叉引用,
|
||||
// 复用 parsePrefab / writePrefab 解析写入。
|
||||
//
|
||||
// 但 .anim 内部对象类型(AnimationClip / Track / Curve / Channel)
|
||||
// 有自己的 schema 规范,最容易踩的坑:
|
||||
//
|
||||
// ✅ cc.animation.RealTrack → 单通道 → `_channel: {__id__: X}` (单数)
|
||||
// ✅ cc.animation.ObjectTrack → 单通道 → `_channel: {__id__: X}` (单数)
|
||||
// ✅ cc.animation.VectorTrack → 多通道 → `_channels: [x, y, z]` (复数数组)
|
||||
// ✅ cc.animation.ColorTrack → 多通道 → `_channels: [r, g, b, a]` (复数数组)
|
||||
//
|
||||
// 写错字段名(比如给 RealTrack 写 `_channels: [...]`)会导致 CC3 编辑器
|
||||
// 按 schema 验证时直接忽略整条轨道,表现为「anim 文件里有数据但编辑器
|
||||
// 不显示关键帧、运行时也不播」——历史踩过的坑,见 anim-schema.md。
|
||||
//
|
||||
// 用本文件里的 make* 工厂函数,保证字段名一定写对。
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ref } = require('./primitives.js');
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 插值模式常量
|
||||
// 对应 cc.RealCurve.interpolationMode 枚举
|
||||
// ─────────────────────────────────────────────
|
||||
const InterpolationMode = Object.freeze({
|
||||
LINEAR: 0, // 线性插值,两关键帧之间平滑过渡
|
||||
CONSTANT: 1, // 常量插值,保持当前值直到下一帧瞬变
|
||||
CUBIC: 2, // 三次插值,带缓动曲线
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Extrapolation 模式常量
|
||||
// 对应 cc.RealCurve.preExtrapolation / postExtrapolation
|
||||
// ─────────────────────────────────────────────
|
||||
const Extrapolation = Object.freeze({
|
||||
LINEAR: 0,
|
||||
CLAMP: 1, // 最常用:首帧之前保持首帧值,末帧之后保持末帧值
|
||||
REPEAT: 2,
|
||||
PINGPONG: 3,
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// RealKeyframeValue:单个浮点关键帧
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.value
|
||||
* @param {number} [opts.interpolationMode=CONSTANT] 0=LINEAR 1=CONSTANT 2=CUBIC
|
||||
* @param {number} [opts.easingMethod=0] 0=Linear,其余见 cc.EasingMethod
|
||||
*/
|
||||
function makeRealKeyframe(opts) {
|
||||
const { value, interpolationMode = InterpolationMode.CONSTANT, easingMethod = 0 } = opts;
|
||||
return {
|
||||
__type__: 'cc.RealKeyframeValue',
|
||||
interpolationMode,
|
||||
tangentWeightMode: 0,
|
||||
value,
|
||||
rightTangent: 0,
|
||||
rightTangentWeight: 1,
|
||||
leftTangent: 0,
|
||||
leftTangentWeight: 1,
|
||||
easingMethod,
|
||||
__editorExtras__: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.RealCurve:浮点曲线
|
||||
// times/values 一一对应,长度必须相同
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number[]} opts.times - 递增时间点(秒)
|
||||
* @param {object[]} opts.values - RealKeyframeValue 对象数组,与 times 同长
|
||||
* @param {number} [opts.preExtrapolation=1] CLAMP
|
||||
* @param {number} [opts.postExtrapolation=1] CLAMP
|
||||
*/
|
||||
function makeRealCurve(opts) {
|
||||
const { times, values, preExtrapolation = Extrapolation.CLAMP, postExtrapolation = Extrapolation.CLAMP } = opts;
|
||||
if (times.length !== values.length) {
|
||||
throw new Error(`makeRealCurve: times.length (${times.length}) !== values.length (${values.length})`);
|
||||
}
|
||||
return {
|
||||
__type__: 'cc.RealCurve',
|
||||
_times: times,
|
||||
_values: values,
|
||||
preExtrapolation,
|
||||
postExtrapolation,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.ObjectCurve:对象引用曲线(用于 spriteFrame 等资产序列)
|
||||
// values 是资产引用对象数组(`{ __uuid__, __expectedType__ }`)
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number[]} opts.times
|
||||
* @param {object[]} opts.values - 资产引用对象
|
||||
*/
|
||||
function makeObjectCurve(opts) {
|
||||
const { times, values, preExtrapolation = Extrapolation.CLAMP, postExtrapolation = Extrapolation.CLAMP } = opts;
|
||||
if (times.length !== values.length) {
|
||||
throw new Error(`makeObjectCurve: times.length (${times.length}) !== values.length (${values.length})`);
|
||||
}
|
||||
return {
|
||||
__type__: 'cc.ObjectCurve',
|
||||
_times: times,
|
||||
_values: values,
|
||||
preExtrapolation,
|
||||
postExtrapolation,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.animation.Channel:Track → Curve 的中间层
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {number} curveIdx - RealCurve/ObjectCurve 在 objects 数组里的下标
|
||||
*/
|
||||
function makeChannel(curveIdx) {
|
||||
return {
|
||||
__type__: 'cc.animation.Channel',
|
||||
_curve: ref(curveIdx),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.animation.HierarchyPath / ComponentPath / TrackPath
|
||||
// 用于定位轨道的目标节点与属性
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {string} path - 节点层级路径,如 "n4" 或 "content/titleBar";根节点自身写 ""
|
||||
*/
|
||||
function makeHierarchyPath(path) {
|
||||
return { __type__: 'cc.animation.HierarchyPath', path };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} component - 组件类型,如 "cc.UIOpacity" / "cc.Sprite"
|
||||
*/
|
||||
function makeComponentPath(component) {
|
||||
return { __type__: 'cc.animation.ComponentPath', component };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[]} parts - 混合 HierarchyPath/ComponentPath 引用与属性字符串
|
||||
* 例:[ref(hierIdx), ref(compIdx), "opacity"]
|
||||
* 或根节点属性:[ref(compIdx), "position"](无 hierarchy path)
|
||||
*/
|
||||
function makeTrackPath(parts) {
|
||||
return { __type__: 'cc.animation.TrackPath', _paths: parts };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.animation.RealTrack(单通道浮点轨道)
|
||||
// ⚠️ 字段必须是 `_channel`(单数),不是 `_channels`
|
||||
// ⚠️ 写错会被 CC3 编辑器 schema 验证忽略,轨道形同虚设
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {number} trackPathIdx - TrackPath 在 objects 数组里的下标
|
||||
* @param {number} channelIdx - Channel 在 objects 数组里的下标
|
||||
*/
|
||||
function makeRealTrack(trackPathIdx, channelIdx) {
|
||||
return {
|
||||
__type__: 'cc.animation.RealTrack',
|
||||
_binding: {
|
||||
__type__: 'cc.animation.TrackBinding',
|
||||
path: ref(trackPathIdx),
|
||||
proxy: null,
|
||||
},
|
||||
_channel: ref(channelIdx),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.animation.ObjectTrack(单通道对象轨道,常用于 spriteFrame 序列帧)
|
||||
// ⚠️ 字段必须是 `_channel`(单数)
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {number} trackPathIdx
|
||||
* @param {number} channelIdx
|
||||
*/
|
||||
function makeObjectTrack(trackPathIdx, channelIdx) {
|
||||
return {
|
||||
__type__: 'cc.animation.ObjectTrack',
|
||||
_binding: {
|
||||
__type__: 'cc.animation.TrackBinding',
|
||||
path: ref(trackPathIdx),
|
||||
proxy: null,
|
||||
},
|
||||
_channel: ref(channelIdx),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.animation.VectorTrack(多通道,x/y/z 或 x/y/z/w)
|
||||
// ⚠️ 字段必须是 `_channels`(复数,数组),且必须提供 `_nComponents`
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {number} trackPathIdx
|
||||
* @param {number[]} channelIndices - 各轴 Channel 下标数组(长度 = nComponents)
|
||||
* @param {number} nComponents - 2(Vec2) / 3(Vec3) / 4(Vec4/Quat)
|
||||
*/
|
||||
function makeVectorTrack(trackPathIdx, channelIndices, nComponents) {
|
||||
if (channelIndices.length !== nComponents) {
|
||||
throw new Error(`makeVectorTrack: channelIndices.length (${channelIndices.length}) !== nComponents (${nComponents})`);
|
||||
}
|
||||
return {
|
||||
__type__: 'cc.animation.VectorTrack',
|
||||
_binding: {
|
||||
__type__: 'cc.animation.TrackBinding',
|
||||
path: ref(trackPathIdx),
|
||||
proxy: null,
|
||||
},
|
||||
_channels: channelIndices.map(ref),
|
||||
_nComponents: nComponents,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.animation.ColorTrack(4 通道 r/g/b/a)
|
||||
// ⚠️ 字段必须是 `_channels`(复数,数组),无 `_nComponents`
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {number} trackPathIdx
|
||||
* @param {number[]} channelIndices - [rIdx, gIdx, bIdx, aIdx]
|
||||
*/
|
||||
function makeColorTrack(trackPathIdx, channelIndices) {
|
||||
if (channelIndices.length !== 4) {
|
||||
throw new Error(`makeColorTrack: expected 4 channel indices, got ${channelIndices.length}`);
|
||||
}
|
||||
return {
|
||||
__type__: 'cc.animation.ColorTrack',
|
||||
_binding: {
|
||||
__type__: 'cc.animation.TrackBinding',
|
||||
path: ref(trackPathIdx),
|
||||
proxy: null,
|
||||
},
|
||||
_channels: channelIndices.map(ref),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.AnimationClipAdditiveSettings(每个 clip 尾巴一份)
|
||||
// ─────────────────────────────────────────────
|
||||
function makeAdditiveSettings() {
|
||||
return {
|
||||
__type__: 'cc.AnimationClipAdditiveSettings',
|
||||
enabled: false,
|
||||
refClip: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.AnimationClip 文件头(objects[0])
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.name
|
||||
* @param {number} opts.sample - 采样帧率
|
||||
* @param {number} opts.duration - 秒
|
||||
* @param {number} [opts.speed=1]
|
||||
* @param {number} [opts.wrapMode=1] 1=Normal, 2=Loop, 22=PingPong, 36=PingPongLoop
|
||||
* @param {number} opts.hash - 哈希值(通常 hashString(name))
|
||||
* @param {number[]} opts.trackIndices - 所有 Track 在 objects 数组里的下标
|
||||
* @param {number} opts.additiveIdx - AdditiveSettings 下标
|
||||
* @param {number[]} [opts.embeddedPlayerIndices=[]] - EmbeddedPlayer 下标
|
||||
*/
|
||||
function makeAnimationClip(opts) {
|
||||
const {
|
||||
name,
|
||||
sample,
|
||||
duration,
|
||||
speed = 1,
|
||||
wrapMode = 1,
|
||||
hash,
|
||||
trackIndices,
|
||||
additiveIdx,
|
||||
embeddedPlayerIndices = [],
|
||||
} = opts;
|
||||
return {
|
||||
__type__: 'cc.AnimationClip',
|
||||
_name: name,
|
||||
_objFlags: 0,
|
||||
__editorExtras__: { embeddedPlayerGroups: [] },
|
||||
_native: '',
|
||||
sample,
|
||||
speed,
|
||||
wrapMode,
|
||||
enableTrsBlending: false,
|
||||
_duration: duration,
|
||||
_hash: hash,
|
||||
_tracks: trackIndices.map(ref),
|
||||
_exoticAnimation: null,
|
||||
_events: [],
|
||||
_embeddedPlayers: embeddedPlayerIndices.map(ref),
|
||||
_additiveSettings: ref(additiveIdx),
|
||||
_auxiliaryCurveEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.animation.EmbeddedPlayer / EmbeddedAnimationClipPlayable
|
||||
// 用于 movieclip 这类「主 clip 触发节点自己 Animation 播子 clip」的结构
|
||||
// ─────────────────────────────────────────────
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.begin - 主 clip 时间线上子 clip 开始秒
|
||||
* @param {number} opts.end - 主 clip 时间线上子 clip 结束秒
|
||||
* @param {number} opts.playableIdx - EmbeddedAnimationClipPlayable 下标
|
||||
*/
|
||||
function makeEmbeddedPlayer(opts) {
|
||||
const { begin, end, playableIdx } = opts;
|
||||
return {
|
||||
__type__: 'cc.animation.EmbeddedPlayer',
|
||||
begin,
|
||||
end,
|
||||
reconciledSpeed: false,
|
||||
playable: ref(playableIdx),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.path - 目标节点路径(相对 clip 所在节点)
|
||||
* @param {string} opts.clipUuid - 子 clip 资产 UUID
|
||||
*/
|
||||
function makeEmbeddedAnimationClipPlayable(opts) {
|
||||
const { path, clipUuid } = opts;
|
||||
return {
|
||||
__type__: 'cc.animation.EmbeddedAnimationClipPlayable',
|
||||
path,
|
||||
clip: {
|
||||
__uuid__: clipUuid,
|
||||
__expectedType__: 'cc.AnimationClip',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 哈希函数:生成 AnimationClip._hash
|
||||
// 用简单字符串哈希,只需保证同名 clip 得到同一 hash 即可
|
||||
// ─────────────────────────────────────────────
|
||||
function hashString(s) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = ((hash << 5) - hash) + s.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InterpolationMode,
|
||||
Extrapolation,
|
||||
makeRealKeyframe,
|
||||
makeRealCurve,
|
||||
makeObjectCurve,
|
||||
makeChannel,
|
||||
makeHierarchyPath,
|
||||
makeComponentPath,
|
||||
makeTrackPath,
|
||||
makeRealTrack,
|
||||
makeObjectTrack,
|
||||
makeVectorTrack,
|
||||
makeColorTrack,
|
||||
makeAdditiveSettings,
|
||||
makeAnimationClip,
|
||||
makeEmbeddedPlayer,
|
||||
makeEmbeddedAnimationClipPlayable,
|
||||
hashString,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
// ============================================================
|
||||
// ClassIdResolver:扫描 assets/scripts/ 下所有 .ts,建立
|
||||
// className → { uuid, classId, scriptPath } 映射。
|
||||
//
|
||||
// 来源:
|
||||
// - @ccclass('SomeName') 装饰器提供 className
|
||||
// - 同路径 <script>.ts.meta 的 uuid 字段
|
||||
// - compressUuid(uuid) 得到 Cocos 序列化 prefab 用的 23 字符 classId
|
||||
//
|
||||
// 缓存粒度:projectRoot;多次调用复用。
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { compressUuid } = require('./id');
|
||||
|
||||
const _cache = new Map(); // projectRoot -> Map<className, entry>
|
||||
|
||||
/** 从路径向上找含 assets/+package.json 的目录。 */
|
||||
function _findProjectRoot(startPath) {
|
||||
const resolved = path.resolve(startPath);
|
||||
let dir;
|
||||
try {
|
||||
dir = fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
|
||||
} catch (_) {
|
||||
dir = path.dirname(resolved);
|
||||
}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (fs.existsSync(path.join(dir, 'assets')) && fs.existsSync(path.join(dir, 'package.json'))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error(`ClassIdResolver: 无法从 "${startPath}" 向上找到项目根`);
|
||||
}
|
||||
|
||||
/** 扫 .ts 抽取 @ccclass('Name');一个文件可以多个 */
|
||||
function _extractCcClassNames(src) {
|
||||
const names = [];
|
||||
const re = /@ccclass\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 在 assetsDir 下找所有 .ts(跳过 .d.ts),返回绝对路径数组。 */
|
||||
function _listTsFiles(assetsDir) {
|
||||
try {
|
||||
const raw = execSync(
|
||||
`find "${assetsDir}" -name "*.ts" -not -name "*.d.ts" -type f`,
|
||||
{ encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }
|
||||
);
|
||||
return raw.trim().split('\n').filter(Boolean);
|
||||
} catch (e) {
|
||||
throw new Error(`ClassIdResolver: find 命令失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 建 projectRoot 下的 className -> entry 索引。 */
|
||||
function _buildIndex(projectRoot) {
|
||||
const scriptsDir = path.join(projectRoot, 'assets', 'scripts');
|
||||
const scanDir = fs.existsSync(scriptsDir) ? scriptsDir : path.join(projectRoot, 'assets');
|
||||
|
||||
const tsFiles = _listTsFiles(scanDir);
|
||||
const index = new Map();
|
||||
|
||||
for (const tsPath of tsFiles) {
|
||||
const metaPath = tsPath + '.meta';
|
||||
if (!fs.existsSync(metaPath)) continue;
|
||||
|
||||
let src, meta;
|
||||
try {
|
||||
src = fs.readFileSync(tsPath, 'utf8');
|
||||
meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (typeof meta.uuid !== 'string') continue;
|
||||
|
||||
const names = _extractCcClassNames(src);
|
||||
if (names.length === 0) continue;
|
||||
|
||||
let classId;
|
||||
try {
|
||||
classId = compressUuid(meta.uuid);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
if (!index.has(name)) {
|
||||
index.set(name, { uuid: meta.uuid, classId, scriptPath: tsPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function _getIndex(startPath) {
|
||||
const projectRoot = _findProjectRoot(startPath);
|
||||
if (_cache.has(projectRoot)) return _cache.get(projectRoot);
|
||||
const idx = _buildIndex(projectRoot);
|
||||
_cache.set(projectRoot, idx);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 @ccclass 名字解析对应的压缩 classId。
|
||||
* @param {string} className 例如 'GMUI'
|
||||
* @param {string} startPath 项目内任一路径(用于定位 projectRoot)
|
||||
* @returns {string|null} 命中返回 classId;未命中返回 null(调用方决定是否报错)
|
||||
*/
|
||||
function resolveClassIdByName(className, startPath) {
|
||||
const idx = _getIndex(startPath);
|
||||
const entry = idx.get(className);
|
||||
return entry ? entry.classId : null;
|
||||
}
|
||||
|
||||
/** 测试/诊断:列出所有 className → classId 对。 */
|
||||
function listAll(startPath) {
|
||||
const idx = _getIndex(startPath);
|
||||
const out = {};
|
||||
for (const [k, v] of idx.entries()) out[k] = v.classId;
|
||||
return out;
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
module.exports = { resolveClassIdByName, listAll, clearCache };
|
||||
@@ -0,0 +1,113 @@
|
||||
// cli/anim-cmd.js — anim 子命令
|
||||
//
|
||||
// .anim 文件与 prefab 同为 JSON 数组 + __id__ 引用格式,editPrefab 可直接复用。
|
||||
// 本子命令是封装:让用户语义上明确"这是动画文件操作",并允许 query 节点结构。
|
||||
//
|
||||
// 用法:
|
||||
// anim query <anim> [--selector tree|node|find|field] ...
|
||||
// anim batch <anim> <ops.json> [--dry-run]
|
||||
//
|
||||
// 注意:op 库当前面向 cc.Node 树设计,对 .anim 内的 cc.AnimationClip /
|
||||
// cc.Track / cc.Curve 等结构无专属 op,但通用的 set-component-field(用于
|
||||
// AnimationClip 顶层)/ set-component-ref / dedupe-component 等仍可用。
|
||||
// 真正的 anim 曲线编辑应由 src/anim-primitives.js 暴露的 helper 在脚本中处理。
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { editPrefab } = require('../editor/index.js');
|
||||
const { queryPrefab } = require('../query/index.js');
|
||||
const { parseFlags } = require('./flags.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolvePath(p) {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
function cmdAnim(args) {
|
||||
const sub = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (!sub || sub === '--help' || sub === '-h') {
|
||||
process.stdout.write(`anim <subcommand> <file> [args]
|
||||
|
||||
Subcommands:
|
||||
query <anim> [--selector tree|node|find|field] ...
|
||||
batch <anim> <ops.json> [--project-root <path>] [--dry-run]
|
||||
|
||||
注意:.anim 与 .prefab 同为 JSON 数组 + __id__ 引用格式,复用 editPrefab。
|
||||
op 库当前主要面向 cc.Node 树;编辑动画曲线请用 src/anim-primitives.js
|
||||
暴露的 helper 在脚本中处理。
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'query') {
|
||||
const { flags, positional } = parseFlags(rest);
|
||||
const animArg = positional[0];
|
||||
if (!animArg) die('anim query: 必须提供 <anim>');
|
||||
const animPath = resolvePath(animArg);
|
||||
if (!fs.existsSync(animPath)) die(`anim query: 文件不存在: ${animPath}`);
|
||||
|
||||
const selectorType = flags['selector'] || 'tree';
|
||||
const withComps = flags['with-comps'] === true;
|
||||
let selector;
|
||||
if (selectorType === 'tree') selector = { type: 'tree', withComps };
|
||||
else if (selectorType === 'node') selector = { type: 'node', name: flags['name'], withComps };
|
||||
else if (selectorType === 'find') selector = { type: 'find', nodeType: flags['type'] };
|
||||
else if (selectorType === 'field') selector = {
|
||||
type: 'field', name: flags['name'], componentType: flags['comp'], field: flags['field'],
|
||||
};
|
||||
else die(`anim query: 不支持的 --selector "${selectorType}"`);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = queryPrefab(animPath, selector);
|
||||
} catch (e) {
|
||||
die('anim query 失败: ' + e.message);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'batch') {
|
||||
const { flags, positional } = parseFlags(rest);
|
||||
const [animArg, opsArg] = positional;
|
||||
if (!animArg) die('anim batch: 必须提供 <anim>');
|
||||
if (!opsArg) die('anim batch: 必须提供 <ops.json>');
|
||||
const animPath = resolvePath(animArg);
|
||||
const opsPath = resolvePath(opsArg);
|
||||
if (!fs.existsSync(animPath)) die(`anim batch: 文件不存在: ${animPath}`);
|
||||
if (!fs.existsSync(opsPath)) die(`anim batch: ops 文件不存在: ${opsPath}`);
|
||||
|
||||
let ops;
|
||||
try {
|
||||
ops = JSON.parse(fs.readFileSync(opsPath, 'utf8'));
|
||||
} catch (e) {
|
||||
die('anim batch: ops.json 解析失败: ' + e.message);
|
||||
}
|
||||
if (!Array.isArray(ops)) die('anim batch: ops.json 必须是数组');
|
||||
|
||||
const editOptions = {};
|
||||
if (flags['project-root']) editOptions.projectRoot = resolvePath(flags['project-root']);
|
||||
if (flags['dry-run'] === true) editOptions.dryRun = true;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = editPrefab(animPath, ops, editOptions);
|
||||
} catch (e) {
|
||||
die('anim batch 失败: ' + e.message);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
die(`anim: 未知子命令 "${sub}",可用: query / batch`);
|
||||
}
|
||||
|
||||
module.exports = { cmdAnim };
|
||||
@@ -0,0 +1,178 @@
|
||||
// cli/batch-cmd.js — batch 子命令
|
||||
//
|
||||
// 用法:
|
||||
// batch <prefab> <ops.json> [--project-root P] [--dry-run]
|
||||
// batch <ops.json> --glob <pattern> [--project-root P] [--dry-run]
|
||||
//
|
||||
// --glob:把第一个位置参数当 ops.json,对所有匹配 pattern 的 prefab 跑同一组 ops
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { editPrefab } = require('../editor/index.js');
|
||||
const { parseFlags } = require('./flags.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolvePath(p) {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
// glob 匹配(自实现,支持 ** / * / ?)
|
||||
// ** 匹配任意层目录(包括 0 层)
|
||||
// * 匹配一段非斜杠字符
|
||||
// ? 匹配单字符
|
||||
function _globToRegex(pattern) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*' && pattern[i + 1] === '*') {
|
||||
// 处理 **/ 和 ** 末尾
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else if (c === '*') {
|
||||
re += '[^/]*';
|
||||
i++;
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i++;
|
||||
} else if ('.+()|[]{}^$\\'.includes(c)) {
|
||||
re += '\\' + c;
|
||||
i++;
|
||||
} else {
|
||||
re += c;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
function _walk(dir, relRoot, out) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const full = path.join(dir, ent.name);
|
||||
const rel = path.relative(relRoot, full);
|
||||
if (ent.isDirectory()) {
|
||||
// 跳过 node_modules / .git 等
|
||||
if (ent.name === 'node_modules' || ent.name === '.git') continue;
|
||||
_walk(full, relRoot, out);
|
||||
} else if (ent.isFile()) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _expandGlob(pattern) {
|
||||
// 取 pattern 里第一个含通配符之前的目录段作为扫描根
|
||||
const cwd = process.cwd();
|
||||
const segments = pattern.split('/');
|
||||
const baseSegs = [];
|
||||
for (const s of segments) {
|
||||
if (s.includes('*') || s.includes('?')) break;
|
||||
baseSegs.push(s);
|
||||
}
|
||||
const baseRel = baseSegs.join('/');
|
||||
const baseAbs = baseRel ? path.resolve(cwd, baseRel) : cwd;
|
||||
if (!fs.existsSync(baseAbs)) return [];
|
||||
|
||||
const all = [];
|
||||
if (fs.statSync(baseAbs).isFile()) {
|
||||
return [path.resolve(cwd, pattern)];
|
||||
}
|
||||
_walk(baseAbs, cwd, all);
|
||||
const re = _globToRegex(pattern);
|
||||
return all.filter((rel) => re.test(rel)).map((rel) => path.resolve(cwd, rel));
|
||||
}
|
||||
|
||||
function cmdBatch(args) {
|
||||
const { flags, positional } = parseFlags(args);
|
||||
|
||||
const editOptions = {};
|
||||
if (flags['project-root']) {
|
||||
editOptions.projectRoot = resolvePath(flags['project-root']);
|
||||
}
|
||||
if (flags['dry-run'] === true) {
|
||||
editOptions.dryRun = true;
|
||||
}
|
||||
|
||||
// glob 模式:第一个位置参数当 ops.json
|
||||
if (flags['glob']) {
|
||||
const opsArg = positional[0];
|
||||
if (!opsArg) die('batch --glob: 必须提供 <ops.json>');
|
||||
const opsPath = resolvePath(opsArg);
|
||||
if (!fs.existsSync(opsPath)) die(`batch --glob: ops 文件不存在: ${opsPath}`);
|
||||
const ops = _readOps(opsPath);
|
||||
|
||||
const matched = _expandGlob(flags['glob']);
|
||||
if (matched.length === 0) {
|
||||
die(`batch --glob: pattern "${flags['glob']}" 未匹配任何文件`);
|
||||
}
|
||||
|
||||
const summary = [];
|
||||
for (const prefabPath of matched) {
|
||||
try {
|
||||
const result = editPrefab(prefabPath, ops, editOptions);
|
||||
summary.push({ file: path.relative(process.cwd(), prefabPath), ...result });
|
||||
} catch (e) {
|
||||
summary.push({ file: path.relative(process.cwd(), prefabPath), error: e.message });
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ matchedCount: matched.length, results: summary }, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// 单文件模式
|
||||
const [prefabArg, opsArg] = positional;
|
||||
if (!prefabArg) die('batch: 必须提供 <prefab>');
|
||||
if (!opsArg) die('batch: 必须提供 <ops.json>');
|
||||
|
||||
const prefabPath = resolvePath(prefabArg);
|
||||
if (!fs.existsSync(prefabPath)) die(`batch: prefab 文件不存在: ${prefabPath}`);
|
||||
|
||||
const ops = opsArg === '-'
|
||||
? _readOpsRaw(fs.readFileSync('/dev/stdin', 'utf8'))
|
||||
: _readOps(resolvePath(opsArg));
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = editPrefab(prefabPath, ops, editOptions);
|
||||
} catch (e) {
|
||||
die('batch 失败: ' + e.message);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function _readOps(opsPath) {
|
||||
if (!fs.existsSync(opsPath)) die(`batch: ops 文件不存在: ${opsPath}`);
|
||||
return _readOpsRaw(fs.readFileSync(opsPath, 'utf8'));
|
||||
}
|
||||
|
||||
function _readOpsRaw(raw) {
|
||||
let ops;
|
||||
try {
|
||||
ops = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
die('batch: ops.json 解析失败: ' + e.message);
|
||||
}
|
||||
if (!Array.isArray(ops)) die('batch: ops.json 必须是数组');
|
||||
return ops;
|
||||
}
|
||||
|
||||
module.exports = { cmdBatch };
|
||||
@@ -0,0 +1,141 @@
|
||||
// ============================================================
|
||||
// cli/build-cmd.js — Cocos CLI 命令行打包(headless,不依赖编辑器/MCP)
|
||||
//
|
||||
// 封装 Cocos Creator 的命令行构建:
|
||||
// CocosCreator --project <path> --build "configPath=<json>"
|
||||
// CocosCreator --project <path> --build "platform=<plat>;debug=<bool>"
|
||||
//
|
||||
// 退出码:Cocos 退出非 0 常见于 postBuild 阶段的资源警告等非致命问题(主构建其实成功)。
|
||||
// 默认照产物存在性判定:产物齐了就退 0(成功),产物缺才透传 Cocos 退出码(失败)。
|
||||
// --strict 关掉这个兜底,直接透传 Cocos 退出码。
|
||||
//
|
||||
// 用法:
|
||||
// cocos-mcp-cli build --project <path> --version 3.8.8 --config <buildConfig.json>
|
||||
// cocos-mcp-cli build --project <path> --cocos <CocosCreator可执行> --platform web-mobile
|
||||
// cocos-mcp-cli build ... --dry-run # 只打印命令不真跑
|
||||
// cocos-mcp-cli build ... --strict # 严格透传 Cocos 退出码(不做产物兜底)
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function die(msg) { process.stderr.write('Error: ' + msg + '\n'); process.exit(1); }
|
||||
|
||||
function parseArgs(rest) {
|
||||
const a = { project: '', cocos: '', version: '', config: '', platform: '', debug: false, dryRun: false, strict: false };
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const k = rest[i];
|
||||
if (k === '--project' || k === '-p') a.project = rest[++i];
|
||||
else if (k === '--cocos' || k === '-c') a.cocos = rest[++i];
|
||||
else if (k === '--version' || k === '-v') a.version = rest[++i];
|
||||
else if (k === '--config') a.config = rest[++i];
|
||||
else if (k === '--platform') a.platform = rest[++i];
|
||||
else if (k === '--debug') a.debug = true;
|
||||
else if (k === '--dry-run') a.dryRun = true;
|
||||
else if (k === '--strict') a.strict = true;
|
||||
else die(`未知参数 "${k}"。用法见 cocos-mcp-cli build --help`);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// 解析 CocosCreator 可执行:--cocos 显式优先,否则 --version 拼标准安装路径(macOS)
|
||||
function resolveCocos(a) {
|
||||
if (a.cocos) {
|
||||
if (!fs.existsSync(a.cocos)) die(`--cocos 路径不存在: ${a.cocos}`);
|
||||
return a.cocos;
|
||||
}
|
||||
if (a.version) {
|
||||
const p = `/Applications/Cocos/Creator/${a.version}/CocosCreator.app/Contents/MacOS/CocosCreator`;
|
||||
if (!fs.existsSync(p)) die(`版本 ${a.version} 不在标准路径: ${p}\n 用 --cocos <可执行绝对路径> 显式指定`);
|
||||
return p;
|
||||
}
|
||||
die('需指定 CocosCreator 可执行:--cocos <path> 或 --version <如 3.8.8>');
|
||||
}
|
||||
|
||||
// 退出非 0 时按产物存在性判定主构建是否成功。true=产物齐 / false=产物缺 / null=无法定位产物目录
|
||||
function checkArtifact(a) {
|
||||
let outDir;
|
||||
if (a.platform) {
|
||||
// outputName 默认等于 platform;非默认场景请用 --config
|
||||
outDir = path.join(a.project, 'build', a.platform);
|
||||
} else if (a.config) {
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(a.config, 'utf8'));
|
||||
const bp = String(cfg.buildPath || 'project://build').replace(/^project:\/\//, a.project + path.sep);
|
||||
const on = cfg.outputName || cfg.platform || '';
|
||||
outDir = path.join(bp, on);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
if (!outDir || !fs.existsSync(outDir)) return false;
|
||||
// 关键产物标志:index.html(web) / game.json(小游戏) / application.js
|
||||
return ['index.html', 'game.json', 'application.js'].some(function (m) { return fs.existsSync(path.join(outDir, m)); });
|
||||
}
|
||||
|
||||
function cmdBuild(rest) {
|
||||
if (rest[0] === '--help' || rest[0] === '-h') {
|
||||
process.stdout.write(
|
||||
'cocos-mcp-cli build — Cocos 命令行打包(headless)\n\n' +
|
||||
' --project, -p <path> 项目根目录(必填)\n' +
|
||||
' --version, -v <ver> Cocos 版本(拼标准安装路径,如 3.8.8)\n' +
|
||||
' --cocos, -c <path> 或直接给 CocosCreator 可执行绝对路径(优先于 --version)\n' +
|
||||
' --config <json> 构建配置文件(→ --build "configPath=<json>")\n' +
|
||||
' --platform <plat> 或按平台构建(→ --build "platform=<plat>"),如 web-mobile / alipay-mini-game\n' +
|
||||
' --debug 平台构建时 debug=true(默认 false)\n' +
|
||||
' --dry-run 只打印将执行的命令,不真跑\n' +
|
||||
' --strict 严格透传 Cocos 退出码(默认会按产物存在性兜底,把 postBuild 非致命的非 0 当成功)\n\n' +
|
||||
'示例:\n' +
|
||||
' cocos-mcp-cli build -p /path/to/forest -v 3.8.8 --config /path/build.json\n' +
|
||||
' cocos-mcp-cli build -p /path/to/forest -v 3.8.8 --platform web-mobile --dry-run\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const a = parseArgs(rest);
|
||||
if (!a.project) die('缺 --project <项目路径>');
|
||||
if (!fs.existsSync(a.project)) die(`项目路径不存在: ${a.project}`);
|
||||
if (!a.config && !a.platform) die('需指定构建配置:--config <buildConfig.json> 或 --platform <平台>');
|
||||
|
||||
const cocos = resolveCocos(a);
|
||||
|
||||
let buildArg;
|
||||
if (a.config) {
|
||||
if (!fs.existsSync(a.config)) die(`--config 文件不存在: ${a.config}`);
|
||||
buildArg = `configPath=${a.config}`;
|
||||
} else {
|
||||
buildArg = `platform=${a.platform};debug=${a.debug ? 'true' : 'false'}`;
|
||||
}
|
||||
|
||||
const argv = ['--project', a.project, '--build', buildArg];
|
||||
|
||||
if (a.dryRun) {
|
||||
process.stdout.write('[dry-run] 将执行:\n ' + cocos + ' --project ' + a.project + ' --build "' + buildArg + '"\n');
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('Cocos CLI build 启动(headless,可能耗时几分钟):\n --project ' + a.project + '\n --build "' + buildArg + '"\n\n');
|
||||
const child = spawn(cocos, argv, { stdio: 'inherit' });
|
||||
child.on('error', function (e) { die('启动 Cocos 失败: ' + e.message); });
|
||||
child.on('exit', function (code) {
|
||||
if (code === 0) {
|
||||
process.stdout.write('\nCocos 退出码: 0 — 构建成功\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const arti = a.strict ? null : checkArtifact(a);
|
||||
if (arti === true) {
|
||||
process.stdout.write('\nCocos 退出码: ' + code + '(非 0),但产物已生成 → 判定主构建成功(非 0 多为 postBuild 资源警告等非致命问题)。\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (arti === false) {
|
||||
process.stdout.write('\nCocos 退出码: ' + code + ',产物未生成 → 构建失败。\n');
|
||||
process.exit(code == null ? 1 : code);
|
||||
}
|
||||
// null:--strict 或无法定位产物目录(如 --config 解析失败)→ 透传
|
||||
process.stdout.write('\nCocos 退出码: ' + (code == null ? '(被信号终止)' : code) +
|
||||
(a.strict ? '(--strict,透传)' : '(无法定位产物目录,透传)') + '\n');
|
||||
process.exit(code == null ? 1 : code);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { cmdBuild };
|
||||
@@ -0,0 +1,136 @@
|
||||
// ============================================================
|
||||
// compact-cmd.js — 清理 prefab data 数组里的 null 槽位 + 重映射 __id__
|
||||
//
|
||||
// 用途:早期某些 prefab(比如 extract-prefab 命令上线前手工生成的)含 null 槽位,
|
||||
// Cocos editor 反序列化容错跳过,但 build worker 严格 scan 撞 null 崩
|
||||
// 「Cannot read properties of undefined (reading '__type__')」。
|
||||
//
|
||||
// 算法(跟 extract-cmd line 105-132 同款,但不剔除任何东西):
|
||||
// 1) 收集所有 null 索引
|
||||
// 2) 构造 oldIdx → newIdx 映射(newIdx = oldIdx - 前面 null 数量)
|
||||
// 3) newData = data.filter(el => el !== null)
|
||||
// 4) 递归重映射所有 __id__ 引用
|
||||
//
|
||||
// 用法:
|
||||
// cocos-mcp-cli compact-prefab <prefab> [--dry-run]
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function cmdCompactPrefab(argv) {
|
||||
let prefabPath = null;
|
||||
let dryRun = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (arg.startsWith('--')) {
|
||||
die(`未知参数 "${arg}"`);
|
||||
} else if (prefabPath === null) {
|
||||
prefabPath = arg;
|
||||
} else {
|
||||
die(`多余位置参数 "${arg}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefabPath) die('需要 <prefab> 路径参数');
|
||||
|
||||
compactOne(prefabPath, dryRun);
|
||||
}
|
||||
|
||||
function compactOne(prefabPath, dryRun) {
|
||||
const abs = path.resolve(process.cwd(), prefabPath);
|
||||
if (!fs.existsSync(abs)) die(`prefab 不存在: ${prefabPath}`);
|
||||
|
||||
const raw = fs.readFileSync(abs, 'utf8');
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
die(`JSON 解析失败 (${prefabPath}): ${e.message}`);
|
||||
}
|
||||
if (!Array.isArray(data)) die(`不是 prefab 数据数组: ${prefabPath}`);
|
||||
|
||||
// 1) 收集 null 索引
|
||||
const nullIdxs = [];
|
||||
data.forEach((el, i) => { if (el === null) nullIdxs.push(i); });
|
||||
|
||||
if (nullIdxs.length === 0) {
|
||||
process.stdout.write(`${prefabPath} → 无 null 槽位,无需 compact\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 构造 oldIdx → newIdx 映射
|
||||
const oldToNew = new Map();
|
||||
let newIdx = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i] !== null) {
|
||||
oldToNew.set(i, newIdx);
|
||||
newIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 新数组
|
||||
const newData = data.filter((el) => el !== null);
|
||||
|
||||
// 4) 重映射 __id__;跟踪指向已删 null 的 dangling 引用
|
||||
let danglingRefs = 0;
|
||||
const danglingDetails = [];
|
||||
for (let i = 0; i < newData.length; i++) {
|
||||
_remapIds(newData[i], oldToNew, '[' + i + ']', danglingDetails);
|
||||
}
|
||||
danglingRefs = danglingDetails.length;
|
||||
|
||||
// 5) Dry-run / Apply
|
||||
const summary = `${prefabPath} → ${data.length} → ${newData.length} (清掉 ${nullIdxs.length} 个 null)`;
|
||||
process.stdout.write(summary + (dryRun ? ' [dry-run]' : '') + '\n');
|
||||
if (nullIdxs.length <= 50) {
|
||||
process.stdout.write(' 原 null 索引: ' + nullIdxs.join(',') + '\n');
|
||||
} else {
|
||||
process.stdout.write(' 原 null 索引(前 20): ' + nullIdxs.slice(0, 20).join(',') + ' ... (共 ' + nullIdxs.length + ')\n');
|
||||
}
|
||||
if (danglingRefs > 0) {
|
||||
process.stderr.write(`⚠ ${danglingRefs} 个 __id__ 引用原本指向 null 槽位(已置 null):\n`);
|
||||
danglingDetails.slice(0, 5).forEach((d) => process.stderr.write(' ' + d + '\n'));
|
||||
if (danglingDetails.length > 5) process.stderr.write(` ... 共 ${danglingDetails.length} 处\n`);
|
||||
}
|
||||
|
||||
if (dryRun) return;
|
||||
|
||||
fs.writeFileSync(abs, JSON.stringify(newData, null, 2) + '\n', 'utf8');
|
||||
process.stdout.write(` ✓ 写入 ${prefabPath}\n`);
|
||||
}
|
||||
|
||||
function _remapIds(obj, map, location, dangling) {
|
||||
if (obj === null || obj === undefined) return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) _remapIds(obj[i], map, location + '[' + i + ']', dangling);
|
||||
return;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
if (typeof obj.__id__ === 'number') {
|
||||
const oldId = obj.__id__;
|
||||
const newId = map.get(oldId);
|
||||
if (newId === undefined) {
|
||||
// 引用指向 null 槽位 —— 把 __id__ 置 null
|
||||
obj.__id__ = null;
|
||||
dangling.push(location + ' → __id__:' + oldId);
|
||||
} else {
|
||||
obj.__id__ = newId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const k of Object.keys(obj)) _remapIds(obj[k], map, location + '.' + k, dangling);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { cmdCompactPrefab };
|
||||
@@ -0,0 +1,160 @@
|
||||
// ============================================================
|
||||
// cli/create-cmd.js — create-prefab 子命令
|
||||
//
|
||||
// 用法:
|
||||
// cocos-mcp-cli create-prefab <output-path>
|
||||
// [--name <name>] [--width <w>] [--height <h>]
|
||||
// [--add-spine <skel-uuid>] [--dry-run]
|
||||
//
|
||||
// 生成最小 prefab(root 节点 + UITransform)+ 配套 .prefab.meta。
|
||||
//
|
||||
// 加 --add-spine <uuid> 时,在 root 节点上多挂一个 sp.Skeleton 组件,
|
||||
// _skeletonData.__uuid__ 指向给定的 .skel 资产 UUID。批量生成 spine prefab
|
||||
// 推荐外层 shell 循环 + N 次调用:
|
||||
//
|
||||
// for meta in assets/res/<group>/<xxxN>/*.skel.meta; do
|
||||
// name=$(basename "$meta" .skel.meta)
|
||||
// uuid=$(node -e 'console.log(require("./"+process.argv[1]).uuid)' "$meta")
|
||||
// node bin/cocos-mcp-cli.js create-prefab \
|
||||
// "assets/packages/<group>/<xxxN>/prefab/${name}.prefab" --add-spine "$uuid"
|
||||
// done
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { deterministicUUID, deterministicFileId } = require('../id.js');
|
||||
const {
|
||||
makePrefabRoot,
|
||||
makeNode,
|
||||
makeUITransform,
|
||||
makePrefabInfo,
|
||||
makeCompPrefabInfo,
|
||||
makeSpSkeleton,
|
||||
} = require('../primitives.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function cmdCreatePrefab(argv) {
|
||||
let outputPath = null;
|
||||
let name = null;
|
||||
let width = null;
|
||||
let height = null;
|
||||
let dryRun = false;
|
||||
let spineSkelUuid = null;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--name') {
|
||||
name = argv[++i];
|
||||
if (!name) die('--name 需要一个值');
|
||||
} else if (arg === '--width') {
|
||||
width = Number(argv[++i]);
|
||||
if (isNaN(width)) die('--width 必须是数字');
|
||||
} else if (arg === '--height') {
|
||||
height = Number(argv[++i]);
|
||||
if (isNaN(height)) die('--height 必须是数字');
|
||||
} else if (arg === '--add-spine') {
|
||||
spineSkelUuid = argv[++i];
|
||||
if (!spineSkelUuid) die('--add-spine 需要一个 .skel 资产 UUID');
|
||||
} else if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (!arg.startsWith('--')) {
|
||||
if (outputPath !== null) die('多余的位置参数: ' + arg);
|
||||
outputPath = arg;
|
||||
} else {
|
||||
die(`未知参数 "${arg}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!outputPath) {
|
||||
die('用法: create-prefab <output-path> [--name <name>] [--width <w>] [--height <h>] [--add-spine <skel-uuid>] [--dry-run]');
|
||||
}
|
||||
|
||||
// 确保 .prefab 后缀
|
||||
if (!outputPath.endsWith('.prefab')) outputPath += '.prefab';
|
||||
|
||||
// 推断 prefab 名称(basename 去掉 .prefab)
|
||||
if (!name) name = path.basename(outputPath, '.prefab');
|
||||
|
||||
// 默认 UITransform 尺寸:spine prefab 走 100×100(sp.Skeleton 自己控制渲染,
|
||||
// contentSize 不影响运行时;与现有 sk_loading.prefab 等产物对齐),
|
||||
// 普通 UI prefab 走 750×200
|
||||
if (width === null) width = spineSkelUuid ? 100 : 750;
|
||||
if (height === null) height = spineSkelUuid ? 100 : 200;
|
||||
|
||||
// 确定性 ID(以名称为种子,保证同名 prefab 每次生成相同 UUID)
|
||||
const seed = `create-prefab:${name}`;
|
||||
const prefabUuid = deterministicUUID(`${seed}:uuid`);
|
||||
const rootFileId = deterministicFileId(`${seed}:root:fid`);
|
||||
const uitransformFileId = deterministicFileId(`${seed}:uitransform:fid`);
|
||||
|
||||
// 索引分配 — 不带 spine(5 条):
|
||||
// 0 cc.Prefab
|
||||
// 1 cc.Node (root)
|
||||
// 2 cc.UITransform
|
||||
// 3 cc.CompPrefabInfo (UITransform 的)
|
||||
// 4 cc.PrefabInfo (root 的)
|
||||
// 带 spine(7 条):
|
||||
// 0 cc.Prefab
|
||||
// 1 cc.Node (root, _components: [2, 4])
|
||||
// 2 cc.UITransform
|
||||
// 3 cc.CompPrefabInfo (UITransform 的)
|
||||
// 4 sp.Skeleton
|
||||
// 5 cc.CompPrefabInfo (sp.Skeleton 的)
|
||||
// 6 cc.PrefabInfo (root 的)
|
||||
let data;
|
||||
if (spineSkelUuid) {
|
||||
const spineFileId = deterministicFileId(`${seed}:sp.Skeleton:fid`);
|
||||
data = [
|
||||
makePrefabRoot({ name, rootId: 1 }),
|
||||
makeNode({ name, componentIds: [2, 4], prefabId: 6 }),
|
||||
makeUITransform({ nodeId: 1, width, height, prefabInfoId: 3 }),
|
||||
makeCompPrefabInfo(uitransformFileId),
|
||||
makeSpSkeleton({ nodeId: 1, skeletonUuid: spineSkelUuid, prefabInfoId: 5 }),
|
||||
makeCompPrefabInfo(spineFileId),
|
||||
makePrefabInfo({ rootId: 1, fileId: rootFileId, assetId: 0 }),
|
||||
];
|
||||
} else {
|
||||
data = [
|
||||
makePrefabRoot({ name, rootId: 1 }),
|
||||
makeNode({ name, componentIds: [2], prefabId: 4 }),
|
||||
makeUITransform({ nodeId: 1, width, height, prefabInfoId: 3 }),
|
||||
makeCompPrefabInfo(uitransformFileId),
|
||||
makePrefabInfo({ rootId: 1, fileId: rootFileId, assetId: 0 }),
|
||||
];
|
||||
}
|
||||
|
||||
const meta = {
|
||||
ver: '1.1.50',
|
||||
importer: 'prefab',
|
||||
imported: true,
|
||||
uuid: prefabUuid,
|
||||
files: ['.json'],
|
||||
subMetas: {},
|
||||
userData: { syncNodeName: name },
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
process.stdout.write('=== PREFAB ===\n');
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
process.stdout.write('\n=== META ===\n');
|
||||
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
fs.writeFileSync(outputPath + '.meta', JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
|
||||
process.stdout.write(`created: ${outputPath}\n`);
|
||||
process.stdout.write(`created: ${outputPath}.meta\n`);
|
||||
}
|
||||
|
||||
module.exports = { cmdCreatePrefab };
|
||||
@@ -0,0 +1,60 @@
|
||||
// cli/diff-cmd.js — diff 子命令(比较两个 prefab 的字段级差异)
|
||||
//
|
||||
// 用法:
|
||||
// diff <prefabA> <prefabB>
|
||||
//
|
||||
// 输出与 batch --dry-run 同格式:
|
||||
// { diff: [{ id, type, name, changes: { 'a.b.c': [oldVal, newVal] } }] }
|
||||
//
|
||||
// 适用于:CI 验证转换工具产物 / 对照历史版本 / review 自动 diff
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parsePrefab } = require('../parse.js');
|
||||
const { computeDiff } = require('../editor/diff.js');
|
||||
const { parseFlags } = require('./flags.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolvePath(p) {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
function cmdDiff(args) {
|
||||
const { positional } = parseFlags(args);
|
||||
const [a, b] = positional;
|
||||
if (!a) die('diff: 必须提供 <prefabA>');
|
||||
if (!b) die('diff: 必须提供 <prefabB>');
|
||||
|
||||
const aPath = resolvePath(a);
|
||||
const bPath = resolvePath(b);
|
||||
if (!fs.existsSync(aPath)) die(`diff: 文件不存在: ${aPath}`);
|
||||
if (!fs.existsSync(bPath)) die(`diff: 文件不存在: ${bPath}`);
|
||||
|
||||
let aData;
|
||||
let bData;
|
||||
try {
|
||||
aData = parsePrefab(aPath);
|
||||
bData = parsePrefab(bPath);
|
||||
} catch (e) {
|
||||
die('diff: 解析失败: ' + e.message);
|
||||
}
|
||||
|
||||
const diff = computeDiff(aData.elements, bData.elements);
|
||||
const result = {
|
||||
a: path.relative(process.cwd(), aPath),
|
||||
b: path.relative(process.cwd(), bPath),
|
||||
elementsA: aData.elements.length,
|
||||
elementsB: bData.elements.length,
|
||||
changedCount: diff.length,
|
||||
diff,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
|
||||
module.exports = { cmdDiff };
|
||||
@@ -0,0 +1,99 @@
|
||||
// ============================================================
|
||||
// cli/ensure-meta-cmd.js — ensure-meta 子命令
|
||||
//
|
||||
// 用法:
|
||||
// cocos-mcp-cli ensure-meta <file-path> [--dry-run]
|
||||
//
|
||||
// 若目标文件已有同名 .meta → 跳过(幂等)。
|
||||
// 若没有 .meta → 按扩展名生成对应格式的 .meta 文件。
|
||||
//
|
||||
// 支持的文件类型:
|
||||
// .ts / .js → typescript importer(ver 4.0.24)
|
||||
// .json → json importer(ver 2.0.1)
|
||||
//
|
||||
// 典型场景:新建脚本后 Cocos 编辑器未就绪时,先手动补 .meta。
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function makeMeta(ext, uuid) {
|
||||
if (ext === '.ts' || ext === '.js') {
|
||||
return {
|
||||
ver: '4.0.24',
|
||||
importer: 'typescript',
|
||||
imported: true,
|
||||
uuid,
|
||||
files: [],
|
||||
subMetas: {},
|
||||
userData: {},
|
||||
};
|
||||
}
|
||||
if (ext === '.json') {
|
||||
return {
|
||||
ver: '2.0.1',
|
||||
importer: 'json',
|
||||
imported: true,
|
||||
uuid,
|
||||
files: ['.json'],
|
||||
subMetas: {},
|
||||
userData: {},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cmdEnsureMeta(argv) {
|
||||
let filePath = null;
|
||||
let dryRun = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (!arg.startsWith('--')) {
|
||||
if (filePath !== null) die('多余的位置参数: ' + arg);
|
||||
filePath = arg;
|
||||
} else {
|
||||
die(`未知参数 "${arg}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
die('用法: ensure-meta <file-path> [--dry-run]\n支持类型: .ts .js .json');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
die(`文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const metaPath = filePath + '.meta';
|
||||
if (fs.existsSync(metaPath)) {
|
||||
process.stdout.write(`skip (already exists): ${metaPath}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const meta = makeMeta(ext, randomUUID());
|
||||
if (!meta) {
|
||||
die(`不支持的文件类型 "${ext}",支持: .ts .js .json`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
process.stdout.write(`created: ${metaPath}\n`);
|
||||
}
|
||||
|
||||
module.exports = { cmdEnsureMeta };
|
||||
@@ -0,0 +1,238 @@
|
||||
// ============================================================
|
||||
// cli/extract-cmd.js — extract-prefab 子命令
|
||||
//
|
||||
// 用法:
|
||||
// cocos-mcp-cli extract-prefab <src-prefab> <out-prefab>
|
||||
// --node <selector> [--name <new-name>] [--dry-run]
|
||||
//
|
||||
// 把 src-prefab 中某个子节点连同其整棵子树(含组件 / PrefabInfo /
|
||||
// 嵌套 PrefabInstance / propertyOverrides / TargetInfo / mountedComponents
|
||||
// 等所有 __id__ 引用闭包)提取出来,构造一个独立的新 prefab + .meta。
|
||||
//
|
||||
// 跟 batch op clone-node 的区别:
|
||||
// - clone-node 在同 prefab 内复制 + 挂到 parent
|
||||
// - extract-prefab 写出到新 .prefab 文件(含 cc.Prefab 头),脱离源文件
|
||||
//
|
||||
// 典型场景:把 HomeBottom 上的 btnTask 子树提取成独立的 task BottomEntry.prefab。
|
||||
//
|
||||
// selector 接受 batch 同款三种形式:
|
||||
// "btnTask"
|
||||
// { "id": 13 }
|
||||
// { "path": "btnTask" }
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { deterministicUUID } = require('../id.js');
|
||||
const { parsePrefab } = require('../parse.js');
|
||||
const { resolveNode } = require('../editor/helpers.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseSelector(raw) {
|
||||
// 支持 --node btnTask 或 --node '{"id":13}'
|
||||
const t = raw.trim();
|
||||
if (t.startsWith('{')) {
|
||||
try { return JSON.parse(t); } catch (e) {
|
||||
die(`--node JSON 解析失败: ${t} (${e.message})`);
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function cmdExtractPrefab(argv) {
|
||||
let srcPath = null;
|
||||
let outPath = null;
|
||||
let nodeSelector = null;
|
||||
let newName = null;
|
||||
let dryRun = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--node') {
|
||||
nodeSelector = parseSelector(argv[++i] ?? '');
|
||||
if (!nodeSelector) die('--node 需要一个值');
|
||||
} else if (arg === '--name') {
|
||||
newName = argv[++i];
|
||||
if (!newName) die('--name 需要一个值');
|
||||
} else if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (!arg.startsWith('--')) {
|
||||
if (srcPath === null) srcPath = arg;
|
||||
else if (outPath === null) outPath = arg;
|
||||
else die('多余的位置参数: ' + arg);
|
||||
} else {
|
||||
die(`未知参数 "${arg}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!srcPath || !outPath) {
|
||||
die('用法: extract-prefab <src-prefab> <out-prefab> --node <selector> [--name <new-name>] [--dry-run]');
|
||||
}
|
||||
if (nodeSelector === null) die('--node 是必需参数');
|
||||
|
||||
const srcAbs = path.resolve(process.cwd(), srcPath);
|
||||
if (!fs.existsSync(srcAbs)) die(`源 prefab 不存在: ${srcPath}`);
|
||||
|
||||
// 确保 .prefab 后缀
|
||||
if (!outPath.endsWith('.prefab')) outPath += '.prefab';
|
||||
if (!newName) newName = path.basename(outPath, '.prefab');
|
||||
|
||||
// 1) 解析源 prefab
|
||||
const prefabData = parsePrefab(srcAbs);
|
||||
const { elements } = prefabData;
|
||||
const { nodeId: srcNodeId } = resolveNode(prefabData, nodeSelector, 'extract-prefab');
|
||||
|
||||
// 2) BFS 闭包收集:从 srcNodeId 出发,把所有递归引用的 __id__ 拉进来
|
||||
const collected = new Set();
|
||||
const queue = [srcNodeId];
|
||||
while (queue.length > 0) {
|
||||
const idx = queue.shift();
|
||||
if (collected.has(idx)) continue;
|
||||
collected.add(idx);
|
||||
const refs = [];
|
||||
_walkCollect(elements[idx], refs);
|
||||
for (const r of refs) {
|
||||
if (!collected.has(r)) queue.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 重新编号:new[0] = 新 cc.Prefab 头部,new[1] = srcNode(root),其余按原 idx 升序
|
||||
const oldToNew = new Map();
|
||||
const sortedOld = [srcNodeId, ...[...collected].filter((i) => i !== srcNodeId).sort((a, b) => a - b)];
|
||||
const newData = [];
|
||||
|
||||
// new[0]: 复制源 prefab 头部模板(只用 __type__ / data / optimizationPolicy / persistent 等基础字段)
|
||||
const srcHead = elements[0] && elements[0].__type__ === 'cc.Prefab' ? elements[0] : null;
|
||||
const newHead = {
|
||||
__type__: 'cc.Prefab',
|
||||
_name: '',
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_native: '',
|
||||
data: { __id__: 1 },
|
||||
optimizationPolicy: srcHead && srcHead.optimizationPolicy !== undefined ? srcHead.optimizationPolicy : 0,
|
||||
persistent: srcHead && srcHead.persistent !== undefined ? srcHead.persistent : false,
|
||||
};
|
||||
newData.push(newHead);
|
||||
|
||||
for (let i = 0; i < sortedOld.length; i++) {
|
||||
oldToNew.set(sortedOld[i], i + 1);
|
||||
newData.push(_deepClone(elements[sortedOld[i]]));
|
||||
}
|
||||
|
||||
// 4) Remap __id__ 引用
|
||||
for (let i = 1; i < newData.length; i++) {
|
||||
_remapIds(newData[i], oldToNew);
|
||||
}
|
||||
|
||||
// 5) 修正根节点:_parent=null, _name=newName
|
||||
const newRoot = newData[1];
|
||||
newRoot._parent = null;
|
||||
newRoot._name = newName;
|
||||
|
||||
// 6) 修正根节点 _prefab(PrefabInfo):root 指向新根 idx 1,asset 指向 idx 0
|
||||
if (newRoot._prefab && typeof newRoot._prefab.__id__ === 'number') {
|
||||
const rootPInfo = newData[newRoot._prefab.__id__];
|
||||
if (rootPInfo && rootPInfo.__type__ === 'cc.PrefabInfo') {
|
||||
rootPInfo.root = { __id__: 1 };
|
||||
rootPInfo.asset = { __id__: 0 };
|
||||
// 这些字段在源 prefab 是相对宿主 prefab 的,新 prefab 是独立的,清掉
|
||||
if ('instance' in rootPInfo) rootPInfo.instance = null;
|
||||
if ('targetOverrides' in rootPInfo) rootPInfo.targetOverrides = null;
|
||||
if ('nestedPrefabInstanceRoots' in rootPInfo) rootPInfo.nestedPrefabInstanceRoots = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 生成 meta
|
||||
const seed = `extract-prefab:${outPath}:${newName}`;
|
||||
const newUuid = deterministicUUID(`${seed}:uuid`);
|
||||
const meta = {
|
||||
ver: '1.1.50',
|
||||
importer: 'prefab',
|
||||
imported: true,
|
||||
uuid: newUuid,
|
||||
files: ['.json'],
|
||||
subMetas: {},
|
||||
userData: { syncNodeName: newName },
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
process.stdout.write('=== PREFAB ===\n');
|
||||
process.stdout.write(JSON.stringify(newData, null, 2) + '\n');
|
||||
process.stdout.write('\n=== META ===\n');
|
||||
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
|
||||
process.stdout.write(`\n=== STATS ===\ncollected ${collected.size} objects from source idx ${srcNodeId}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const outAbs = path.resolve(process.cwd(), outPath);
|
||||
const dir = path.dirname(outAbs);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(outAbs, JSON.stringify(newData, null, 2) + '\n', 'utf8');
|
||||
fs.writeFileSync(outAbs + '.meta', JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
|
||||
process.stdout.write(`created: ${outPath} (${collected.size} objects)\n`);
|
||||
process.stdout.write(`created: ${outPath}.meta\n`);
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────
|
||||
|
||||
// 跳过的字段:_parent 反向引用会把父链/兄弟子树拖进闭包,破坏"只提取子树"的语义
|
||||
const SKIP_KEYS = new Set(['_parent']);
|
||||
|
||||
function _walkCollect(obj, out) {
|
||||
if (obj === null || obj === undefined) return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const v of obj) _walkCollect(v, out);
|
||||
return;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
if (typeof obj.__id__ === 'number') {
|
||||
out.push(obj.__id__);
|
||||
return;
|
||||
}
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (SKIP_KEYS.has(k)) continue;
|
||||
_walkCollect(obj[k], out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _deepClone(obj) {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(_deepClone);
|
||||
const out = {};
|
||||
for (const k of Object.keys(obj)) out[k] = _deepClone(obj[k]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function _remapIds(obj, map) {
|
||||
if (obj === null || obj === undefined) return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const v of obj) _remapIds(v, map);
|
||||
return;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
if (typeof obj.__id__ === 'number') {
|
||||
const newId = map.get(obj.__id__);
|
||||
if (newId !== undefined) {
|
||||
obj.__id__ = newId;
|
||||
} else {
|
||||
// 引用集合外的 idx —— 闭包应该完整,理论不会发生
|
||||
obj.__id__ = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const k of Object.keys(obj)) _remapIds(obj[k], map);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { cmdExtractPrefab };
|
||||
@@ -0,0 +1,29 @@
|
||||
// cli/flags.js — argv 中提取 --key value 形式的选项
|
||||
|
||||
'use strict';
|
||||
|
||||
function parseFlags(argv) {
|
||||
const flags = {};
|
||||
const positional = [];
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const arg = argv[i];
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
flags[key] = next;
|
||||
i += 2;
|
||||
} else {
|
||||
flags[key] = true;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
positional.push(arg);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { flags, positional };
|
||||
}
|
||||
|
||||
module.exports = { parseFlags };
|
||||
@@ -0,0 +1,87 @@
|
||||
// cli/help.js — 帮助信息
|
||||
|
||||
'use strict';
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`cocos-mcp-cli — CC3 prefab 离线编辑工具
|
||||
|
||||
Usage:
|
||||
cocos-mcp-cli query <prefab> [--selector tree|node|find|field] [--name X] [--type cc.Label]
|
||||
[--comp cc.UITransform] [--field _anchorPoint] [--with-comps]
|
||||
cocos-mcp-cli set <prefab> <nodeName> <field> <value>
|
||||
cocos-mcp-cli batch <prefab> <ops.json> [--project-root <projectRoot>] [--dry-run]
|
||||
cocos-mcp-cli batch <ops.json> --glob <pattern> [--project-root <path>] [--dry-run]
|
||||
cocos-mcp-cli anim <subcommand> <file> [args] # subcommand: query | batch
|
||||
cocos-mcp-cli diff <prefabA> <prefabB> # 字段级 diff
|
||||
cocos-mcp-cli create-prefab <out> [--name X] [--width W] [--height H] [--add-spine <uuid>]
|
||||
cocos-mcp-cli extract-prefab <src> <out> --node <selector> [--name X] [--dry-run]
|
||||
|
||||
Commands:
|
||||
query 只读查询,输出 JSON
|
||||
set 单条属性写入(field: active / label.text / position.x|y|z)
|
||||
batch 批量写入,ops.json 是 editPrefab ops 数组;--glob 跨多个 prefab
|
||||
anim 操作 .anim 文件(与 prefab 同格式)
|
||||
diff 比较两个 prefab 的字段级差异
|
||||
create-prefab 创建空白 prefab(root + UITransform,可选 sp.Skeleton)
|
||||
extract-prefab 把 src 中的某个子节点闭包提取为独立 prefab(含组件 / PrefabInfo /
|
||||
嵌套 PrefabInstance / overrides 的全部 __id__ 引用)
|
||||
|
||||
Query options:
|
||||
--selector tree 节点树(默认)
|
||||
--selector node 单节点详情,需要 --name <节点名>
|
||||
--selector find 按 __type__ 列 id,需要 --type <类型>
|
||||
--selector field 单组件单字段值,需要 --name --comp --field
|
||||
--with-comps tree/node 下展开组件字段(输出 components: [{type,id,fields}])
|
||||
|
||||
Batch options:
|
||||
--project-root <path> 指定含 assets/+package.json 的项目根;
|
||||
当 prefab 放在 /tmp/ 等非项目目录时必须显式传入,
|
||||
否则 className → classId 自动规范化会抛错(避免写入 className 字符串导致 cocos MissingScript)。
|
||||
--dry-run 不写盘,输出会改的字段 diff({ path: [old, new] } 形式)。
|
||||
|
||||
Supported ops:
|
||||
set-position / set-label-text / set-sprite-frame / set-active / rename-node
|
||||
set-component-field # 普通节点改任意组件任意字段,property 接字符串或嵌套路径数组
|
||||
set-component-enabled # 改 _enabled
|
||||
set-anchor / set-size # cc.UITransform 锚点 / 尺寸便捷写法
|
||||
# set-anchor 支持 compensatePosition 自动补偿 lpos
|
||||
adjust-position # lpos 相对偏移
|
||||
reorder-children # 调子节点顺序(影响渲染层级)
|
||||
bulk-set # 按 selector(byComponent/byNamePrefix/byNameRegex)一次改一批
|
||||
add-node / remove-node / clone-node
|
||||
add-component / set-component-ref # componentType 支持 @ccclass 名或压缩 classId
|
||||
# set-component-ref 的 refSubNode 可用字符串数组走多层嵌套 stub
|
||||
set-nested-component-field # 仅 stub 节点(嵌套 prefab)改组件字段
|
||||
dedupe-component # 合并同节点重复组件
|
||||
ensure-meta # 给新建 .ts/.json 创建 .meta(v4 uuid),
|
||||
# 避免等 cocos 编辑器生成;放在 add-component 前即可
|
||||
# 联动(同 batch 内 cache invalidate 重扫)
|
||||
|
||||
Component shortcuts(组件快捷 op,多字段一次设置):
|
||||
set-editbox node + inputMode?/maxLength?/placeholder?/string?/inputFlag?/fontSize?
|
||||
inputMode: 0=ANY 1=EMAIL 2=NUMERIC 3=PHONE 4=URL 5=DECIMAL 6=SINGLE_LINE
|
||||
set-label node + text?/fontSize?/lineHeight?/overflow?/horizontalAlign?/verticalAlign?/bold?/italic?/underline?/enableWrapText?
|
||||
overflow: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE
|
||||
set-button node + interactable?/transition?/zoomScale?/duration?
|
||||
transition: 0=NONE 1=COLOR 2=SPRITE 3=SCALE
|
||||
set-layout node + type?/resizeMode?/paddingLeft?/paddingRight?/paddingTop?/paddingBottom?/spacingX?/spacingY?/startAxis?/constraint?/constraintNum?/affectedByScale?
|
||||
type: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID
|
||||
set-richtext node + text?/maxWidth?/fontSize?/lineHeight?
|
||||
set-sprite node + sizeMode?/type?/grayscale?/trim?(换图用 set-sprite-frame)
|
||||
type: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH
|
||||
set-node-color node + r?/g?/b?/a?(0-255)
|
||||
|
||||
节点定位三种形式(适用所有 op 的 node/parent/target/source/refNode):
|
||||
"name" / { id: N } / { path: "Canvas/Main/itemList" }
|
||||
|
||||
Examples:
|
||||
cocos-mcp-cli query HomeUI.prefab --selector tree --with-comps
|
||||
cocos-mcp-cli query HomeUI.prefab --selector field --name itemList \\
|
||||
--comp cc.UITransform --field _anchorPoint
|
||||
cocos-mcp-cli set HomeUI.prefab btnClose label.text "关闭"
|
||||
cocos-mcp-cli batch HomeUI.prefab ops.json
|
||||
cocos-mcp-cli batch HomeUI.prefab ops.json --dry-run
|
||||
`);
|
||||
}
|
||||
|
||||
module.exports = { printHelp };
|
||||
@@ -0,0 +1,63 @@
|
||||
// ============================================================
|
||||
// cli/main.js — 子命令分发入口
|
||||
//
|
||||
// 用法:
|
||||
// cocos-mcp-cli query <prefab> [--selector ...] [--with-comps] ...
|
||||
// cocos-mcp-cli set <prefab> <nodeName> <field> <value>
|
||||
// cocos-mcp-cli batch <prefab> <ops.json> [--project-root ...] [--dry-run]
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { printHelp } = require('./help.js');
|
||||
const { cmdQuery } = require('./query-cmd.js');
|
||||
const { cmdSet } = require('./set-cmd.js');
|
||||
const { cmdBatch } = require('./batch-cmd.js');
|
||||
const { cmdAnim } = require('./anim-cmd.js');
|
||||
const { cmdDiff } = require('./diff-cmd.js');
|
||||
const { cmdCreatePrefab } = require('./create-cmd.js');
|
||||
const { cmdExtractPrefab } = require('./extract-cmd.js');
|
||||
const { cmdCompactPrefab } = require('./compact-cmd.js');
|
||||
const { cmdEnsureMeta } = require('./ensure-meta-cmd.js');
|
||||
const { cmdBuild } = require('./build-cmd.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const cmd = argv[0];
|
||||
const rest = argv.slice(1);
|
||||
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'query') {
|
||||
cmdQuery(rest);
|
||||
} else if (cmd === 'set') {
|
||||
cmdSet(rest);
|
||||
} else if (cmd === 'batch') {
|
||||
cmdBatch(rest);
|
||||
} else if (cmd === 'anim') {
|
||||
cmdAnim(rest);
|
||||
} else if (cmd === 'diff') {
|
||||
cmdDiff(rest);
|
||||
} else if (cmd === 'create-prefab') {
|
||||
cmdCreatePrefab(rest);
|
||||
} else if (cmd === 'extract-prefab') {
|
||||
cmdExtractPrefab(rest);
|
||||
} else if (cmd === 'compact-prefab') {
|
||||
cmdCompactPrefab(rest);
|
||||
} else if (cmd === 'ensure-meta') {
|
||||
cmdEnsureMeta(rest);
|
||||
} else if (cmd === 'build') {
|
||||
cmdBuild(rest);
|
||||
} else {
|
||||
die(`未知子命令 "${cmd}",可用: query / set / batch / anim / diff / create-prefab / extract-prefab / compact-prefab / ensure-meta / build`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -0,0 +1,81 @@
|
||||
// cli/query-cmd.js — query 子命令
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { queryPrefab } = require('../query/index.js');
|
||||
const { parseFlags } = require('./flags.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolvePath(p) {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
function cmdQuery(args) {
|
||||
const { flags, positional } = parseFlags(args);
|
||||
|
||||
const prefabArg = positional[0];
|
||||
if (!prefabArg) die('query: 必须提供 <prefab> 路径');
|
||||
|
||||
const prefabPath = resolvePath(prefabArg);
|
||||
if (!fs.existsSync(prefabPath)) die(`query: 文件不存在: ${prefabPath}`);
|
||||
|
||||
const selectorType = flags['selector'] || 'tree';
|
||||
const withComps = flags['with-comps'] === true;
|
||||
let selector;
|
||||
|
||||
if (selectorType === 'tree') {
|
||||
selector = { type: 'tree', withComps };
|
||||
} else if (selectorType === 'node') {
|
||||
const name = flags['name'];
|
||||
if (!name) die('query --selector node: 必须提供 --name <节点名>');
|
||||
selector = { type: 'node', name, withComps };
|
||||
} else if (selectorType === 'find') {
|
||||
const nodeType = flags['type'];
|
||||
if (!nodeType) die('query --selector find: 必须提供 --type <组件类型>');
|
||||
selector = { type: 'find', nodeType };
|
||||
} else if (selectorType === 'field') {
|
||||
const name = flags['name'];
|
||||
const compType = flags['comp'];
|
||||
const field = flags['field'];
|
||||
if (!name) die('query --selector field: 必须提供 --name <节点名>');
|
||||
if (!compType) die('query --selector field: 必须提供 --comp <组件类型>');
|
||||
if (!field) die('query --selector field: 必须提供 --field <字段名>');
|
||||
selector = { type: 'field', name, componentType: compType, field };
|
||||
} else if (selectorType === 'overrides') {
|
||||
// --node 支持 name / --id N / --path A/B/C 三种
|
||||
let nodeSel;
|
||||
if (flags['id'] !== undefined) {
|
||||
const idNum = Number(flags['id']);
|
||||
if (!Number.isInteger(idNum) || idNum < 0) die('query --selector overrides: --id 必须是非负整数');
|
||||
nodeSel = { id: idNum };
|
||||
} else if (typeof flags['path'] === 'string' && flags['path'].length > 0) {
|
||||
nodeSel = { path: flags['path'] };
|
||||
} else if (typeof flags['node'] === 'string' && flags['node'].length > 0) {
|
||||
nodeSel = flags['node'];
|
||||
} else if (typeof flags['name'] === 'string' && flags['name'].length > 0) {
|
||||
nodeSel = flags['name'];
|
||||
} else {
|
||||
die('query --selector overrides: 必须提供 --id N 或 --path A/B/C 或 --name <name>');
|
||||
}
|
||||
selector = { type: 'overrides', node: nodeSel };
|
||||
} else {
|
||||
die(`query: 不支持的 --selector 值 "${selectorType}",可选: tree / node / find / field / overrides`);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = queryPrefab(prefabPath, selector);
|
||||
} catch (e) {
|
||||
die('query 失败: ' + e.message);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
|
||||
module.exports = { cmdQuery };
|
||||
@@ -0,0 +1,79 @@
|
||||
// cli/set-cmd.js — set 子命令(单字段快捷写入)
|
||||
//
|
||||
// field 支持:
|
||||
// active → set-active (true/false)
|
||||
// label.text → set-label-text
|
||||
// position.x|y|z → set-position(只改一轴,其余保留)
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parsePrefab } = require('../parse.js');
|
||||
const { editPrefab } = require('../editor/index.js');
|
||||
const { parseFlags } = require('./flags.js');
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write('Error: ' + msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolvePath(p) {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
function cmdSet(args) {
|
||||
const { positional } = parseFlags(args);
|
||||
|
||||
const [prefabArg, nodeName, field, rawValue] = positional;
|
||||
if (!prefabArg) die('set: 必须提供 <prefab>');
|
||||
if (!nodeName) die('set: 必须提供 <nodeName>');
|
||||
if (!field) die('set: 必须提供 <field>');
|
||||
if (rawValue === undefined) die('set: 必须提供 <value>');
|
||||
|
||||
const prefabPath = resolvePath(prefabArg);
|
||||
if (!fs.existsSync(prefabPath)) die(`set: 文件不存在: ${prefabPath}`);
|
||||
|
||||
let op;
|
||||
|
||||
if (field === 'active') {
|
||||
if (rawValue !== 'true' && rawValue !== 'false') {
|
||||
die('set active: value 必须是 true 或 false');
|
||||
}
|
||||
op = { op: 'set-active', node: nodeName, active: rawValue === 'true' };
|
||||
|
||||
} else if (field === 'label.text') {
|
||||
op = { op: 'set-label-text', node: nodeName, text: rawValue };
|
||||
|
||||
} else if (field === 'position.x' || field === 'position.y' || field === 'position.z') {
|
||||
const axis = field.split('.')[1];
|
||||
const num = parseFloat(rawValue);
|
||||
if (isNaN(num)) die(`set ${field}: value 必须是数字`);
|
||||
const prefabData = parsePrefab(prefabPath);
|
||||
const node = prefabData.findNodeByName(nodeName);
|
||||
if (!node) die(`set: 找不到节点 "${nodeName}"`);
|
||||
const lpos = node._lpos || { x: 0, y: 0, z: 0 };
|
||||
const newPos = { x: lpos.x || 0, y: lpos.y || 0, z: lpos.z || 0 };
|
||||
newPos[axis] = num;
|
||||
op = { op: 'set-position', node: nodeName, x: newPos.x, y: newPos.y, z: newPos.z };
|
||||
|
||||
} else {
|
||||
die(
|
||||
`set: 不支持的 field "${field}",支持:\n` +
|
||||
' active\n' +
|
||||
' label.text\n' +
|
||||
' position.x / position.y / position.z'
|
||||
);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = editPrefab(prefabPath, [op]);
|
||||
} catch (e) {
|
||||
die('set 失败: ' + e.message);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
|
||||
module.exports = { cmdSet };
|
||||
@@ -0,0 +1,59 @@
|
||||
// ============================================================
|
||||
// editor/diff.js — dry-run 用的 elements 字段级 diff
|
||||
// 输出:[{ id, type, name, changes: { 'a.b.c': [old, new] } }]
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
function computeDiff(before, after) {
|
||||
const out = [];
|
||||
const maxLen = Math.max(before.length, after.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = before[i];
|
||||
const b = after[i];
|
||||
if (a === undefined && b !== undefined) {
|
||||
out.push({ id: i, type: 'added', after: b });
|
||||
continue;
|
||||
}
|
||||
if (a !== undefined && b === undefined) {
|
||||
out.push({ id: i, type: 'removed', before: a });
|
||||
continue;
|
||||
}
|
||||
const changes = {};
|
||||
diffObject(a, b, '', changes);
|
||||
if (Object.keys(changes).length > 0) {
|
||||
out.push({
|
||||
id: i,
|
||||
type: b && b.__type__ ? b.__type__ : null,
|
||||
name: b && b._name !== undefined ? b._name : undefined,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function diffObject(a, b, prefix, out) {
|
||||
if (a === b) return;
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
||||
out[prefix || '<root>'] = [a, b];
|
||||
return;
|
||||
}
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const k of keys) {
|
||||
const av = a[k];
|
||||
const bv = b[k];
|
||||
if (av === bv) continue;
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (
|
||||
typeof av === 'object' && av !== null &&
|
||||
typeof bv === 'object' && bv !== null
|
||||
) {
|
||||
diffObject(av, bv, path, out);
|
||||
} else {
|
||||
out[path] = [av, bv];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { computeDiff };
|
||||
@@ -0,0 +1,206 @@
|
||||
// ============================================================
|
||||
// editor/helpers.js — 节点定位 / 组件查找 / 类型规范化
|
||||
// 所有 op handler 共用的低层工具
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isCompressedClassId, compressUuid } = require('../id.js');
|
||||
const { resolveClassIdByName } = require('../classid-resolver.js');
|
||||
|
||||
// ─── componentType 规范化 ────────────────────────────────────
|
||||
//
|
||||
// cli 允许 op 里用以下三种形式传 componentType:
|
||||
// 1. @ccclass 名(如 'MyUI')
|
||||
// 2. 原始 UUID(如 '5a154a84-89a1-509a-8949-96edd6fb74a2')
|
||||
// 3. 压缩 classId(23 字符,已规范化格式,如 '5a154qEiaFQmolJlu3W+3Si')
|
||||
//
|
||||
// 但 Cocos 编辑器序列化 prefab 时会把 __type__ 规范化为压缩 classId。
|
||||
// 为避免「写入字符串名/原始 UUID → 编辑器 reimport 后规范化 + 清空 refs」的坑,
|
||||
// 在每个 op 的 handler 开头把 componentType 统一转成压缩 classId。
|
||||
//
|
||||
// 规则:
|
||||
// - 空/非字符串:原样返回(让 handler 各自报参数错)
|
||||
// - 以 'cc.' / 'sp.' / 'dragonBones.' 开头:引擎类,不可能是 className,原样
|
||||
// - 已经是 23 字符压缩格式:原样
|
||||
// - 原始 UUID 格式(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx):压缩为 classId
|
||||
// - 其他(视作 @ccclass 名):扫 assets/scripts 反查 → 压缩 classId
|
||||
// 找不到时**直接抛错**:cocos 反序列化看到 className 字符串会报 MissingScript,
|
||||
// 与其降级写入留个坑不如让 cli 当场失败,告诉调用方真实原因(meta 未生成 / class 名拼错 / 没加 @ccclass)。
|
||||
//
|
||||
// 这确保 add-component / set-component-ref / remove-component 无论传哪种形式
|
||||
// 都能 lookup 到同一 __type__ 字符串,避免同 batch 内 add+ref 类型不一致。
|
||||
const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function normalizeComponentType(componentType, resolverStartPath) {
|
||||
if (typeof componentType !== 'string' || componentType.length === 0) {
|
||||
return componentType;
|
||||
}
|
||||
if (/^(cc|sp|dragonBones)\./.test(componentType)) return componentType;
|
||||
if (isCompressedClassId(componentType)) return componentType;
|
||||
// 原始 UUID 格式 → 压缩为 classId,与 @ccclass 名查表结果统一
|
||||
if (_UUID_RE.test(componentType)) {
|
||||
try { return compressUuid(componentType); } catch (_) {}
|
||||
}
|
||||
// 视作 @ccclass 名:必须查表得到压缩 classId,否则写入会造成 cocos MissingScript
|
||||
if (!resolverStartPath) {
|
||||
throw new Error(
|
||||
`normalizeComponentType: className "${componentType}" 无法解析——缺少 prefab 路径用于定位项目根。` +
|
||||
`\n 通常因 prefab 在项目目录外(如 /tmp/)时未传 --project-root。`
|
||||
);
|
||||
}
|
||||
const classId = resolveClassIdByName(componentType, resolverStartPath);
|
||||
if (!classId) {
|
||||
throw new Error(
|
||||
`normalizeComponentType: className "${componentType}" 在 assets/scripts 下找不到对应 .ts.meta。` +
|
||||
`\n 常见原因:` +
|
||||
`\n 1) .ts 文件刚新建,cocos 编辑器尚未生成 .ts.meta(等编辑器自动 import 后重跑);` +
|
||||
`\n 2) class 未加 @ccclass('${componentType}') 装饰器;` +
|
||||
`\n 3) @ccclass 参数与 className 拼写不一致。`
|
||||
);
|
||||
}
|
||||
return classId;
|
||||
}
|
||||
|
||||
// ─── 判断节点是否是 stub(嵌套 prefab 根节点)────────────────
|
||||
|
||||
function isStub(elements, node) {
|
||||
if (!node || node.__type__ !== 'cc.Node') return false;
|
||||
const prefabRef = node._prefab;
|
||||
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return false;
|
||||
const prefabInfo = elements[prefabRef.__id__];
|
||||
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return false;
|
||||
const instanceRef = prefabInfo.instance;
|
||||
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return false;
|
||||
const instance = elements[instanceRef.__id__];
|
||||
return !!(instance && instance.__type__ === 'cc.PrefabInstance');
|
||||
}
|
||||
|
||||
// ─── 引用相等查 __id__ ───────────────────────────────────────
|
||||
|
||||
function indexOfNode(elements, node) {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (elements[i] === node) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ─── 节点定位(按名字串 或 {id:N})──────────────────────────
|
||||
|
||||
function resolveNode(prefabData, nodeSelector, opDesc) {
|
||||
const { elements } = prefabData;
|
||||
|
||||
if (typeof nodeSelector === 'string') {
|
||||
const node = prefabData.findNodeByName(nodeSelector);
|
||||
if (!node) {
|
||||
throw new Error(`editPrefab [${opDesc}]: 找不到节点 "${nodeSelector}"`);
|
||||
}
|
||||
const nodeId = indexOfNode(elements, node);
|
||||
if (nodeId < 0) {
|
||||
throw new Error(`editPrefab [${opDesc}]: 节点 "${nodeSelector}" 找到但索引失败(内部错误)`);
|
||||
}
|
||||
return { node, nodeId };
|
||||
}
|
||||
|
||||
if (nodeSelector && typeof nodeSelector === 'object') {
|
||||
if (typeof nodeSelector.id === 'number') {
|
||||
const nodeId = nodeSelector.id;
|
||||
const node = elements[nodeId];
|
||||
if (!node || node.__type__ !== 'cc.Node') {
|
||||
throw new Error(`editPrefab [${opDesc}]: __id__ ${nodeId} 不是有效 cc.Node`);
|
||||
}
|
||||
return { node, nodeId };
|
||||
}
|
||||
if (typeof nodeSelector.path === 'string' && nodeSelector.path.length > 0) {
|
||||
return resolveNodeByPath(prefabData, nodeSelector.path, opDesc);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`editPrefab [${opDesc}]: node 参数必须是字符串名称、{ id: N } 或 { path: 'A/B/C' },收到: ${JSON.stringify(nodeSelector)}`
|
||||
);
|
||||
}
|
||||
|
||||
// 按路径定位节点(DOM-like)
|
||||
// path 形如 "Canvas/Main/itemList",从根节点开始按 _name 逐级下钻
|
||||
// 每段必须命中 _children 中某个节点的 _name
|
||||
// 遇到 stub 节点时不下钻(stub _name 在 propertyOverrides 里,超出 cli 范围)
|
||||
function resolveNodeByPath(prefabData, pathStr, opDesc) {
|
||||
const { elements, rootId, getRoot } = prefabData;
|
||||
const segments = pathStr.split('/').filter((s) => s.length > 0);
|
||||
if (segments.length === 0) {
|
||||
throw new Error(`editPrefab [${opDesc}]: path 段为空`);
|
||||
}
|
||||
|
||||
let curId = rootId;
|
||||
let cur = getRoot();
|
||||
// 第一段对齐根节点名(如 "Canvas"),允许省略
|
||||
if (cur._name === segments[0]) {
|
||||
segments.shift();
|
||||
}
|
||||
|
||||
for (const seg of segments) {
|
||||
if (!Array.isArray(cur._children)) {
|
||||
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在节点 "${cur._name}" 下没有子节点,无法继续下钻到 "${seg}"`);
|
||||
}
|
||||
const matches = [];
|
||||
for (const cref of cur._children) {
|
||||
if (typeof cref.__id__ !== 'number') continue;
|
||||
const child = elements[cref.__id__];
|
||||
if (child && child._name === seg) {
|
||||
matches.push(cref.__id__);
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下找不到子节点 "${seg}"`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
// 同名子节点 path 无法消歧,强制报错而非静默取首个
|
||||
throw new Error(
|
||||
`editPrefab [${opDesc}]: path "${pathStr}" 在 "${cur._name}" 下有 ${matches.length} 个同名子节点 "${seg}"(__id__: ${matches.join(', ')}),` +
|
||||
`path 选择器无法消歧。请改用 {id: N} 精确定位,或对父节点用 path、对该层用 id 组合`
|
||||
);
|
||||
}
|
||||
curId = matches[0];
|
||||
cur = elements[curId];
|
||||
}
|
||||
|
||||
return { node: cur, nodeId: curId };
|
||||
}
|
||||
|
||||
// ─── 找节点上指定类型的组件 ──────────────────────────────────
|
||||
|
||||
function findComponent(elements, node, compType) {
|
||||
if (!Array.isArray(node._components)) return null;
|
||||
for (const compRef of node._components) {
|
||||
if (typeof compRef.__id__ !== 'number') continue;
|
||||
const comp = elements[compRef.__id__];
|
||||
if (comp && comp.__type__ === compType) return comp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 找根节点的 PrefabInfo(持有 nestedPrefabInstanceRoots / targetOverrides)──
|
||||
|
||||
function findRootPrefabInfo(elements, rootNodeId) {
|
||||
// 根节点直接持有其 PrefabInfo 的引用——沿 rootNode._prefab.__id__ 跳一步即可。
|
||||
// 不遍历:prefab 内每个节点都有自己的 PrefabInfo(root/__id__ 均指向根节点),
|
||||
// 迭代会优先命中遇到的第一个非根节点 PrefabInfo,导致 targetOverrides 写错位置。
|
||||
const rootNode = elements[rootNodeId];
|
||||
if (!rootNode || rootNode.__type__ !== 'cc.Node') return null;
|
||||
const prefabRef = rootNode._prefab;
|
||||
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return null;
|
||||
const pi = elements[prefabRef.__id__];
|
||||
if (!pi || pi.__type__ !== 'cc.PrefabInfo') return null;
|
||||
if (pi.instance !== null && pi.instance !== undefined) return null;
|
||||
return pi;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeComponentType,
|
||||
isStub,
|
||||
indexOfNode,
|
||||
resolveNode,
|
||||
findComponent,
|
||||
findRootPrefabInfo,
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
// ============================================================
|
||||
// editor/id-utils.js — fileId 分配 / 子树断开 / __id__ 重映射
|
||||
//
|
||||
// 用于 add-node / clone-node / remove-node / dedupe-component 共用:
|
||||
// - fileId 唯一性(deterministic + 冲突检测)
|
||||
// - 删节点时递归断开 _parent
|
||||
// - 删 elements 后所有 __id__ 引用收缩
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createFileIdGenerator } = require('../id.js');
|
||||
|
||||
// ─── 收集 elements 中所有现有 fileId ─────────────────────────
|
||||
|
||||
/**
|
||||
* 遍历 elements,收集所有 cc.PrefabInfo / cc.CompPrefabInfo / cc.PrefabInstance 的 fileId。
|
||||
* @param {object[]} elements
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function collectExistingFileIds(elements) {
|
||||
const ids = new Set();
|
||||
for (const el of elements) {
|
||||
if (!el) continue;
|
||||
if (
|
||||
(el.__type__ === 'cc.PrefabInfo' ||
|
||||
el.__type__ === 'cc.CompPrefabInfo' ||
|
||||
el.__type__ === 'cc.PrefabInstance') &&
|
||||
typeof el.fileId === 'string' &&
|
||||
el.fileId.length > 0
|
||||
) {
|
||||
ids.add(el.fileId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成不与 existingIds 冲突的 fileId。
|
||||
* 先用 baseSeed 生成,若冲突则追加 #1、#2 … 直到不冲突。
|
||||
* deterministic:相同 baseSeed + 相同现有集合 → 相同结果。
|
||||
*/
|
||||
function uniqueFileId(baseSeed, existingIds) {
|
||||
let candidate = createFileIdGenerator(baseSeed)();
|
||||
if (!existingIds.has(candidate)) {
|
||||
existingIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
let counter = 1;
|
||||
while (true) {
|
||||
candidate = createFileIdGenerator(`${baseSeed}#${counter}`)();
|
||||
if (!existingIds.has(candidate)) {
|
||||
existingIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 断开子树(remove-node 用)────────────────────────────────
|
||||
|
||||
/**
|
||||
* 递归断开子树中所有节点及其关联对象的 _parent 引用(置 null)。
|
||||
* 元素本身保留在数组,只让它们成为真正的孤儿。
|
||||
*/
|
||||
function disconnectSubtree(elements, nodeId) {
|
||||
const node = elements[nodeId];
|
||||
if (!node || node.__type__ !== 'cc.Node') return;
|
||||
|
||||
if (Array.isArray(node._children)) {
|
||||
for (const childRef of node._children) {
|
||||
if (typeof childRef.__id__ === 'number') {
|
||||
disconnectSubtree(elements, childRef.__id__);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node._parent = null;
|
||||
|
||||
if (node._prefab && typeof node._prefab.__id__ === 'number') {
|
||||
const pi = elements[node._prefab.__id__];
|
||||
if (pi && pi.__type__ === 'cc.PrefabInfo') {
|
||||
pi._parent = null;
|
||||
|
||||
if (pi.instance && typeof pi.instance.__id__ === 'number') {
|
||||
const prefabInst = elements[pi.instance.__id__];
|
||||
if (prefabInst && prefabInst.__type__ === 'cc.PrefabInstance') {
|
||||
if (Array.isArray(prefabInst.mountedChildren)) {
|
||||
for (const mcRef of prefabInst.mountedChildren) {
|
||||
if (typeof mcRef.__id__ === 'number') {
|
||||
disconnectSubtree(elements, mcRef.__id__);
|
||||
}
|
||||
}
|
||||
prefabInst.mountedChildren = [];
|
||||
}
|
||||
prefabInst.propertyOverrides = [];
|
||||
if (Array.isArray(prefabInst.mountedComponents)) {
|
||||
prefabInst.mountedComponents = [];
|
||||
}
|
||||
pi.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(node._components)) {
|
||||
for (const compRef of node._components) {
|
||||
if (typeof compRef.__id__ !== 'number') continue;
|
||||
const comp = elements[compRef.__id__];
|
||||
if (!comp) continue;
|
||||
comp._parent = null;
|
||||
if (comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
|
||||
const cpi = elements[comp.__prefab.__id__];
|
||||
if (cpi && cpi.__type__ === 'cc.CompPrefabInfo') {
|
||||
cpi._parent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── elements 重排:__id__ 引用映射 / 收缩 ───────────────────
|
||||
//
|
||||
// 用于 dedupe-component:合并删除组件后,所有 __id__ 指向被删/被合并对象的
|
||||
// 引用要重定向到 keeper 或按缩减后的下标 shift。
|
||||
|
||||
/** @property 字段非 null 计数(粗略打分,挑 keeper) */
|
||||
function countPropertyRefs(comp) {
|
||||
let n = 0;
|
||||
for (const [k, v] of Object.entries(comp)) {
|
||||
if (isReservedCompField(k)) continue;
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'object' || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 合并时不触碰的核心字段 */
|
||||
function isReservedCompField(key) {
|
||||
return (
|
||||
key === '__type__' ||
|
||||
key === '_name' ||
|
||||
key === '_objFlags' ||
|
||||
key === '__editorExtras__' ||
|
||||
key === 'node' ||
|
||||
key === '_enabled' ||
|
||||
key === '__prefab' ||
|
||||
key === '_id'
|
||||
);
|
||||
}
|
||||
|
||||
/** 所有节点的 _components / mountedComponents 去掉指向 deleteSet 的 ref */
|
||||
function filterCompRefsInElements(elements, deleteSet) {
|
||||
for (const el of elements) {
|
||||
if (!el || typeof el !== 'object') continue;
|
||||
if (Array.isArray(el._components)) {
|
||||
el._components = el._components.filter(
|
||||
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(el.mountedComponents)) {
|
||||
el.mountedComponents = el.mountedComponents.filter(
|
||||
(r) => !(r && typeof r.__id__ === 'number' && deleteSet.has(r.__id__))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把所有 __id__ 指向 redirect.keys 的 ref 改成指向 redirect.get(...) */
|
||||
function redirectIdsAcrossElements(elements, redirect) {
|
||||
if (redirect.size === 0) return;
|
||||
const visit = (obj) => {
|
||||
if (obj === null || typeof obj !== 'object') return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const v of obj) visit(v);
|
||||
return;
|
||||
}
|
||||
if (typeof obj.__id__ === 'number' && redirect.has(obj.__id__)) {
|
||||
obj.__id__ = redirect.get(obj.__id__);
|
||||
}
|
||||
for (const k of Object.keys(obj)) visit(obj[k]);
|
||||
};
|
||||
visit(elements);
|
||||
}
|
||||
|
||||
function buildShiftMap(total, deleteSet) {
|
||||
const map = new Array(total);
|
||||
let removed = 0;
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (deleteSet.has(i)) {
|
||||
map[i] = null;
|
||||
removed++;
|
||||
} else {
|
||||
map[i] = i - removed;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function shiftIdsAcrossElements(elements, shiftMap) {
|
||||
const visit = (obj) => {
|
||||
if (obj === null || typeof obj !== 'object') return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const v of obj) visit(v);
|
||||
return;
|
||||
}
|
||||
if (typeof obj.__id__ === 'number') {
|
||||
const nv = shiftMap[obj.__id__];
|
||||
if (nv != null) obj.__id__ = nv;
|
||||
}
|
||||
for (const k of Object.keys(obj)) visit(obj[k]);
|
||||
};
|
||||
visit(elements);
|
||||
}
|
||||
|
||||
// ─── 清理根 PrefabInfo.targetOverrides 中悬空条目 ─────────────
|
||||
//
|
||||
// 从根 PrefabInfo.targetOverrides 移除 source/target 落入 removedIds 的条目。
|
||||
// 被移除的 cc.TargetOverrideInfo / cc.TargetInfo 对象本身保留为孤儿
|
||||
// (软删策略,保持其他 __id__ 稳定)。
|
||||
//
|
||||
// 调用方:
|
||||
// - remove-node:删 stub 后清「外层脚本 → stub 内部组件/节点」的悬空 override
|
||||
// - remove-component:删组件后清「该组件 → 嵌套 stub 内部组件/节点」的悬空 override
|
||||
//
|
||||
// 不传 removedIds 则不做任何事;rootId 必须传(指向根 cc.Node 在 elements 数组中的 __id__)。
|
||||
function cleanupRootTargetOverrides(elements, rootId, removedIds) {
|
||||
if (!removedIds || removedIds.size === 0) return;
|
||||
const rootNode = elements[rootId];
|
||||
if (!rootNode || !rootNode._prefab || typeof rootNode._prefab.__id__ !== 'number') return;
|
||||
const rootPrefabInfo = elements[rootNode._prefab.__id__];
|
||||
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
|
||||
if (!Array.isArray(rootPrefabInfo.targetOverrides)) return;
|
||||
|
||||
rootPrefabInfo.targetOverrides = rootPrefabInfo.targetOverrides.filter((ref) => {
|
||||
if (!ref || typeof ref.__id__ !== 'number') return false;
|
||||
const ov = elements[ref.__id__];
|
||||
if (!ov) return false;
|
||||
const t = ov.target;
|
||||
const s = ov.source;
|
||||
if (t && typeof t.__id__ === 'number' && removedIds.has(t.__id__)) return false;
|
||||
if (s && typeof s.__id__ === 'number' && removedIds.has(s.__id__)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 同步根 PrefabInfo.nestedPrefabInstanceRoots ─────────────
|
||||
//
|
||||
// 重建根节点 PrefabInfo.nestedPrefabInstanceRoots = 当前所有「有父 + 有 PrefabInfo +
|
||||
// PrefabInfo.instance 指向 cc.PrefabInstance」的嵌套 stub 节点 __id__。
|
||||
//
|
||||
// 调用方:
|
||||
// - remove-node:软删 stub 后,被删节点 _parent 已置 null,扫描时自动出局,
|
||||
// 其登记从 nestedPrefabInstanceRoots 剔除。
|
||||
// - sync-nested-roots op:单独修「删了一半」残留的悬空嵌套实例根(父引用已被移除
|
||||
// 但根 PrefabInfo 登记残留 → 残留 asset 仍被当依赖加载)。
|
||||
function syncNestedRoots(elements, rootId) {
|
||||
const rootNode = elements[rootId];
|
||||
if (!rootNode || !rootNode._prefab) return;
|
||||
const rootPrefabInfo = elements[rootNode._prefab.__id__];
|
||||
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
|
||||
const stubIds = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
|
||||
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
|
||||
const pi = elements[el._prefab.__id__];
|
||||
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
|
||||
if (!pi.instance) continue;
|
||||
const inst = elements[pi.instance.__id__];
|
||||
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
|
||||
stubIds.push(i);
|
||||
}
|
||||
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectExistingFileIds,
|
||||
uniqueFileId,
|
||||
disconnectSubtree,
|
||||
countPropertyRefs,
|
||||
isReservedCompField,
|
||||
filterCompRefsInElements,
|
||||
redirectIdsAcrossElements,
|
||||
buildShiftMap,
|
||||
shiftIdsAcrossElements,
|
||||
cleanupRootTargetOverrides,
|
||||
syncNestedRoots,
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
// ============================================================
|
||||
// editor/index.js — 声明式批量编辑 prefab 主入口
|
||||
//
|
||||
// editPrefab(filePath, ops[], options?)
|
||||
// - 内存内依次执行所有 op
|
||||
// - 自动判别 stub vs 普通节点
|
||||
// - 任一 op 失败抛错、不落盘
|
||||
// - dryRun: 跑完不写盘,返回字段级 diff
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { parsePrefab } = require('../parse.js');
|
||||
const { writePrefab } = require('../write.js');
|
||||
const { computeDiff } = require('./diff.js');
|
||||
|
||||
// 各 op handler
|
||||
const { execSetPosition } = require('./ops/set-position.js');
|
||||
const { execSetLabelText } = require('./ops/set-label-text.js');
|
||||
const { execSetSpriteFrame } = require('./ops/set-sprite-frame.js');
|
||||
const { execSetActive } = require('./ops/set-active.js');
|
||||
const { execSetComponentField } = require('./ops/set-component-field.js');
|
||||
const { execSetComponentEnabled } = require('./ops/set-component-enabled.js');
|
||||
const { execSetAnchor } = require('./ops/set-anchor.js');
|
||||
const { execSetSize } = require('./ops/set-size.js');
|
||||
const { execAdjustPosition } = require('./ops/adjust-position.js');
|
||||
const { execRenameNode } = require('./ops/rename-node.js');
|
||||
const { execReparent } = require('./ops/reparent.js');
|
||||
const { execReorderChildren } = require('./ops/reorder-children.js');
|
||||
const { execAddNode } = require('./ops/add-node.js');
|
||||
const { execRemoveNode } = require('./ops/remove-node.js');
|
||||
const { execCloneNode } = require('./ops/clone-node.js');
|
||||
const { execAddComponent } = require('./ops/add-component.js');
|
||||
const { execRemoveComponent } = require('./ops/remove-component.js');
|
||||
const { execSetComponentRef } = require('./ops/set-component-ref.js');
|
||||
const { execSetNestedComponentField } = require('./ops/set-nested-component-field.js');
|
||||
const { execBulkSet } = require('./ops/bulk-set.js');
|
||||
const { execDedupeComponent } = require('./ops/dedupe-component.js');
|
||||
const { execSetEditBox } = require('./ops/set-editbox.js');
|
||||
const { execSetLabel } = require('./ops/set-label.js');
|
||||
const { execSetButton } = require('./ops/set-button.js');
|
||||
const { execSetLayout } = require('./ops/set-layout.js');
|
||||
const { execSetRichText } = require('./ops/set-richtext.js');
|
||||
const { execSetSprite } = require('./ops/set-sprite.js');
|
||||
const { execSetNodeColor } = require('./ops/set-node-color.js');
|
||||
const { execReplaceNestedPrefab } = require('./ops/replace-nested-prefab.js');
|
||||
const { execAddNestedPrefab } = require('./ops/add-nested-prefab.js');
|
||||
const { execResetOverrides } = require('./ops/reset-overrides.js');
|
||||
const { execEnsureMeta } = require('./ops/ensure-meta.js');
|
||||
const { execSyncNestedRoots } = require('./ops/sync-nested-roots.js');
|
||||
const { validateOps } = require('./op-schema.js');
|
||||
|
||||
const OP_HANDLERS = {
|
||||
'set-position': execSetPosition,
|
||||
'set-label-text': execSetLabelText,
|
||||
'set-sprite-frame': execSetSpriteFrame,
|
||||
'set-active': execSetActive,
|
||||
'set-component-field': execSetComponentField,
|
||||
'set-component-enabled': execSetComponentEnabled,
|
||||
'set-anchor': execSetAnchor,
|
||||
'set-size': execSetSize,
|
||||
'adjust-position': execAdjustPosition,
|
||||
'rename-node': execRenameNode,
|
||||
'reparent': execReparent,
|
||||
'reorder-children': execReorderChildren,
|
||||
'add-node': execAddNode,
|
||||
'remove-node': execRemoveNode,
|
||||
'clone-node': execCloneNode,
|
||||
'add-component': execAddComponent,
|
||||
'remove-component': execRemoveComponent,
|
||||
'set-component-ref': execSetComponentRef,
|
||||
'set-nested-component-field': execSetNestedComponentField,
|
||||
'bulk-set': execBulkSet,
|
||||
'dedupe-component': execDedupeComponent,
|
||||
'set-editbox': execSetEditBox,
|
||||
'set-label': execSetLabel,
|
||||
'set-button': execSetButton,
|
||||
'set-layout': execSetLayout,
|
||||
'set-richtext': execSetRichText,
|
||||
'set-sprite': execSetSprite,
|
||||
'set-node-color': execSetNodeColor,
|
||||
'replace-nested-prefab': execReplaceNestedPrefab,
|
||||
'add-nested-prefab': execAddNestedPrefab,
|
||||
'reset-overrides': execResetOverrides,
|
||||
'ensure-meta': execEnsureMeta,
|
||||
'sync-nested-roots': execSyncNestedRoots,
|
||||
};
|
||||
|
||||
/**
|
||||
* 声明式批量编辑 prefab
|
||||
*
|
||||
* @param {string} filePath prefab 文件路径(读取 + 写回同一路径)
|
||||
* @param {object[]} ops op 描述数组
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.projectRoot] 项目根目录(含 assets/),默认从 filePath 向上推断。
|
||||
* @param {boolean} [options.dryRun] true 时不写盘,仅返回模拟结果(含 diff)。
|
||||
* @returns {{ changed: boolean, opsApplied: number, nodesAffected: (string|number)[], dryRun?: boolean, diff?: object[] }}
|
||||
*
|
||||
* @throws 任一 op 失败时抛错,不落盘
|
||||
*/
|
||||
function editPrefab(filePath, ops, options) {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw new Error('editPrefab: filePath 必须是字符串');
|
||||
}
|
||||
if (!Array.isArray(ops) || ops.length === 0) {
|
||||
throw new Error('editPrefab: ops 必须是非空数组');
|
||||
}
|
||||
|
||||
// schema 预校验:跑前发现拼错的字段(comp / ref / 拼漏 op 等),不到 handler 才报错
|
||||
validateOps(ops, Object.keys(OP_HANDLERS));
|
||||
|
||||
const opts = options || {};
|
||||
|
||||
const prefabData = parsePrefab(filePath);
|
||||
prefabData.resolverStartPath = opts.projectRoot || filePath;
|
||||
|
||||
const dryRun = !!opts.dryRun;
|
||||
prefabData.dryRun = dryRun;
|
||||
const beforeSnapshot = dryRun
|
||||
? JSON.parse(JSON.stringify(prefabData.elements))
|
||||
: null;
|
||||
|
||||
const affectedIds = new Set();
|
||||
const affectedNames = [];
|
||||
|
||||
let opsApplied = 0;
|
||||
for (const op of ops) {
|
||||
if (!op || typeof op.op !== 'string') {
|
||||
throw new Error(`editPrefab: op 格式错误(缺少 op 字段): ${JSON.stringify(op)}`);
|
||||
}
|
||||
|
||||
const handler = OP_HANDLERS[op.op];
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`editPrefab: 不支持的 op 类型 "${op.op}",支持: ${Object.keys(OP_HANDLERS).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const nodeId = handler(prefabData, op);
|
||||
if (typeof nodeId === 'number' && nodeId >= 0) {
|
||||
affectedIds.add(nodeId);
|
||||
}
|
||||
// bulk-set 0 匹配时返回 -1,跳过 affectedIds
|
||||
opsApplied++;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
writePrefab(filePath, prefabData.elements, prefabData.raw);
|
||||
}
|
||||
|
||||
for (const id of affectedIds) {
|
||||
const node = prefabData.elements[id];
|
||||
if (node && node._name) {
|
||||
affectedNames.push(node._name);
|
||||
} else {
|
||||
affectedNames.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
changed: !dryRun,
|
||||
opsApplied,
|
||||
nodesAffected: affectedNames,
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
result.dryRun = true;
|
||||
result.diff = computeDiff(beforeSnapshot, prefabData.elements);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { editPrefab, OP_HANDLERS };
|
||||
@@ -0,0 +1,440 @@
|
||||
// ============================================================
|
||||
// editor/nested.js — stub 节点(嵌套 prefab)相关协议
|
||||
//
|
||||
// 涵盖:
|
||||
// - 从嵌套 prefab 反查 CompPrefabInfo.fileId / Node PrefabInfo.fileId
|
||||
// - 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 override
|
||||
// - cc.TargetOverrideInfo 跨 nested @property 挂载
|
||||
// 协议背景见 prefab-schema.md §4 与 set-component-ref op 上方注释。
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { parsePrefab } = require('../parse.js');
|
||||
const { resolveUuidToPath } = require('../uuid-resolver.js');
|
||||
const { findRootPrefabInfo } = require('./helpers.js');
|
||||
|
||||
// ─── 嵌套 prefab:找指定组件的 CompPrefabInfo.fileId ─────────
|
||||
|
||||
/**
|
||||
* @param {string} hostPrefabPath 宿主 prefab 文件路径(用于 UuidResolver 推断项目根)
|
||||
* @param {object[]} elements 宿主 prefab elements 数组
|
||||
* @param {number} stubNodeId stub 节点的 __id__
|
||||
* @param {string} compType 组件类型,如 'cc.Label' / 'cc.Sprite'
|
||||
* @param {string|null} nodeName 可选:指定嵌套 prefab 内的节点名(null = 第一个匹配)
|
||||
* @returns {string} CompPrefabInfo.fileId
|
||||
*/
|
||||
function getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, nodeName) {
|
||||
const stubNode = elements[stubNodeId];
|
||||
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
|
||||
throw new Error(`getNestedCompFileId: ${stubNodeId} 不是有效 cc.Node`);
|
||||
}
|
||||
|
||||
const prefabRef = stubNode._prefab;
|
||||
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
|
||||
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
|
||||
}
|
||||
const prefabInfo = elements[prefabRef.__id__];
|
||||
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
|
||||
throw new Error(`getNestedCompFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
|
||||
}
|
||||
const assetRef = prefabInfo.asset;
|
||||
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
|
||||
throw new Error(
|
||||
`getNestedCompFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
|
||||
);
|
||||
}
|
||||
const nestedUuid = assetRef.__uuid__;
|
||||
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
|
||||
|
||||
let nestedData;
|
||||
try {
|
||||
nestedData = parsePrefab(nestedPath);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`getNestedCompFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}): ${e.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const nEls = nestedData.elements;
|
||||
for (let i = 0; i < nEls.length; i++) {
|
||||
const el = nEls[i];
|
||||
if (!el || el.__type__ !== compType) continue;
|
||||
|
||||
if (nodeName !== null && nodeName !== undefined) {
|
||||
if (!el.node || typeof el.node.__id__ !== 'number') continue;
|
||||
const ownerNode = nEls[el.node.__id__];
|
||||
if (!ownerNode || ownerNode._name !== nodeName) continue;
|
||||
}
|
||||
|
||||
if (!el.__prefab || typeof el.__prefab.__id__ !== 'number') continue;
|
||||
const cpi = nEls[el.__prefab.__id__];
|
||||
if (!cpi || cpi.__type__ !== 'cc.CompPrefabInfo') continue;
|
||||
if (typeof cpi.fileId !== 'string' || cpi.fileId.length === 0) continue;
|
||||
|
||||
return cpi.fileId;
|
||||
}
|
||||
|
||||
const nodeHint = nodeName ? `(节点名: "${nodeName}")` : '';
|
||||
throw new Error(
|
||||
`getNestedCompFileId: 在嵌套 prefab "${nestedPath}" 中找不到 ${compType} 组件${nodeHint},` +
|
||||
`或该组件没有 cc.CompPrefabInfo.fileId。`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 嵌套 prefab:找目标节点的 PrefabInfo.fileId ──────────────
|
||||
|
||||
/**
|
||||
* @param {string} hostPrefabPath 宿主 prefab 路径
|
||||
* @param {object[]} elements 宿主 prefab elements
|
||||
* @param {number} stubNodeId stub 节点 __id__
|
||||
* @param {string|null} nodeName 目标节点名(null = 嵌套 prefab 根节点)
|
||||
* @returns {string} 目标节点 cc.PrefabInfo.fileId
|
||||
*/
|
||||
function getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, nodeName) {
|
||||
const stubNode = elements[stubNodeId];
|
||||
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
|
||||
throw new Error(`getNestedNodeFileId: ${stubNodeId} 不是有效 cc.Node`);
|
||||
}
|
||||
|
||||
const prefabRef = stubNode._prefab;
|
||||
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
|
||||
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
|
||||
}
|
||||
const prefabInfo = elements[prefabRef.__id__];
|
||||
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
|
||||
throw new Error(`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
|
||||
}
|
||||
const assetRef = prefabInfo.asset;
|
||||
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
|
||||
throw new Error(
|
||||
`getNestedNodeFileId: stub 节点 ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`
|
||||
);
|
||||
}
|
||||
const nestedUuid = assetRef.__uuid__;
|
||||
const nestedPath = resolveUuidToPath(nestedUuid, hostPrefabPath);
|
||||
|
||||
let nestedData;
|
||||
try {
|
||||
nestedData = parsePrefab(nestedPath);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`getNestedNodeFileId: 加载嵌套 prefab 失败(uuid=${nestedUuid}, path=${nestedPath}): ${e.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const nEls = nestedData.elements;
|
||||
for (let i = 0; i < nEls.length; i++) {
|
||||
const el = nEls[i];
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
|
||||
const pi = nEls[el._prefab.__id__];
|
||||
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
|
||||
if (typeof pi.fileId !== 'string' || pi.fileId.length === 0) continue;
|
||||
|
||||
if (nodeName === null || nodeName === undefined) {
|
||||
// 根节点:_parent 为 null
|
||||
if (el._parent === null || el._parent === undefined) {
|
||||
return pi.fileId;
|
||||
}
|
||||
} else {
|
||||
if (el._name === nodeName) {
|
||||
return pi.fileId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeHint = nodeName ? `(节点名: "${nodeName}")` : '(根节点)';
|
||||
throw new Error(
|
||||
`getNestedNodeFileId: 在嵌套 prefab "${nestedPath}" 中找不到目标节点${nodeHint},` +
|
||||
`或该节点没有 cc.PrefabInfo.fileId。`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 在 stub 节点的 PrefabInstance.propertyOverrides 写入字段 ─
|
||||
|
||||
/**
|
||||
* 在 stub 节点的 PrefabInstance.propertyOverrides 中写入一条组件属性 override。
|
||||
* TargetInfo.localID 使用 compFileId(嵌套 prefab 内该组件的 CompPrefabInfo.fileId)。
|
||||
*
|
||||
* @param {object} prefabData parsePrefab 返回值
|
||||
* @param {number} stubNodeId stub 节点 __id__
|
||||
* @param {string} compFileId 嵌套 prefab 内目标组件的 CompPrefabInfo.fileId
|
||||
* @param {string[]} propertyPath 属性路径,如 ['_string']
|
||||
* @param {*} value 要写入的值
|
||||
*/
|
||||
function setStubCompOverride(prefabData, stubNodeId, compFileId, propertyPath, value) {
|
||||
const { elements } = prefabData;
|
||||
|
||||
const stubNode = elements[stubNodeId];
|
||||
const prefabInfo = elements[stubNode._prefab.__id__];
|
||||
const prefabInstance = elements[prefabInfo.instance.__id__];
|
||||
|
||||
if (!prefabInstance || prefabInstance.__type__ !== 'cc.PrefabInstance') {
|
||||
throw new Error(`setStubCompOverride: stub ${stubNodeId} 没有有效 PrefabInstance`);
|
||||
}
|
||||
|
||||
if (Array.isArray(prefabInstance.propertyOverrides)) {
|
||||
for (const overrideRef of prefabInstance.propertyOverrides) {
|
||||
if (typeof overrideRef.__id__ !== 'number') continue;
|
||||
const info = elements[overrideRef.__id__];
|
||||
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
|
||||
|
||||
const tiRef = info.targetInfo;
|
||||
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
|
||||
const ti = elements[tiRef.__id__];
|
||||
if (!ti || ti.__type__ !== 'cc.TargetInfo') continue;
|
||||
if (!Array.isArray(ti.localID) || ti.localID[0] !== compFileId) continue;
|
||||
|
||||
if (
|
||||
Array.isArray(info.propertyPath) &&
|
||||
info.propertyPath.length === propertyPath.length &&
|
||||
info.propertyPath.every((p, i) => p === propertyPath[i])
|
||||
) {
|
||||
info.value = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetInfo = {
|
||||
__type__: 'cc.TargetInfo',
|
||||
localID: [compFileId],
|
||||
};
|
||||
const targetInfoId = elements.length;
|
||||
elements.push(targetInfo);
|
||||
|
||||
const overrideInfo = {
|
||||
__type__: 'CCPropertyOverrideInfo',
|
||||
targetInfo: { __id__: targetInfoId },
|
||||
propertyPath: [...propertyPath],
|
||||
value,
|
||||
};
|
||||
const overrideInfoId = elements.length;
|
||||
elements.push(overrideInfo);
|
||||
|
||||
if (!Array.isArray(prefabInstance.propertyOverrides)) {
|
||||
prefabInstance.propertyOverrides = [];
|
||||
}
|
||||
prefabInstance.propertyOverrides.push({ __id__: overrideInfoId });
|
||||
}
|
||||
|
||||
// ─── 跨 nested @property 挂载(cc.TargetOverrideInfo)─────────
|
||||
//
|
||||
// 背景:主 prefab 里 BottomView.prefab 的某个脚本组件(如 BottomView)有
|
||||
// @property _btnStore: cc.Button,btnStore 节点在主 prefab 里是 stub 代理
|
||||
// (PrefabInstance),真正的 cc.Button 组件在子 prefab StoreBtn.prefab 里。
|
||||
// 正确协议:在主 prefab root PrefabInfo.targetOverrides 里写一条
|
||||
// cc.TargetOverrideInfo,target 指向 stub 节点,targetInfo.localID 是子
|
||||
// prefab 里目标组件的 __prefab.fileId。
|
||||
//
|
||||
// localID 为数组支持多层 nested:每过一层 PrefabInstance 边界新开子 map,
|
||||
// 每个元素是该层某节点/组件的 fileId。当前 cli 实现只支持 1 层;多层场景由
|
||||
// 上游 tools/step-3-script/bind-prefab-components 兜底。
|
||||
|
||||
/**
|
||||
* 在子 prefab 里按 compType + subNode 找目标组件 / 节点 fileId,
|
||||
* 返回 localID 数组。支持多层嵌套:
|
||||
*
|
||||
* subNode = null | string → 单层(在子 prefab 根上找 compType)
|
||||
* subNode = ['name1', 'name2'] → 多层(每段是嵌套 stub 节点名,
|
||||
* 最后一段 + compType 决定终点)
|
||||
*
|
||||
* 多层链:path=['A','B'], compType='cc.Label'
|
||||
* = 主 prefab stub → A.prefab 内的 stub 'A' → B.prefab 内的 cc.Label
|
||||
* 返回 [stub-A 在 A.prefab 内的 fileId, B.prefab 内 cc.Label 的 fileId]
|
||||
* 注意每跨一层 PrefabInstance 边界,链 push 一个 fileId。
|
||||
*/
|
||||
function resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode) {
|
||||
// 单层:subNode 为 null 或字符串
|
||||
if (subNode === null || subNode === undefined || typeof subNode === 'string') {
|
||||
if (compType === 'cc.Node') {
|
||||
const nodeFileId = getNestedNodeFileId(hostPrefabPath, elements, stubNodeId, subNode);
|
||||
return [nodeFileId];
|
||||
}
|
||||
const compFileId = getNestedCompFileId(hostPrefabPath, elements, stubNodeId, compType, subNode);
|
||||
return [compFileId];
|
||||
}
|
||||
|
||||
// 多层:subNode 是字符串数组(路径)
|
||||
if (!Array.isArray(subNode) || !subNode.every((s) => typeof s === 'string' && s.length > 0)) {
|
||||
throw new Error(`resolveLocalIdChain: subNode 必须是 null / 字符串 / 字符串数组,收到 ${JSON.stringify(subNode)}`);
|
||||
}
|
||||
if (subNode.length === 0) {
|
||||
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, null);
|
||||
}
|
||||
if (subNode.length === 1) {
|
||||
return resolveLocalIdChain(hostPrefabPath, elements, stubNodeId, compType, subNode[0]);
|
||||
}
|
||||
|
||||
// 多层:从当前 stub 进入第一层嵌套,找名字 = subNode[0] 的内嵌 stub,
|
||||
// 拿到它在嵌套 prefab 内的 fileId,递归走剩下的路径
|
||||
const [firstSeg, ...restPath] = subNode;
|
||||
const { nestedPath, nestedData } = _loadNestedPrefab(hostPrefabPath, elements, stubNodeId);
|
||||
const nEls = nestedData.elements;
|
||||
|
||||
let innerStubId = -1;
|
||||
let innerStubFileId = null;
|
||||
for (let i = 0; i < nEls.length; i++) {
|
||||
const el = nEls[i];
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
if (el._name !== firstSeg) continue;
|
||||
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
|
||||
const innerPi = nEls[el._prefab.__id__];
|
||||
if (!innerPi || innerPi.__type__ !== 'cc.PrefabInfo') continue;
|
||||
if (!innerPi.instance) continue; // 不是 stub
|
||||
if (typeof innerPi.fileId !== 'string' || innerPi.fileId.length === 0) continue;
|
||||
innerStubId = i;
|
||||
innerStubFileId = innerPi.fileId;
|
||||
break;
|
||||
}
|
||||
if (innerStubId < 0) {
|
||||
throw new Error(
|
||||
`resolveLocalIdChain: 嵌套 prefab "${nestedPath}" 中找不到名为 "${firstSeg}" 的 stub 节点`
|
||||
);
|
||||
}
|
||||
|
||||
// 递归到下一层(用嵌套 prefab 自身作为 hostPrefabPath)
|
||||
const innerChain = resolveLocalIdChain(nestedPath, nEls, innerStubId, compType, restPath);
|
||||
return [innerStubFileId, ...innerChain];
|
||||
}
|
||||
|
||||
/** 加载 stub 指向的嵌套 prefab,返回路径 + parsed data */
|
||||
function _loadNestedPrefab(hostPrefabPath, elements, stubNodeId) {
|
||||
const stubNode = elements[stubNodeId];
|
||||
if (!stubNode || stubNode.__type__ !== 'cc.Node') {
|
||||
throw new Error(`_loadNestedPrefab: ${stubNodeId} 不是有效 cc.Node`);
|
||||
}
|
||||
const prefabRef = stubNode._prefab;
|
||||
if (!prefabRef || typeof prefabRef.__id__ !== 'number') {
|
||||
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 没有 _prefab 引用`);
|
||||
}
|
||||
const prefabInfo = elements[prefabRef.__id__];
|
||||
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
|
||||
throw new Error(`_loadNestedPrefab: stub 节点 ${stubNodeId} 的 _prefab 不是 cc.PrefabInfo`);
|
||||
}
|
||||
const assetRef = prefabInfo.asset;
|
||||
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
|
||||
throw new Error(`_loadNestedPrefab: stub ${stubNodeId} 的 PrefabInfo.asset 不是 UUID 引用`);
|
||||
}
|
||||
const nestedPath = resolveUuidToPath(assetRef.__uuid__, hostPrefabPath);
|
||||
const nestedData = parsePrefab(nestedPath);
|
||||
return { nestedPath, nestedData };
|
||||
}
|
||||
|
||||
// ─── propertyPath 数组索引 normalize ─────────────────────────────────────────
|
||||
//
|
||||
// Cocos 编辑器加载 prefab 时按 JSON 类型区分属性名(string)与数组索引(number)。
|
||||
// 若数组索引以 string 形式写入(如 "0" 代替 0),编辑器无法匹配对应数组槽,
|
||||
// TargetOverrideInfo 静默失效(inspector 显示空)。
|
||||
//
|
||||
// 使用方法:
|
||||
// addRootTargetOverride 在写入前调用 normalizePropertyPath,
|
||||
// 保证任何经由字符串解析("_items.0"、"_items[0]")或直接传入的数字 string
|
||||
// 都被转换为 number 类型的数组索引。
|
||||
//
|
||||
// 例:["_items", "0"] → ["_items", 0]
|
||||
// ["_items", 0 ] → ["_items", 0] (已是 number,不变)
|
||||
// ["_role" ] → ["_role" ] (无下标,不变)
|
||||
|
||||
function normalizePropertyPath(path) {
|
||||
return path.map(function(seg) {
|
||||
if (typeof seg === 'string' && /^\d+$/.test(seg)) {
|
||||
return parseInt(seg, 10);
|
||||
}
|
||||
return seg;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 给主 prefab root PrefabInfo.targetOverrides 追加一条 cc.TargetOverrideInfo
|
||||
* + cc.TargetInfo,实现跨 stub @property 挂载。
|
||||
*
|
||||
* @param {(string|number)[]} propertyPath 属性路径数组,普通字段如 ["_role"],
|
||||
* 数组字段元素如 ["_items", 0](索引用数字而非字符串)。
|
||||
* 传入字符串形式的数字索引(如 "0")会被内部自动转为 number,调用方无需预处理。
|
||||
*/
|
||||
function addRootTargetOverride(prefabData, rootId, sourceCompId, propertyPath, targetStubId, localIdChain) {
|
||||
const { elements } = prefabData;
|
||||
const rootPrefabInfo = findRootPrefabInfo(elements, rootId);
|
||||
if (!rootPrefabInfo) {
|
||||
throw new Error(`addRootTargetOverride: 找不到主 prefab root PrefabInfo(rootId=${rootId})`);
|
||||
}
|
||||
|
||||
// 确保数组索引为 number 类型(Cocos 编辑器按类型匹配,string "0" ≠ number 0)
|
||||
const normalizedPath = normalizePropertyPath(propertyPath);
|
||||
|
||||
// 幂等:已存在同 source/propertyPath/target/localID 的 override 直接返回
|
||||
// 注意:dedupe key 使用完整 propertyPath 数组比对,
|
||||
// 允许同一字段名但不同索引(如 ["_items",0] vs ["_items",1])共存。
|
||||
const existingRefs = Array.isArray(rootPrefabInfo.targetOverrides) ? rootPrefabInfo.targetOverrides : [];
|
||||
for (const r of existingRefs) {
|
||||
if (typeof r.__id__ !== 'number') continue;
|
||||
const ov = elements[r.__id__];
|
||||
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
|
||||
if (!ov.source || ov.source.__id__ !== sourceCompId) continue;
|
||||
if (!Array.isArray(ov.propertyPath) || ov.propertyPath.length !== normalizedPath.length) continue;
|
||||
if (!ov.propertyPath.every((p, i) => p === normalizedPath[i])) continue;
|
||||
if (!ov.target || ov.target.__id__ !== targetStubId) continue;
|
||||
const tiRef = ov.targetInfo;
|
||||
if (!tiRef || typeof tiRef.__id__ !== 'number') continue;
|
||||
const ti = elements[tiRef.__id__];
|
||||
if (!ti || !Array.isArray(ti.localID)) continue;
|
||||
if (ti.localID.length !== localIdChain.length) continue;
|
||||
if (ti.localID.every((v, i) => v === localIdChain[i])) return;
|
||||
}
|
||||
|
||||
const targetInfoId = elements.length;
|
||||
elements.push({
|
||||
__type__: 'cc.TargetInfo',
|
||||
localID: localIdChain.slice(),
|
||||
});
|
||||
const overrideId = elements.length;
|
||||
elements.push({
|
||||
__type__: 'cc.TargetOverrideInfo',
|
||||
source: { __id__: sourceCompId },
|
||||
sourceInfo: null,
|
||||
propertyPath: normalizedPath.slice(),
|
||||
target: { __id__: targetStubId },
|
||||
targetInfo: { __id__: targetInfoId },
|
||||
});
|
||||
if (!Array.isArray(rootPrefabInfo.targetOverrides)) {
|
||||
rootPrefabInfo.targetOverrides = [];
|
||||
}
|
||||
// 插入策略:
|
||||
// - 单字段 override(propertyPath.length === 1,如 ["_btnClose"]):插到所有
|
||||
// 数组字段 override 之前。
|
||||
// - 数组字段 override(propertyPath.length > 1,如 ["_items", 0]):追加到末尾。
|
||||
//
|
||||
// 为什么:Cocos 加载 prefab 时,若 rootTargetOverrides 数组里前面有数组字段
|
||||
// override,后面位置的单字段 override 会被静默跳过(实测 cocos 3.8.x 行为,
|
||||
// 见 forest/extensions/cc-3-8-x-mcp/doc/cli.md 坑 14)。单字段插前面规避此 bug。
|
||||
const newRef = { __id__: overrideId };
|
||||
const isSingleField = normalizedPath.length === 1;
|
||||
if (isSingleField) {
|
||||
const arr = rootPrefabInfo.targetOverrides;
|
||||
let firstArrayIdx = arr.length;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const r = arr[i];
|
||||
if (!r || typeof r.__id__ !== 'number') continue;
|
||||
const ov = elements[r.__id__];
|
||||
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
|
||||
if (Array.isArray(ov.propertyPath) && ov.propertyPath.length > 1) {
|
||||
firstArrayIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
arr.splice(firstArrayIdx, 0, newRef);
|
||||
} else {
|
||||
rootPrefabInfo.targetOverrides.push(newRef);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNestedCompFileId,
|
||||
getNestedNodeFileId,
|
||||
setStubCompOverride,
|
||||
resolveLocalIdChain,
|
||||
addRootTargetOverride,
|
||||
normalizePropertyPath,
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
// ============================================================
|
||||
// editor/op-schema.js — ops 跑前 schema 校验
|
||||
//
|
||||
// 价值:
|
||||
// - 字段拼错(comp / ref / propery)一次性报齐,不用一条条 op 跑到才发现
|
||||
// - 未知 op 类型 / 必填字段缺失,跑前就报,避免部分写入后回滚浪费时间
|
||||
// - 字段类型错(`width: "100"`)跑前就报,避免运行时崩
|
||||
//
|
||||
// 校验粒度:必填字段名 + 已知字段拼写白名单 + 字段类型;
|
||||
// 业务约束(值域、互斥)留给 handler(更易给出场景化错误信息)
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
// 类型令牌:
|
||||
// 'number' | 'string' | 'boolean' | 'object' | 'array' | 'any'
|
||||
// 'node-selector' — 字符串 / 数字 / { id, path } 三选一
|
||||
// 'string|array' — property 类支持嵌套路径数组
|
||||
// 'string|object' — refSubNode 支持字符串或字符串数组(这里只做粗校验)
|
||||
//
|
||||
// 'any' 不做类型断言(覆盖 value / props 一类 raw JSON)。
|
||||
const T = {
|
||||
node: 'node-selector',
|
||||
parent: 'node-selector',
|
||||
target: 'node-selector',
|
||||
source: 'node-selector',
|
||||
refNode: 'node-selector',
|
||||
componentType: 'string',
|
||||
property: 'string|array',
|
||||
refType: 'string',
|
||||
refSubNode: 'any', // string | string[]
|
||||
value: 'any',
|
||||
props: 'object',
|
||||
selector: 'object',
|
||||
order: 'array',
|
||||
text: 'string',
|
||||
name: 'string',
|
||||
uuid: 'string',
|
||||
prefabUuid: 'string',
|
||||
active: 'boolean',
|
||||
enabled: 'boolean',
|
||||
compensatePosition: 'boolean',
|
||||
clearOverrides: 'boolean',
|
||||
bold: 'boolean',
|
||||
italic: 'boolean',
|
||||
underline: 'boolean',
|
||||
enableWrapText: 'boolean',
|
||||
grayscale: 'boolean',
|
||||
trim: 'boolean',
|
||||
affectedByScale: 'boolean',
|
||||
interactable: 'boolean',
|
||||
all: 'boolean',
|
||||
x: 'number',
|
||||
y: 'number',
|
||||
z: 'number',
|
||||
dx: 'number',
|
||||
dy: 'number',
|
||||
dz: 'number',
|
||||
width: 'number',
|
||||
height: 'number',
|
||||
r: 'number',
|
||||
g: 'number',
|
||||
b: 'number',
|
||||
a: 'number',
|
||||
fontSize: 'number',
|
||||
lineHeight: 'number',
|
||||
maxWidth: 'number',
|
||||
maxLength: 'number',
|
||||
inputMode: 'number',
|
||||
inputFlag: 'number',
|
||||
zoomScale: 'number',
|
||||
duration: 'number',
|
||||
type: 'number',
|
||||
sizeMode: 'number',
|
||||
overflow: 'number',
|
||||
horizontalAlign: 'number',
|
||||
verticalAlign: 'number',
|
||||
transition: 'number',
|
||||
resizeMode: 'number',
|
||||
paddingLeft: 'number',
|
||||
paddingRight: 'number',
|
||||
paddingTop: 'number',
|
||||
paddingBottom: 'number',
|
||||
spacingX: 'number',
|
||||
spacingY: 'number',
|
||||
startAxis: 'number',
|
||||
constraint: 'number',
|
||||
constraintNum: 'number',
|
||||
placeholder: 'string',
|
||||
string: 'string',
|
||||
labelNode: 'string',
|
||||
spriteNode: 'string',
|
||||
subNode: 'any', // string | string[]
|
||||
};
|
||||
|
||||
// 每个 op 的字段白名单(含必填 + 可选;'op' 隐含必填)
|
||||
// typeOverrides:对全局 T 表的字段类型做局部覆盖(同名字段在不同 op 里语义不同时用)
|
||||
const SCHEMAS = {
|
||||
'set-position': { required: ['node', 'x', 'y'], optional: ['z'] },
|
||||
'set-label-text': { required: ['node', 'text'], optional: ['labelNode'] },
|
||||
'set-sprite-frame': { required: ['node', 'uuid'], optional: ['spriteNode'] },
|
||||
'set-active': { required: ['node', 'active'], optional: [] },
|
||||
'set-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: [] },
|
||||
'set-component-enabled': { required: ['node', 'componentType', 'enabled'], optional: ['subNode'] },
|
||||
'set-anchor': { required: ['node'], optional: ['x', 'y', 'compensatePosition'] },
|
||||
'set-size': { required: ['node'], optional: ['width', 'height'] },
|
||||
'adjust-position': { required: ['node'], optional: ['dx', 'dy', 'dz'] },
|
||||
'rename-node': { required: ['node', 'name'], optional: [] },
|
||||
// reparent: 把节点搬到另一个父节点下(不复制;普通 inline 节点;自带循环检测)
|
||||
'reparent': { required: ['node', 'parent'], optional: ['index'] },
|
||||
'reorder-children': { required: ['node', 'order'], optional: [] },
|
||||
// add-node 的 node 是「新节点描述对象」而非 selector
|
||||
'add-node': { required: ['parent', 'node'], optional: [], typeOverrides: { node: 'object' } },
|
||||
'remove-node': { required: ['target'], optional: [] },
|
||||
'clone-node': { required: ['source', 'parent', 'name'], optional: [] },
|
||||
'add-component': { required: ['node', 'componentType'], optional: ['props'] },
|
||||
'remove-component': { required: ['node', 'componentType'], optional: [] },
|
||||
'set-component-ref': { required: ['node', 'componentType', 'property', 'refNode'], optional: ['refType', 'refSubNode'] },
|
||||
'set-nested-component-field': { required: ['node', 'componentType', 'property', 'value'], optional: ['subNode'] },
|
||||
// bulk-set 的 target 是 "node" 或 "component:<type>" 字符串模式,不是 selector
|
||||
'bulk-set': { required: ['selector', 'target', 'property', 'value'], optional: [], typeOverrides: { target: 'string' } },
|
||||
'dedupe-component': { required: [], optional: ['node'] },
|
||||
'set-editbox': { required: ['node'], optional: ['inputMode', 'maxLength', 'placeholder', 'string', 'inputFlag', 'fontSize'] },
|
||||
'set-label': { required: ['node'], optional: ['text', 'fontSize', 'lineHeight', 'overflow', 'horizontalAlign', 'verticalAlign', 'bold', 'italic', 'underline', 'enableWrapText'] },
|
||||
'set-button': { required: ['node'], optional: ['interactable', 'transition', 'zoomScale', 'duration'] },
|
||||
'set-layout': { required: ['node'], optional: ['type', 'resizeMode', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'spacingX', 'spacingY', 'startAxis', 'constraint', 'constraintNum', 'affectedByScale'] },
|
||||
'set-richtext': { required: ['node'], optional: ['text', 'maxWidth', 'fontSize', 'lineHeight'] },
|
||||
'set-sprite': { required: ['node'], optional: ['sizeMode', 'type', 'grayscale', 'trim'] },
|
||||
'set-node-color': { required: ['node'], optional: ['r', 'g', 'b', 'a'] },
|
||||
'replace-nested-prefab': { required: ['target', 'prefabUuid'], optional: ['clearOverrides'] },
|
||||
'add-nested-prefab': { required: ['parent', 'prefabUuid'], optional: ['name', 'lpos'] },
|
||||
'reset-overrides': { required: ['node'], optional: ['property', 'componentType', 'subNode', 'all'] },
|
||||
// ensure-meta: 给 .ts/.json 文件创建 .meta(v4 uuid),让后续 className → classId 查表能命中
|
||||
'ensure-meta': { required: ['path'], optional: [], typeOverrides: { path: 'string' } },
|
||||
'sync-nested-roots': { required: [], optional: [] },
|
||||
};
|
||||
|
||||
// 已知拼错 → 正确字段映射(友好提示)
|
||||
const COMMON_TYPOS = {
|
||||
'comp': 'componentType',
|
||||
'compType': 'componentType',
|
||||
'ref': 'refNode',
|
||||
'propery': 'property',
|
||||
'val': 'value',
|
||||
'newName': 'name',
|
||||
'nodeName': 'node',
|
||||
};
|
||||
|
||||
function _formatType(token) {
|
||||
switch (token) {
|
||||
case 'node-selector': return '字符串/数字/{id}/{path}';
|
||||
case 'string|array': return '字符串或数组';
|
||||
default: return token;
|
||||
}
|
||||
}
|
||||
|
||||
function _checkType(value, token) {
|
||||
if (token === 'any') return true;
|
||||
if (token === 'node-selector') {
|
||||
if (typeof value === 'string') return value.length > 0;
|
||||
if (typeof value === 'number') return Number.isInteger(value) && value >= 0;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return typeof value.id === 'number' || typeof value.path === 'string';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (token === 'string|array') {
|
||||
return typeof value === 'string' || Array.isArray(value);
|
||||
}
|
||||
if (token === 'array') return Array.isArray(value);
|
||||
if (token === 'object') {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
return typeof value === token; // number / string / boolean
|
||||
}
|
||||
|
||||
function validateOps(ops, knownOpTypes) {
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
const op = ops[i];
|
||||
const prefix = `ops[${i}]`;
|
||||
|
||||
if (!op || typeof op !== 'object' || Array.isArray(op)) {
|
||||
errors.push(`${prefix}: 不是对象`);
|
||||
continue;
|
||||
}
|
||||
if (typeof op.op !== 'string') {
|
||||
errors.push(`${prefix}: 缺 'op' 字段`);
|
||||
continue;
|
||||
}
|
||||
if (!knownOpTypes.includes(op.op)) {
|
||||
errors.push(`${prefix}: 不支持的 op 类型 "${op.op}",已知: ${knownOpTypes.join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const schema = SCHEMAS[op.op];
|
||||
if (!schema) continue; // 没登记 schema 的 op 跳过
|
||||
|
||||
const known = new Set(['op', ...schema.required, ...schema.optional]);
|
||||
|
||||
// 必填检查
|
||||
for (const r of schema.required) {
|
||||
if (!(r in op)) {
|
||||
errors.push(`${prefix} (${op.op}): 缺必填字段 "${r}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 多余字段 + 类型检查
|
||||
for (const k of Object.keys(op)) {
|
||||
if (k === 'op') continue;
|
||||
if (!known.has(k)) {
|
||||
const suggest = COMMON_TYPOS[k];
|
||||
if (suggest && known.has(suggest)) {
|
||||
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",可能想写 "${suggest}"`);
|
||||
} else {
|
||||
errors.push(`${prefix} (${op.op}): 未知字段 "${k}",已知: ${[...known].join(', ')}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 类型检查(only 在 T 中登记的字段;未登记的留给 handler)
|
||||
// op 的 typeOverrides 优先级高于全局 T
|
||||
const token = (schema.typeOverrides && schema.typeOverrides[k]) || T[k];
|
||||
if (!token) continue;
|
||||
if (!_checkType(op[k], token)) {
|
||||
const got = Array.isArray(op[k]) ? 'array' : (op[k] === null ? 'null' : typeof op[k]);
|
||||
errors.push(
|
||||
`${prefix} (${op.op}): 字段 "${k}" 类型应为 ${_formatType(token)},实际是 ${got}(值: ${JSON.stringify(op[k])})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`editPrefab: ops schema 校验失败:\n ${errors.join('\n ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateOps, SCHEMAS };
|
||||
@@ -0,0 +1,75 @@
|
||||
// add-component: 在节点 _components 数组里加一个指向指定 ccclass 的组件条目
|
||||
// + 配套的 cc.CompPrefabInfo(含 deterministic fileId)
|
||||
// op: { op: 'add-component', node, componentType, props? }
|
||||
//
|
||||
// - componentType: 组件 ccclass 名(如 'TaskBtn' / 'cc.Sprite')
|
||||
// - props: 可选,初始 @property 字段值,会浅合并到组件对象上
|
||||
//
|
||||
// 限制:
|
||||
// - stub 节点暂不支持
|
||||
// - 同节点同类型组件已存在时抛错
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ref, makeCompPrefabInfo } = require('../../primitives.js');
|
||||
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
|
||||
|
||||
function execAddComponent(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, componentType: rawComponentType, props } = op;
|
||||
|
||||
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
|
||||
throw new Error(`editPrefab [add-component]: componentType 必须是非空字符串`);
|
||||
}
|
||||
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'add-component');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [add-component]: stub 节点挂自定义组件暂未实现(需 PrefabInstance.mountedComponents)`);
|
||||
}
|
||||
|
||||
if (findComponent(elements, node, componentType)) {
|
||||
throw new Error(`editPrefab [add-component]: 节点 "${node._name}" 已挂 "${componentType}" 组件`);
|
||||
}
|
||||
|
||||
let seed = 'unknown';
|
||||
if (node._prefab && typeof node._prefab.__id__ === 'number') {
|
||||
const pi = elements[node._prefab.__id__];
|
||||
if (pi && pi.fileId) seed = pi.fileId;
|
||||
}
|
||||
const existingFileIds = collectExistingFileIds(elements);
|
||||
const compFileId = uniqueFileId(`${seed}#addComp#${componentType}`, existingFileIds);
|
||||
|
||||
const compId = elements.length;
|
||||
const cpiId = compId + 1;
|
||||
|
||||
const compObj = Object.assign(
|
||||
{
|
||||
__type__: componentType,
|
||||
_name: '',
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: ref(nodeId),
|
||||
_enabled: true,
|
||||
__prefab: ref(cpiId),
|
||||
_id: '',
|
||||
},
|
||||
props && typeof props === 'object' ? props : {}
|
||||
);
|
||||
|
||||
const cpiObj = makeCompPrefabInfo(compFileId);
|
||||
|
||||
elements.push(compObj);
|
||||
elements.push(cpiObj);
|
||||
|
||||
if (!Array.isArray(node._components)) {
|
||||
node._components = [];
|
||||
}
|
||||
node._components.push(ref(compId));
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execAddComponent };
|
||||
@@ -0,0 +1,149 @@
|
||||
// add-nested-prefab: 在指定父节点下嵌入一个外部 prefab 实例(stub)。
|
||||
//
|
||||
// 等效于在 Cocos 编辑器把某个 prefab 文件拖入当前 prefab 树。生成三个对象:
|
||||
// - 一个 stub cc.Node(_name/_active 留空,由子 prefab 默认或 override 决定)
|
||||
// - 一个 cc.PrefabInfo(asset.__uuid__ = prefabUuid,instance 指向 PrefabInstance)
|
||||
// - 一个 cc.PrefabInstance(prefabRootNode 指向外层 prefab 根 = rootId)
|
||||
//
|
||||
// 可选 name / lpos 通过 propertyOverrides 写到 PrefabInstance 上(targetInfo.localID
|
||||
// 用子 prefab 内根节点的 PrefabInfo.fileId,需读外部 prefab 文件解析)。
|
||||
//
|
||||
// op: { op: 'add-nested-prefab', parent: string|{id:N}, prefabUuid: string, name?: string, lpos?: [x,y,z] }
|
||||
//
|
||||
// 协议背景:参 doc/nested-prefab-protocol.md;与 replace-nested-prefab 互补
|
||||
// (replace 替换 asset uuid 不动节点结构,add 是从零生成嵌套实例)。
|
||||
|
||||
'use strict';
|
||||
|
||||
const { resolveNode } = require('../helpers.js');
|
||||
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
|
||||
|
||||
function execAddNestedPrefab(prefabData, op) {
|
||||
const { elements, rootId } = prefabData;
|
||||
const { parent: parentSelector, prefabUuid, name, lpos } = op;
|
||||
|
||||
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
|
||||
throw new Error(`editPrefab [add-nested-prefab]: prefabUuid 必须是非空字符串`);
|
||||
}
|
||||
const cleanUuid = prefabUuid.trim();
|
||||
|
||||
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-nested-prefab');
|
||||
|
||||
// 父 prefab fileId 作 deterministic 种子
|
||||
let parentFileId = 'unknown';
|
||||
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
|
||||
const parentPi = elements[parentNode._prefab.__id__];
|
||||
if (parentPi && parentPi.fileId) parentFileId = parentPi.fileId;
|
||||
}
|
||||
const existingFileIds = collectExistingFileIds(elements);
|
||||
const baseSeed = `${parentFileId}#addNested#${cleanUuid}#${name ?? ''}`;
|
||||
const stubFileId = uniqueFileId(baseSeed, existingFileIds);
|
||||
const instanceFileId = uniqueFileId(`${baseSeed}#instance`, existingFileIds);
|
||||
|
||||
// 分配 id:stubNode → prefabInfo → prefabInstance → [TargetInfo + OverrideInfo] × N
|
||||
const stubNodeId = elements.length;
|
||||
const prefabInfoId = stubNodeId + 1;
|
||||
const instanceId = stubNodeId + 2;
|
||||
let nextId = instanceId + 1;
|
||||
|
||||
const propertyOverrideRefs = [];
|
||||
const overrideElements = [];
|
||||
|
||||
// PropertyOverride 的 targetInfo.localID 用 stub 自己在外层 prefab 内的 PrefabInfo.fileId
|
||||
// (而不是子 prefab 内根节点 fileId)。CC3 协议:targetInfo 定位 override 应用的「目标对象」,
|
||||
// 对于 stub Node 自己的 _name/_lpos 这类字段,目标对象就是 stub 在外层 prefab 内的标识。
|
||||
function pushOverride(propertyPath, value) {
|
||||
const tiId = nextId++;
|
||||
const oiId = nextId++;
|
||||
overrideElements.push({
|
||||
__type__: 'cc.TargetInfo',
|
||||
localID: [stubFileId],
|
||||
});
|
||||
overrideElements.push({
|
||||
__type__: 'CCPropertyOverrideInfo',
|
||||
targetInfo: { __id__: tiId },
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
propertyOverrideRefs.push({ __id__: oiId });
|
||||
}
|
||||
|
||||
if (name !== undefined) pushOverride(['_name'], name);
|
||||
if (lpos !== undefined) {
|
||||
pushOverride(['_lpos'], {
|
||||
__type__: 'cc.Vec3',
|
||||
x: lpos[0] || 0,
|
||||
y: lpos[1] || 0,
|
||||
z: lpos[2] || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const stubNode = {
|
||||
__type__: 'cc.Node',
|
||||
_objFlags: 0,
|
||||
_parent: { __id__: parentId },
|
||||
_prefab: { __id__: prefabInfoId },
|
||||
__editorExtras__: {},
|
||||
};
|
||||
|
||||
const stubPrefabInfo = {
|
||||
__type__: 'cc.PrefabInfo',
|
||||
root: { __id__: stubNodeId },
|
||||
asset: { __uuid__: cleanUuid, __expectedType__: 'cc.Prefab' },
|
||||
fileId: stubFileId,
|
||||
instance: { __id__: instanceId },
|
||||
targetOverrides: null,
|
||||
};
|
||||
|
||||
const prefabInstance = {
|
||||
__type__: 'cc.PrefabInstance',
|
||||
fileId: instanceFileId,
|
||||
prefabRootNode: { __id__: rootId },
|
||||
mountedChildren: [],
|
||||
mountedComponents: [],
|
||||
propertyOverrides: propertyOverrideRefs,
|
||||
removedComponents: [],
|
||||
};
|
||||
|
||||
elements.push(stubNode);
|
||||
elements.push(stubPrefabInfo);
|
||||
elements.push(prefabInstance);
|
||||
for (const o of overrideElements) elements.push(o);
|
||||
|
||||
if (!Array.isArray(parentNode._children)) parentNode._children = [];
|
||||
parentNode._children.push({ __id__: stubNodeId });
|
||||
|
||||
// 同步外层 prefab 根 PrefabInfo.nestedPrefabInstanceRoots(cocos 加载嵌套实例的入口列表)。
|
||||
// 缺这一步运行时 stub 节点不会被解析渲染,子 prefab 内容看不到。
|
||||
syncNestedRoots(elements, rootId);
|
||||
|
||||
return stubNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建外层 prefab 根 PrefabInfo.nestedPrefabInstanceRoots,包含所有 _parent 非 null 的活 stub 节点。
|
||||
* 软删(remove-node)留下的孤儿 stub 自动排除。
|
||||
*/
|
||||
function syncNestedRoots(elements, rootId) {
|
||||
const rootNode = elements[rootId];
|
||||
if (!rootNode || !rootNode._prefab) return;
|
||||
const rootPrefabInfo = elements[rootNode._prefab.__id__];
|
||||
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') return;
|
||||
|
||||
const stubIds = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
if (!el._parent || typeof el._parent.__id__ !== 'number') continue;
|
||||
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
|
||||
const pi = elements[el._prefab.__id__];
|
||||
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
|
||||
if (!pi.instance) continue;
|
||||
const inst = elements[pi.instance.__id__];
|
||||
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
|
||||
stubIds.push(i);
|
||||
}
|
||||
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.map((id) => ({ __id__: id }));
|
||||
}
|
||||
|
||||
module.exports = { execAddNestedPrefab };
|
||||
@@ -0,0 +1,120 @@
|
||||
// add-node: 在指定父节点下新增一个 cc.Node
|
||||
// op: { op: 'add-node', parent: string|{id:N}, node: { name, lpos?, components? } }
|
||||
//
|
||||
// 支持:
|
||||
// - 普通父节点:新节点进入 parent._children
|
||||
// - stub 父节点(嵌套 prefab 实例):新节点进入 PrefabInstance.mountedChildren
|
||||
// 若 node.components 包含 'UITransform',自动创建 cc.UITransform(默认 100×100)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ref, makeNode, makePrefabInfo, makeCompPrefabInfo, makeUITransform } = require('../../primitives.js');
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
|
||||
|
||||
const SUPPORTED_COMPONENTS = ['UITransform'];
|
||||
|
||||
function execAddNode(prefabData, op) {
|
||||
const { elements, rootId } = prefabData;
|
||||
const { parent: parentSelector, node: nodeSpec } = op;
|
||||
|
||||
if (!nodeSpec || typeof nodeSpec.name !== 'string') {
|
||||
throw new Error(`editPrefab [add-node]: node.name 必须是字符串`);
|
||||
}
|
||||
|
||||
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'add-node');
|
||||
|
||||
if (Array.isArray(nodeSpec.components)) {
|
||||
for (const comp of nodeSpec.components) {
|
||||
if (typeof comp === 'string' && !SUPPORTED_COMPONENTS.includes(comp)) {
|
||||
throw new Error(
|
||||
`editPrefab [add-node]: unknown component type: ${comp}(已支持: ${SUPPORTED_COMPONENTS.join(', ')})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newNodeId = elements.length;
|
||||
|
||||
// 父节点 fileId 用作 deterministic 种子
|
||||
let parentFileId = 'unknown';
|
||||
if (parentNode._prefab && typeof parentNode._prefab.__id__ === 'number') {
|
||||
const parentPrefabInfo = elements[parentNode._prefab.__id__];
|
||||
if (parentPrefabInfo && parentPrefabInfo.fileId) {
|
||||
parentFileId = parentPrefabInfo.fileId;
|
||||
}
|
||||
}
|
||||
const baseSeed = `${parentFileId}#addNode#${nodeSpec.name}`;
|
||||
|
||||
const existingFileIds = collectExistingFileIds(elements);
|
||||
const nodeFileId = uniqueFileId(baseSeed, existingFileIds);
|
||||
const uitFileId = uniqueFileId(`${baseSeed}#uit`, existingFileIds);
|
||||
|
||||
const prefabInfoId = newNodeId + 1;
|
||||
let componentIds = [];
|
||||
const newObjects = [];
|
||||
|
||||
if (Array.isArray(nodeSpec.components) && nodeSpec.components.includes('UITransform')) {
|
||||
const uitId = newNodeId + 2;
|
||||
const uitPrefabInfoId = newNodeId + 3;
|
||||
componentIds = [uitId];
|
||||
|
||||
const uitObj = makeUITransform({
|
||||
nodeId: newNodeId,
|
||||
width: nodeSpec.width || 100,
|
||||
height: nodeSpec.height || 100,
|
||||
anchor: nodeSpec.anchor || [0.5, 0.5],
|
||||
prefabInfoId: uitPrefabInfoId,
|
||||
});
|
||||
const uitCpi = makeCompPrefabInfo(uitFileId);
|
||||
|
||||
newObjects.push(uitObj);
|
||||
newObjects.push(uitCpi);
|
||||
}
|
||||
|
||||
const lpos = nodeSpec.lpos || [0, 0, 0];
|
||||
const newNodeObj = makeNode({
|
||||
name: nodeSpec.name,
|
||||
pos: lpos,
|
||||
active: nodeSpec.active !== undefined ? nodeSpec.active : true,
|
||||
parentId,
|
||||
childIds: [],
|
||||
componentIds,
|
||||
prefabId: prefabInfoId,
|
||||
});
|
||||
|
||||
const newPrefabInfoObj = makePrefabInfo({
|
||||
rootId,
|
||||
fileId: nodeFileId,
|
||||
assetId: 0,
|
||||
nestedPrefabInstanceRoots: null,
|
||||
});
|
||||
|
||||
elements.push(newNodeObj);
|
||||
elements.push(newPrefabInfoObj);
|
||||
for (const o of newObjects) elements.push(o);
|
||||
|
||||
if (isStub(elements, parentNode)) {
|
||||
const prefabRef = parentNode._prefab;
|
||||
const parentPrefabInfo = elements[prefabRef.__id__];
|
||||
const instanceRef = parentPrefabInfo.instance;
|
||||
const prefabInstance = elements[instanceRef.__id__];
|
||||
if (!Array.isArray(prefabInstance.mountedChildren)) {
|
||||
prefabInstance.mountedChildren = [];
|
||||
}
|
||||
prefabInstance.mountedChildren.push({ __id__: newNodeId });
|
||||
} else {
|
||||
if (!Array.isArray(parentNode._children)) {
|
||||
parentNode._children = [];
|
||||
}
|
||||
parentNode._children.push({ __id__: newNodeId });
|
||||
}
|
||||
|
||||
// ref 在此模块虽然没直接用,但保留 import 以便上层调试时一致;
|
||||
// 实际节点对象的子引用全在 makeNode/makePrefabInfo/makeUITransform 内部生成。
|
||||
void ref;
|
||||
|
||||
return newNodeId;
|
||||
}
|
||||
|
||||
module.exports = { execAddNode };
|
||||
@@ -0,0 +1,41 @@
|
||||
// adjust-position: lpos 相对偏移
|
||||
// op: { op:'adjust-position', node, dx?, dy?, dz? }
|
||||
//
|
||||
// 适合"在原位置基础上挪 N 像素"场景,免去先 query 取原值。
|
||||
// 任一轴缺省视为 0。stub 节点走 setOverrideProperty,与 set-position 一致。
|
||||
|
||||
'use strict';
|
||||
|
||||
const { setOverrideProperty } = require('../../overrides.js');
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
|
||||
function execAdjustPosition(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, dx = 0, dy = 0, dz = 0 } = op;
|
||||
|
||||
if (typeof dx !== 'number' || typeof dy !== 'number' || typeof dz !== 'number') {
|
||||
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 必须是数字`);
|
||||
}
|
||||
if (dx === 0 && dy === 0 && dz === 0) {
|
||||
throw new Error(`editPrefab [adjust-position]: dx/dy/dz 至少一个非零`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'adjust-position');
|
||||
const lpos = node._lpos || { x: 0, y: 0, z: 0 };
|
||||
const newLpos = {
|
||||
__type__: 'cc.Vec3',
|
||||
x: (lpos.x || 0) + dx,
|
||||
y: (lpos.y || 0) + dy,
|
||||
z: (lpos.z || 0) + dz,
|
||||
};
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
|
||||
} else {
|
||||
node._lpos = newLpos;
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execAdjustPosition };
|
||||
@@ -0,0 +1,112 @@
|
||||
// bulk-set: 按 selector 找一批节点,统一改字段(一条 op 顶 N 条)
|
||||
// op: { op:'bulk-set', selector, target, property, value }
|
||||
//
|
||||
// selector:节点筛选条件
|
||||
// { byComponent: 'cc.Label' } → 所有挂 cc.Label 的节点
|
||||
// { byNamePrefix: 'btn' } → 所有 _name 以 'btn' 开头的节点
|
||||
// { byNameRegex: '^icon_\\d+$' } → 正则匹配
|
||||
// 多条件并存为 AND
|
||||
//
|
||||
// target:要改的对象层
|
||||
// 'node' → 改节点字段,如 _active / _name
|
||||
// 'component:<T>' → 改节点上 type=T 的组件字段(每个匹配节点都得有这个组件,否则跳过)
|
||||
//
|
||||
// property:字符串或字符串数组(嵌套路径)
|
||||
// value:写入值
|
||||
//
|
||||
// 行为:
|
||||
// - 匹配 0 个不算错(返回 [] 但 opsApplied 仍 +1)
|
||||
// - stub 节点跳过(bulk-set 不处理 stub,避免不同代码路径混用)
|
||||
// - 返回所有受影响的 nodeId 数组(editPrefab 主循环会聚合到 affectedNodes)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, findComponent } = require('../helpers.js');
|
||||
|
||||
function _matchSelector(elements, node, selector) {
|
||||
if (selector.byComponent) {
|
||||
if (!findComponent(elements, node, selector.byComponent)) return false;
|
||||
}
|
||||
if (selector.byNamePrefix) {
|
||||
if (typeof node._name !== 'string' || !node._name.startsWith(selector.byNamePrefix)) return false;
|
||||
}
|
||||
if (selector.byNameRegex) {
|
||||
if (typeof node._name !== 'string') return false;
|
||||
const re = new RegExp(selector.byNameRegex);
|
||||
if (!re.test(node._name)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _setNested(obj, path, value) {
|
||||
if (typeof path === 'string') {
|
||||
obj[path] = value;
|
||||
return;
|
||||
}
|
||||
let cur = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const k = path[i];
|
||||
if (cur[k] === null || cur[k] === undefined || typeof cur[k] !== 'object') {
|
||||
throw new Error(
|
||||
`bulk-set: 路径 ${path.slice(0, i + 1).join('.')} 不是对象(${JSON.stringify(cur[k])}),无法继续下钻`
|
||||
);
|
||||
}
|
||||
cur = cur[k];
|
||||
}
|
||||
cur[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
function execBulkSet(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { selector, target, property, value } = op;
|
||||
|
||||
if (!selector || typeof selector !== 'object' || Object.keys(selector).length === 0) {
|
||||
throw new Error(`editPrefab [bulk-set]: selector 必须是非空对象`);
|
||||
}
|
||||
if (typeof target !== 'string' || target.length === 0) {
|
||||
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>'`);
|
||||
}
|
||||
if (
|
||||
!(typeof property === 'string' && property.length > 0) &&
|
||||
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string'))
|
||||
) {
|
||||
throw new Error(`editPrefab [bulk-set]: property 必须是非空字符串或字符串数组`);
|
||||
}
|
||||
if (value === undefined) {
|
||||
throw new Error(`editPrefab [bulk-set]: value 不能是 undefined`);
|
||||
}
|
||||
|
||||
let targetKind = target;
|
||||
let targetCompType = null;
|
||||
if (target.startsWith('component:')) {
|
||||
targetCompType = target.slice('component:'.length);
|
||||
if (targetCompType.length === 0) {
|
||||
throw new Error(`editPrefab [bulk-set]: target='component:' 后必须跟组件类型`);
|
||||
}
|
||||
targetKind = 'component';
|
||||
} else if (target !== 'node') {
|
||||
throw new Error(`editPrefab [bulk-set]: target 必须是 'node' 或 'component:<Type>',收到 "${target}"`);
|
||||
}
|
||||
|
||||
const affected = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
if (isStub(elements, el)) continue;
|
||||
if (!_matchSelector(elements, el, selector)) continue;
|
||||
|
||||
if (targetKind === 'node') {
|
||||
_setNested(el, property, value);
|
||||
} else {
|
||||
const comp = findComponent(elements, el, targetCompType);
|
||||
if (!comp) continue; // 节点匹配但没这个组件,跳过
|
||||
_setNested(comp, property, value);
|
||||
}
|
||||
affected.push(i);
|
||||
}
|
||||
|
||||
// 至少返回一个 id 让 affectedNodes 不报错(即使 0 匹配也不算 op fail)
|
||||
return affected.length > 0 ? affected[0] : -1;
|
||||
}
|
||||
|
||||
module.exports = { execBulkSet };
|
||||
@@ -0,0 +1,150 @@
|
||||
// clone-node: 深拷贝 source 及其整棵子树,挂到 parent 下
|
||||
// op: { op: 'clone-node', source: string|{id:N}, parent: string|{id:N}, name: string }
|
||||
//
|
||||
// - 为每个新节点/组件分配新 __id__(push 到数组末尾)
|
||||
// - 为每个新节点和组件生成新 fileId(deterministic,种子基于 source fileId + newName)
|
||||
// - 更新所有内部 _parent 引用指向新副本
|
||||
// - 新树挂到 parent._children(若 parent 是 stub 则走 mountedChildren)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
const { collectExistingFileIds, uniqueFileId } = require('../id-utils.js');
|
||||
|
||||
function execCloneNode(prefabData, op) {
|
||||
const { elements, rootId } = prefabData;
|
||||
const { source: sourceSelector, parent: parentSelector, name: newName } = op;
|
||||
|
||||
if (typeof newName !== 'string') {
|
||||
throw new Error(`editPrefab [clone-node]: name 必须是字符串`);
|
||||
}
|
||||
|
||||
const { node: sourceNode, nodeId: sourceId } = resolveNode(prefabData, sourceSelector, 'clone-node');
|
||||
const { node: parentNode, nodeId: parentId } = resolveNode(prefabData, parentSelector, 'clone-node');
|
||||
|
||||
const oldToNew = new Map();
|
||||
|
||||
function collectSubtreeNodeIds(nodeId) {
|
||||
const ids = [nodeId];
|
||||
const node = elements[nodeId];
|
||||
if (node && Array.isArray(node._children)) {
|
||||
for (const childRef of node._children) {
|
||||
if (typeof childRef.__id__ === 'number') {
|
||||
ids.push(...collectSubtreeNodeIds(childRef.__id__));
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
const subtreeNodeIds = collectSubtreeNodeIds(sourceId);
|
||||
|
||||
const allSourceIds = [];
|
||||
for (const nid of subtreeNodeIds) {
|
||||
allSourceIds.push(nid);
|
||||
const n = elements[nid];
|
||||
if (!n) continue;
|
||||
if (n._prefab && typeof n._prefab.__id__ === 'number') {
|
||||
allSourceIds.push(n._prefab.__id__);
|
||||
}
|
||||
if (Array.isArray(n._components)) {
|
||||
for (const cRef of n._components) {
|
||||
if (typeof cRef.__id__ === 'number') {
|
||||
const compId = cRef.__id__;
|
||||
allSourceIds.push(compId);
|
||||
const comp = elements[compId];
|
||||
if (comp && comp.__prefab && typeof comp.__prefab.__id__ === 'number') {
|
||||
allSourceIds.push(comp.__prefab.__id__);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSourceIds = [...new Set(allSourceIds)];
|
||||
|
||||
const insertStart = elements.length;
|
||||
for (let i = 0; i < uniqueSourceIds.length; i++) {
|
||||
oldToNew.set(uniqueSourceIds[i], insertStart + i);
|
||||
elements.push(null);
|
||||
}
|
||||
|
||||
let sourceFileId = 'unknown';
|
||||
if (sourceNode._prefab && typeof sourceNode._prefab.__id__ === 'number') {
|
||||
const srcPInfo = elements[sourceNode._prefab.__id__];
|
||||
if (srcPInfo && srcPInfo.fileId) sourceFileId = srcPInfo.fileId;
|
||||
}
|
||||
const cloneBaseSeed = `${sourceFileId}#clone#${newName}`;
|
||||
const cloneExistingFileIds = collectExistingFileIds(elements);
|
||||
let cloneGenCounter = 0;
|
||||
function cloneGen() {
|
||||
const subSeed = `${cloneBaseSeed}#slot${cloneGenCounter++}`;
|
||||
return uniqueFileId(subSeed, cloneExistingFileIds);
|
||||
}
|
||||
|
||||
function cloneObj(obj) {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(cloneObj);
|
||||
if (typeof obj.__id__ === 'number') {
|
||||
const newId = oldToNew.get(obj.__id__);
|
||||
if (newId !== undefined) return { __id__: newId };
|
||||
return { ...obj };
|
||||
}
|
||||
const result = {};
|
||||
for (const k of Object.keys(obj)) {
|
||||
result[k] = cloneObj(obj[k]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const oldId of uniqueSourceIds) {
|
||||
const newId = oldToNew.get(oldId);
|
||||
const srcObj = elements[oldId];
|
||||
if (!srcObj) {
|
||||
elements[newId] = null;
|
||||
continue;
|
||||
}
|
||||
const cloned = cloneObj(srcObj);
|
||||
|
||||
if (cloned.__type__ === 'cc.PrefabInfo') {
|
||||
cloned.fileId = cloneGen();
|
||||
cloned.root = { __id__: rootId };
|
||||
cloned.asset = { __id__: 0 };
|
||||
cloned.instance = null;
|
||||
cloned.targetOverrides = null;
|
||||
cloned.nestedPrefabInstanceRoots = null;
|
||||
}
|
||||
if (cloned.__type__ === 'cc.CompPrefabInfo') {
|
||||
cloned.fileId = cloneGen();
|
||||
}
|
||||
|
||||
elements[newId] = cloned;
|
||||
}
|
||||
|
||||
const newRootId = oldToNew.get(sourceId);
|
||||
const newRootNode = elements[newRootId];
|
||||
|
||||
newRootNode._name = newName;
|
||||
newRootNode._parent = { __id__: parentId };
|
||||
|
||||
if (isStub(elements, parentNode)) {
|
||||
const prefabRef = parentNode._prefab;
|
||||
const parentPrefabInfo = elements[prefabRef.__id__];
|
||||
const instanceRef = parentPrefabInfo.instance;
|
||||
const prefabInstance = elements[instanceRef.__id__];
|
||||
if (!Array.isArray(prefabInstance.mountedChildren)) {
|
||||
prefabInstance.mountedChildren = [];
|
||||
}
|
||||
prefabInstance.mountedChildren.push({ __id__: newRootId });
|
||||
} else {
|
||||
if (!Array.isArray(parentNode._children)) {
|
||||
parentNode._children = [];
|
||||
}
|
||||
parentNode._children.push({ __id__: newRootId });
|
||||
}
|
||||
|
||||
return newRootId;
|
||||
}
|
||||
|
||||
module.exports = { execCloneNode };
|
||||
@@ -0,0 +1,132 @@
|
||||
// dedupe-component: 合并同节点上同语义但重复挂载的组件条目
|
||||
//
|
||||
// 背景:cli 若用 className 写入 __type__(如 "GMUI"),而 Cocos 编辑器 reimport
|
||||
// 时会把 __type__ 规范化为压缩 classId(如 "a57b6RRA21B5I70mCpu1pBP"),
|
||||
// 在 TS 脚本尚未注册时 @property refs 会被丢弃,造成同节点出现「字符串版 +
|
||||
// 压缩版」两份组件,其中一份 refs 完整、另一份全 null。本 op 把它们合并成一条。
|
||||
//
|
||||
// op: { op: 'dedupe-component', node? }
|
||||
// - node: 仅扫指定节点;缺省 → 扫整个 prefab 所有普通节点
|
||||
//
|
||||
// 策略:
|
||||
// 1. 按 normalizeComponentType() 后的 compType 分组
|
||||
// 2. 同 compType >=2 命中时,选非空 @property 字段最多的作为 keeper
|
||||
// 3. 把 losers 的非空字段合并进 keeper(keeper 为 null/undefined 才填)
|
||||
// 4. keeper.__type__ 写成规范化后的 compType
|
||||
// 5. losers 的 comp idx 和 __prefab CompPrefabInfo idx 进入删除集
|
||||
// 6. _components 数组过滤被删的引用
|
||||
// 7. 其他 elements 里 __id__ 指向被删组件的引用映射到 keeper 新 id
|
||||
// 8. 全部 __id__ 按缩减后的索引重映射 + splice 实际删除
|
||||
// 限制:stub 节点暂不处理。
|
||||
|
||||
'use strict';
|
||||
|
||||
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
|
||||
const {
|
||||
countPropertyRefs,
|
||||
isReservedCompField,
|
||||
filterCompRefsInElements,
|
||||
redirectIdsAcrossElements,
|
||||
buildShiftMap,
|
||||
shiftIdsAcrossElements,
|
||||
} = require('../id-utils.js');
|
||||
|
||||
function execDedupeComponent(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector } = op || {};
|
||||
|
||||
// ── 1. 决定扫哪些节点
|
||||
const targets = [];
|
||||
if (nodeSelector == null) {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (el && el.__type__ === 'cc.Node') targets.push({ node: el, nodeId: i });
|
||||
}
|
||||
} else {
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'dedupe-component');
|
||||
targets.push({ node, nodeId });
|
||||
}
|
||||
|
||||
// ── 2. 逐节点分组,找到所有要合并的 group
|
||||
const merges = [];
|
||||
const affectedNodes = new Set();
|
||||
|
||||
for (const { node, nodeId } of targets) {
|
||||
if (isStub(elements, node)) continue;
|
||||
if (!Array.isArray(node._components)) continue;
|
||||
|
||||
const groups = new Map();
|
||||
for (const cref of node._components) {
|
||||
if (!cref || typeof cref.__id__ !== 'number') continue;
|
||||
const comp = elements[cref.__id__];
|
||||
if (!comp || typeof comp.__type__ !== 'string') continue;
|
||||
const normalized = normalizeComponentType(comp.__type__, prefabData.resolverStartPath);
|
||||
if (!groups.has(normalized)) groups.set(normalized, []);
|
||||
groups.get(normalized).push({ compId: cref.__id__, comp });
|
||||
}
|
||||
|
||||
for (const [normalized, list] of groups.entries()) {
|
||||
if (list.length < 2) continue;
|
||||
const scored = list.map((x) => ({ ...x, score: countPropertyRefs(x.comp) }));
|
||||
scored.sort((a, b) => b.score - a.score || a.compId - b.compId);
|
||||
const keeper = scored[0];
|
||||
const losers = scored.slice(1);
|
||||
|
||||
for (const loser of losers) {
|
||||
for (const [k, v] of Object.entries(loser.comp)) {
|
||||
if (isReservedCompField(k)) continue;
|
||||
if ((keeper.comp[k] === null || keeper.comp[k] === undefined) && v !== null && v !== undefined) {
|
||||
keeper.comp[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
keeper.comp.__type__ = normalized;
|
||||
|
||||
merges.push({
|
||||
keeperCompId: keeper.compId,
|
||||
loserCompIds: losers.map((x) => x.compId),
|
||||
normalizedType: normalized,
|
||||
nodeId,
|
||||
});
|
||||
affectedNodes.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (merges.length === 0) return [];
|
||||
|
||||
// ── 3. 收集要删除的 elements id 与「loser→keeper」重定向
|
||||
const deleteSet = new Set();
|
||||
const redirect = new Map();
|
||||
for (const m of merges) {
|
||||
for (const loserId of m.loserCompIds) {
|
||||
deleteSet.add(loserId);
|
||||
redirect.set(loserId, m.keeperCompId);
|
||||
const loserComp = elements[loserId];
|
||||
const pref = loserComp && loserComp.__prefab;
|
||||
if (pref && typeof pref.__id__ === 'number') {
|
||||
deleteSet.add(pref.__id__);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. 先把所有节点的 _components / mountedComponents 数组过滤掉被删的引用
|
||||
filterCompRefsInElements(elements, deleteSet);
|
||||
|
||||
// ── 5. __id__ 重定向(loser → keeper)
|
||||
redirectIdsAcrossElements(elements, redirect);
|
||||
|
||||
// ── 6. 构建 shift 映射 + 执行删除
|
||||
const shiftMap = buildShiftMap(elements.length, deleteSet);
|
||||
shiftIdsAcrossElements(elements, shiftMap);
|
||||
const sortedToDel = Array.from(deleteSet).sort((a, b) => b - a);
|
||||
for (const idx of sortedToDel) elements.splice(idx, 1);
|
||||
|
||||
// ── 7. 更新 prefabData.rootId
|
||||
if (typeof prefabData.rootId === 'number' && shiftMap[prefabData.rootId] != null) {
|
||||
prefabData.rootId = shiftMap[prefabData.rootId];
|
||||
}
|
||||
|
||||
return Array.from(affectedNodes).map((id) => shiftMap[id] ?? id);
|
||||
}
|
||||
|
||||
module.exports = { execDedupeComponent };
|
||||
@@ -0,0 +1,109 @@
|
||||
// ensure-meta: 给指定 .ts / .json 文件创建 .meta(如果不存在)
|
||||
// op: { op:'ensure-meta', path }
|
||||
//
|
||||
// 用途:新建 .ts / .ctrl.json 后 cocos 编辑器尚未生成 .meta,但 cli 后续要用
|
||||
// className → classId 查表(add-component 等)。这时在 add-component 之前插一条
|
||||
// ensure-meta,主动写一个标准 .meta(v4 uuid + 按扩展名选模板),让 cli 当场能查到表,
|
||||
// 而不必等 cocos 编辑器异步 import。
|
||||
//
|
||||
// 路径规则:path 是绝对路径,或相对项目根(如 'assets/scripts/.../X.ts')。
|
||||
// 已存在 .meta 时幂等不动。
|
||||
// dry-run 时不写盘(让 --dry-run 语义一致)。
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { clearCache } = require('../../classid-resolver.js');
|
||||
|
||||
function _v4Uuid() {
|
||||
const b = crypto.randomBytes(16);
|
||||
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
|
||||
const h = b.toString('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
const _META_TEMPLATES = {
|
||||
'.ts': (uuid) => ({
|
||||
ver: '4.0.24',
|
||||
importer: 'typescript',
|
||||
imported: true,
|
||||
uuid,
|
||||
files: [],
|
||||
subMetas: {},
|
||||
userData: { simulateGlobals: [] },
|
||||
}),
|
||||
'.json': (uuid) => ({
|
||||
ver: '2.0.1',
|
||||
importer: 'json',
|
||||
imported: true,
|
||||
uuid,
|
||||
files: ['.json'],
|
||||
subMetas: {},
|
||||
userData: {},
|
||||
}),
|
||||
};
|
||||
|
||||
function _resolveProjectRoot(startPath) {
|
||||
let dir = fs.statSync(startPath).isDirectory() ? startPath : path.dirname(startPath);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json')) && fs.existsSync(path.join(dir, 'assets'))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function execEnsureMeta(prefabData, op) {
|
||||
if (typeof op.path !== 'string' || op.path.length === 0) {
|
||||
throw new Error("ensure-meta: 缺必填字段 'path'");
|
||||
}
|
||||
|
||||
let filePath = op.path;
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
const projectRoot = _resolveProjectRoot(prefabData.resolverStartPath);
|
||||
if (!projectRoot) {
|
||||
throw new Error(
|
||||
`ensure-meta: 无法定位项目根(含 assets/+package.json),请用绝对 path`
|
||||
);
|
||||
}
|
||||
filePath = path.resolve(projectRoot, op.path);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`ensure-meta: 文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const metaPath = filePath + '.meta';
|
||||
if (fs.existsSync(metaPath)) {
|
||||
// 幂等:已存在不动
|
||||
return -1;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const template = _META_TEMPLATES[ext];
|
||||
if (!template) {
|
||||
throw new Error(
|
||||
`ensure-meta: 不支持的文件扩展名 "${ext}"(当前支持: ${Object.keys(_META_TEMPLATES).join(' / ')})`
|
||||
);
|
||||
}
|
||||
|
||||
if (prefabData.dryRun) {
|
||||
// dry-run 模式不落盘
|
||||
return -1;
|
||||
}
|
||||
|
||||
const meta = template(_v4Uuid());
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
// 同 batch 后续 op(如 add-component)会调 resolveClassIdByName 查表,
|
||||
// resolver 有进程内 cache,必须 invalidate 让下次重扫覆盖新建的 meta
|
||||
clearCache();
|
||||
return -1;
|
||||
}
|
||||
|
||||
module.exports = { execEnsureMeta };
|
||||
@@ -0,0 +1,78 @@
|
||||
// remove-component: 从普通节点 `_components` 数组移除指定组件的引用,
|
||||
// 组件元素本身保留为 orphan(保持其他 __id__ 稳定,与 remove-node 同策略)。
|
||||
// 关联的 cc.CompPrefabInfo 也随之 orphan(它只被 component._ _prefab 引用)。
|
||||
//
|
||||
// 同步清根 PrefabInfo.targetOverrides 中 source 指向被删组件的悬空条目:
|
||||
// 外层脚本通过 targetOverride 把嵌套 stub 内部组件/节点挂到自己 @property 字段时,
|
||||
// 删组件后这些 override 仍被根 PrefabInfo 引用 → 可达悬空引用 → cocos 解析时
|
||||
// 反序列化 source.__id__ 触发 missing-class 报错。
|
||||
//
|
||||
// op: { op: 'remove-component', node, componentType }
|
||||
//
|
||||
// 不支持 stub 节点:嵌套 prefab 的组件由子 prefab 拥有,外层无法删除,
|
||||
// 只能 set-component-enabled 禁用。stub 上调用本 op 会抛错。
|
||||
|
||||
'use strict';
|
||||
|
||||
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
|
||||
const { cleanupRootTargetOverrides } = require('../id-utils.js');
|
||||
|
||||
function execRemoveComponent(prefabData, op) {
|
||||
const { elements, rootId } = prefabData;
|
||||
const { node: nodeSelector, componentType: rawCompType } = op;
|
||||
|
||||
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
|
||||
throw new Error(`editPrefab [remove-component]: componentType 必须是非空字符串`);
|
||||
}
|
||||
|
||||
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'remove-component');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(
|
||||
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 是 stub(嵌套 prefab 根),无法删除其内部组件;改用 set-component-enabled 禁用`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(node._components) || node._components.length === 0) {
|
||||
throw new Error(
|
||||
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 没有 _components 数组`
|
||||
);
|
||||
}
|
||||
|
||||
let matchedCompId = -1;
|
||||
const next = [];
|
||||
for (const ref of node._components) {
|
||||
if (!ref || typeof ref.__id__ !== 'number') {
|
||||
next.push(ref);
|
||||
continue;
|
||||
}
|
||||
const comp = elements[ref.__id__];
|
||||
if (matchedCompId < 0 && comp && comp.__type__ === componentType) {
|
||||
matchedCompId = ref.__id__;
|
||||
continue; // 丢弃这条引用
|
||||
}
|
||||
next.push(ref);
|
||||
}
|
||||
|
||||
if (matchedCompId < 0) {
|
||||
throw new Error(
|
||||
`editPrefab [remove-component]: 节点 "${node._name || nodeId}" 上找不到 ${rawCompType} 组件`
|
||||
);
|
||||
}
|
||||
|
||||
node._components = next;
|
||||
|
||||
// 收集被删组件相关 __id__:组件本身 + 它的 cc.CompPrefabInfo(__prefab 字段)。
|
||||
// targetOverride 的 source 一般指向组件本身;带上 CompPrefabInfo 为防御性兜底。
|
||||
const removedIds = new Set([matchedCompId]);
|
||||
const matchedComp = elements[matchedCompId];
|
||||
if (matchedComp && matchedComp.__prefab && typeof matchedComp.__prefab.__id__ === 'number') {
|
||||
removedIds.add(matchedComp.__prefab.__id__);
|
||||
}
|
||||
cleanupRootTargetOverrides(elements, rootId, removedIds);
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execRemoveComponent };
|
||||
@@ -0,0 +1,98 @@
|
||||
// remove-node: 从父 _children(或 stub 的 mountedChildren)移除节点引用,
|
||||
// 并递归断开整棵子树所有节点/组件的 _parent 引用。
|
||||
// 节点元素本身保留在数组(保持其他 __id__ 稳定)。
|
||||
// op: { op: 'remove-node', target: string|{id:N} }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
const { disconnectSubtree, cleanupRootTargetOverrides, syncNestedRoots } = require('../id-utils.js');
|
||||
|
||||
function execRemoveNode(prefabData, op) {
|
||||
const { elements, rootId } = prefabData;
|
||||
const { target: targetSelector } = op;
|
||||
|
||||
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'remove-node');
|
||||
|
||||
if (!targetNode._parent || typeof targetNode._parent.__id__ !== 'number') {
|
||||
throw new Error(`editPrefab [remove-node]: 目标节点没有父节点,无法移除(根节点不能删除)`);
|
||||
}
|
||||
const parentId = targetNode._parent.__id__;
|
||||
const parentNode = elements[parentId];
|
||||
if (!parentNode || parentNode.__type__ !== 'cc.Node') {
|
||||
throw new Error(`editPrefab [remove-node]: 父节点 __id__=${parentId} 不是有效 cc.Node`);
|
||||
}
|
||||
|
||||
if (isStub(elements, parentNode)) {
|
||||
const prefabRef = parentNode._prefab;
|
||||
const parentPrefabInfo = elements[prefabRef.__id__];
|
||||
const instanceRef = parentPrefabInfo.instance;
|
||||
const prefabInstance = elements[instanceRef.__id__];
|
||||
if (Array.isArray(prefabInstance.mountedChildren)) {
|
||||
prefabInstance.mountedChildren = prefabInstance.mountedChildren.filter(
|
||||
(r) => r.__id__ !== targetId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(parentNode._children)) {
|
||||
parentNode._children = parentNode._children.filter(
|
||||
(r) => r.__id__ !== targetId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集整棵子树的所有 __id__(节点/组件/PrefabInfo/PrefabInstance)。
|
||||
// 必须在 disconnectSubtree 之前——后者会清空 mountedChildren、置 pi.instance=null,
|
||||
// 之后就拿不到嵌套实例的关联对象了。
|
||||
const subtreeIds = collectSubtreeIds(elements, targetId);
|
||||
|
||||
disconnectSubtree(elements, targetId);
|
||||
|
||||
// 软删后同步外层 PrefabInfo.nestedPrefabInstanceRoots,清掉孤儿 stub 引用
|
||||
syncNestedRoots(elements, rootId);
|
||||
|
||||
// 清掉根 PrefabInfo.targetOverrides 中 source/target 指向被删子树的悬空条目。
|
||||
// 外层脚本对嵌套 stub 内部组件/节点的引用(如 _passScoreView → scoreView)走 targetOverride,
|
||||
// 删了 stub 后这条 override 仍被根 PrefabInfo 引用 → 可达悬空引用,运行时解析会报错。
|
||||
cleanupRootTargetOverrides(elements, rootId, subtreeIds);
|
||||
|
||||
return targetId;
|
||||
}
|
||||
|
||||
// 收集子树所有相关 __id__:节点、其组件、_prefab(PrefabInfo)、instance(PrefabInstance)、
|
||||
// 以及 mountedChildren 指向的嵌套子树。供 targetOverride 悬空判断用。
|
||||
function collectSubtreeIds(elements, nodeId, acc) {
|
||||
acc = acc || new Set();
|
||||
const node = elements[nodeId];
|
||||
if (!node || node.__type__ !== 'cc.Node' || acc.has(nodeId)) return acc;
|
||||
acc.add(nodeId);
|
||||
|
||||
if (Array.isArray(node._children)) {
|
||||
for (const c of node._children) {
|
||||
if (c && typeof c.__id__ === 'number') collectSubtreeIds(elements, c.__id__, acc);
|
||||
}
|
||||
}
|
||||
|
||||
if (node._prefab && typeof node._prefab.__id__ === 'number') {
|
||||
acc.add(node._prefab.__id__);
|
||||
const pi = elements[node._prefab.__id__];
|
||||
if (pi && pi.instance && typeof pi.instance.__id__ === 'number') {
|
||||
acc.add(pi.instance.__id__);
|
||||
const inst = elements[pi.instance.__id__];
|
||||
if (inst && Array.isArray(inst.mountedChildren)) {
|
||||
for (const mc of inst.mountedChildren) {
|
||||
if (mc && typeof mc.__id__ === 'number') collectSubtreeIds(elements, mc.__id__, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(node._components)) {
|
||||
for (const c of node._components) {
|
||||
if (c && typeof c.__id__ === 'number') acc.add(c.__id__);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
module.exports = { execRemoveNode };
|
||||
@@ -0,0 +1,32 @@
|
||||
// rename-node: 改节点 _name
|
||||
// op: { op:'rename-node', node, name }
|
||||
//
|
||||
// 普通节点:直接改 node._name
|
||||
// stub 节点:name 存在 PrefabInstance.propertyOverrides 而不是 node._name
|
||||
// 走 setOverrideProperty(['_name']) 与 set-active 同模式
|
||||
|
||||
'use strict';
|
||||
|
||||
const { setOverrideProperty } = require('../../overrides.js');
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
|
||||
function execRenameNode(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, name } = op;
|
||||
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new Error(`editPrefab [rename-node]: name 必须是非空字符串`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'rename-node');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
setOverrideProperty(prefabData, nodeId, ['_name'], name);
|
||||
} else {
|
||||
node._name = name;
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execRenameNode };
|
||||
@@ -0,0 +1,75 @@
|
||||
// reorder-children: 调整节点的 _children 顺序(影响 UI 渲染层级)
|
||||
// op: { op:'reorder-children', node, order }
|
||||
//
|
||||
// order:子节点名字数组(必须包含全部 _children 的 name),按这个顺序重排
|
||||
// 或 __id__ 数组:[{id:N}, {id:M}, ...]
|
||||
//
|
||||
// stub 节点暂不支持(mountedChildren 顺序场景少见)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
|
||||
function execReorderChildren(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, order } = op;
|
||||
|
||||
if (!Array.isArray(order) || order.length === 0) {
|
||||
throw new Error(`editPrefab [reorder-children]: order 必须是非空数组`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reorder-children');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(
|
||||
`editPrefab [reorder-children]: 节点 "${node._name}" 是 stub,stub 子节点重排暂不支持`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(node._children)) {
|
||||
throw new Error(`editPrefab [reorder-children]: 节点 "${node._name}" 没有 _children`);
|
||||
}
|
||||
|
||||
const childMap = new Map(); // key (name 或 id) -> child ref
|
||||
for (const cref of node._children) {
|
||||
if (typeof cref.__id__ !== 'number') continue;
|
||||
const child = elements[cref.__id__];
|
||||
if (!child) continue;
|
||||
childMap.set(cref.__id__, cref);
|
||||
if (typeof child._name === 'string' && child._name.length > 0) {
|
||||
childMap.set(child._name, cref);
|
||||
}
|
||||
}
|
||||
|
||||
if (order.length !== node._children.length) {
|
||||
throw new Error(
|
||||
`editPrefab [reorder-children]: order 长度 ${order.length} ≠ _children 长度 ${node._children.length}(必须包含所有子节点)`
|
||||
);
|
||||
}
|
||||
|
||||
const newChildren = [];
|
||||
const seen = new Set();
|
||||
for (const item of order) {
|
||||
let key;
|
||||
if (typeof item === 'string') {
|
||||
key = item;
|
||||
} else if (item && typeof item.id === 'number') {
|
||||
key = item.id;
|
||||
} else {
|
||||
throw new Error(`editPrefab [reorder-children]: order 元素必须是字符串名或 {id:N},收到 ${JSON.stringify(item)}`);
|
||||
}
|
||||
const ref = childMap.get(key);
|
||||
if (!ref) {
|
||||
throw new Error(`editPrefab [reorder-children]: order 中的 "${key}" 不在 _children 内`);
|
||||
}
|
||||
if (seen.has(ref.__id__)) {
|
||||
throw new Error(`editPrefab [reorder-children]: order 中重复出现 "${key}"`);
|
||||
}
|
||||
seen.add(ref.__id__);
|
||||
newChildren.push(ref);
|
||||
}
|
||||
|
||||
node._children = newChildren;
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execReorderChildren };
|
||||
@@ -0,0 +1,91 @@
|
||||
// reparent: 把节点从原父节点下移到新父节点下(不复制,原节点搬家)
|
||||
// op: { op:'reparent', node, parent, index? }
|
||||
//
|
||||
// 行为:
|
||||
// 1. 从原 parent._children 数组里移除 node 引用
|
||||
// 2. 把 node 引用 push 到新 parent._children(或按 index 插入指定位置)
|
||||
// 3. 改 node._parent 指向新 parent
|
||||
//
|
||||
// 限制:
|
||||
// - 不支持 stub 节点(嵌套 prefab 实例)作为 source 或 target
|
||||
// stub 的父子关系存在 PrefabInstance.mountedChildren / nestedPrefabInstanceRoots,
|
||||
// 需要独立的 nested-reparent op,本 op 仅处理普通 inline 节点
|
||||
// - node 不能是 prefab 根节点(rootId=1),根节点 _parent 必须为 null
|
||||
// - parent 不能是 node 的后代(避免循环)
|
||||
// - 不修改 PrefabInfo.fileId(节点身份不变,外部引用仍然有效)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
|
||||
/** node 是否是 parent(或其后代)的祖先 → 循环检测 */
|
||||
function isAncestorOf(elements, ancestorId, candidateId) {
|
||||
let cur = candidateId;
|
||||
let safety = 0;
|
||||
while (cur != null && safety++ < 10000) {
|
||||
if (cur === ancestorId) return true;
|
||||
const node = elements[cur];
|
||||
if (!node || !node._parent) return false;
|
||||
cur = node._parent.__id__;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function execReparent(prefabData, op) {
|
||||
const { elements, rootId } = prefabData;
|
||||
const { node: nodeSelector, parent: parentSelector, index } = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reparent');
|
||||
const { node: newParent, nodeId: newParentId } = resolveNode(prefabData, parentSelector, 'reparent');
|
||||
|
||||
// 根节点不能搬家
|
||||
if (nodeId === rootId) {
|
||||
throw new Error(`editPrefab [reparent]: 根节点(id=${rootId})不能 reparent,其 _parent 必须为 null`);
|
||||
}
|
||||
|
||||
// stub 检查
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [reparent]: source 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
|
||||
}
|
||||
if (isStub(elements, newParent)) {
|
||||
throw new Error(`editPrefab [reparent]: target parent 是 stub 节点(嵌套 prefab 实例),不支持,需独立 op`);
|
||||
}
|
||||
|
||||
// 同一节点不动
|
||||
const oldParentId = node._parent ? node._parent.__id__ : null;
|
||||
if (oldParentId === newParentId) {
|
||||
// 仅 index 调整 → 走 reorder-children 更清晰;这里允许只换位(reorder 调整)
|
||||
if (index === undefined) return nodeId;
|
||||
}
|
||||
|
||||
// 循环检测:newParent 不能是 node 的后代
|
||||
if (isAncestorOf(elements, nodeId, newParentId)) {
|
||||
throw new Error(`editPrefab [reparent]: 循环引用——新父节点(id=${newParentId})是源节点(id=${nodeId})的后代`);
|
||||
}
|
||||
|
||||
// 1. 从原 parent._children 移除
|
||||
if (oldParentId != null) {
|
||||
const oldParent = elements[oldParentId];
|
||||
if (oldParent && Array.isArray(oldParent._children)) {
|
||||
oldParent._children = oldParent._children.filter(
|
||||
(c) => !c || c.__id__ !== nodeId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 加到新 parent._children
|
||||
if (!Array.isArray(newParent._children)) newParent._children = [];
|
||||
const ref = { __id__: nodeId };
|
||||
if (typeof index === 'number' && index >= 0 && index < newParent._children.length) {
|
||||
newParent._children.splice(index, 0, ref);
|
||||
} else {
|
||||
newParent._children.push(ref);
|
||||
}
|
||||
|
||||
// 3. 改 node._parent
|
||||
node._parent = { __id__: newParentId };
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execReparent };
|
||||
@@ -0,0 +1,57 @@
|
||||
// replace-nested-prefab: 替换 stub 节点(嵌套 prefab 实例)引用的外部 prefab asset。
|
||||
// 改 PrefabInfo.asset.__uuid__;可选清空 PrefabInstance.propertyOverrides。
|
||||
//
|
||||
// 适用场景:
|
||||
// 想把 ListItem.prefab 里某个嵌套子 prefab 从 OldPrefab 换成 NewPrefab,但
|
||||
// 保留 stub 节点的父子关系、_prefab fileId 不变(即 ListItem 内的 __id__
|
||||
// 引用稳定)。
|
||||
//
|
||||
// 注意:
|
||||
// - propertyOverrides 里的 targetFileId 是按老 prefab 内部 fileId 写的,新
|
||||
// prefab 通常没有对应 fileId。默认保留 overrides(编辑器加载时 skip 找不
|
||||
// 到的 override,不报错);clearOverrides=true 显式清空更干净。
|
||||
// - 不修改 PrefabInstance.fileId(这个是 stub 在外层 prefab 内的稳定标识,
|
||||
// 跟外部 prefab 的 fileId 无关)。
|
||||
//
|
||||
// op: { op: 'replace-nested-prefab', target: string|{id:N}, prefabUuid: string, clearOverrides?: boolean }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
|
||||
function execReplaceNestedPrefab(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { target: targetSelector, prefabUuid, clearOverrides } = op;
|
||||
|
||||
if (typeof prefabUuid !== 'string' || prefabUuid.trim() === '') {
|
||||
throw new Error(`editPrefab [replace-nested-prefab]: prefabUuid 必须是非空字符串`);
|
||||
}
|
||||
|
||||
const { node: targetNode, nodeId: targetId } = resolveNode(prefabData, targetSelector, 'replace-nested-prefab');
|
||||
|
||||
if (!isStub(elements, targetNode)) {
|
||||
throw new Error(`editPrefab [replace-nested-prefab]: 目标节点 [${targetId}] 不是嵌套 prefab stub(无 _prefab.instance)`);
|
||||
}
|
||||
|
||||
if (!targetNode._prefab || typeof targetNode._prefab.__id__ !== 'number') {
|
||||
throw new Error(`editPrefab [replace-nested-prefab]: stub 节点 [${targetId}] 没有 _prefab 引用`);
|
||||
}
|
||||
const prefabInfo = elements[targetNode._prefab.__id__];
|
||||
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') {
|
||||
throw new Error(`editPrefab [replace-nested-prefab]: _prefab 指向的不是 cc.PrefabInfo`);
|
||||
}
|
||||
if (!prefabInfo.asset || typeof prefabInfo.asset !== 'object') {
|
||||
throw new Error(`editPrefab [replace-nested-prefab]: PrefabInfo 缺 asset 字段`);
|
||||
}
|
||||
|
||||
prefabInfo.asset.__uuid__ = prefabUuid.trim();
|
||||
|
||||
if (clearOverrides === true && prefabInfo.instance && typeof prefabInfo.instance.__id__ === 'number') {
|
||||
const prefabInstance = elements[prefabInfo.instance.__id__];
|
||||
if (prefabInstance && Array.isArray(prefabInstance.propertyOverrides)) {
|
||||
prefabInstance.propertyOverrides = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { execReplaceNestedPrefab };
|
||||
@@ -0,0 +1,127 @@
|
||||
// reset-overrides: 清除 stub 节点的 propertyOverrides(回滚到嵌套 prefab 默认值)
|
||||
// op: { op:'reset-overrides', node, property?, componentType?, subNode?, all? }
|
||||
//
|
||||
// 调用形态:
|
||||
// 1) all=true:清空 stub 的整个 propertyOverrides 数组(一键回滚)
|
||||
// 不能同时指定 property / componentType
|
||||
// 2) property(无 componentType):清匹配 stub 节点字段 override
|
||||
// target = stub 自身 fileId,propertyPath = [property]
|
||||
// 常见字段 _lpos / _name / _active / _lscale 等
|
||||
// 3) property + componentType:清嵌套内某组件字段 override
|
||||
// subNode 用于嵌套 prefab 内同类型组件消歧(同 set-nested-component-field)
|
||||
//
|
||||
// 移除的 CCPropertyOverrideInfo / TargetInfo 作为 orphan 留在 elements,
|
||||
// 保持其他 __id__ 稳定(与 remove-node / remove-component 同策略)。
|
||||
//
|
||||
// 幂等:未找到匹配 override 不报错(缺省静默,CC3_MCP_DEBUG=1 时打 warn)。
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, normalizeComponentType } = require('../helpers.js');
|
||||
const { getNestedCompFileId, getNestedNodeFileId } = require('../nested.js');
|
||||
|
||||
function execResetOverrides(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const {
|
||||
node: nodeSelector,
|
||||
property,
|
||||
componentType: rawComponentType,
|
||||
subNode = null,
|
||||
all = false,
|
||||
} = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'reset-overrides');
|
||||
if (!isStub(elements, node)) {
|
||||
throw new Error(
|
||||
`editPrefab [reset-overrides]: 节点 "${node._name || nodeId}" 不是 stub,普通节点没有 propertyOverrides`
|
||||
);
|
||||
}
|
||||
|
||||
const prefabInfo = elements[node._prefab.__id__];
|
||||
const prefabInstance = elements[prefabInfo.instance.__id__];
|
||||
const stubFileId = prefabInfo.fileId;
|
||||
|
||||
// 模式 1:清空全部
|
||||
if (all) {
|
||||
if (property !== undefined || rawComponentType !== undefined) {
|
||||
throw new Error(
|
||||
`editPrefab [reset-overrides]: all=true 时禁止同时提供 property / componentType`
|
||||
);
|
||||
}
|
||||
if (Array.isArray(prefabInstance.propertyOverrides)) {
|
||||
prefabInstance.propertyOverrides = [];
|
||||
}
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
// 模式 2/3:按 propertyPath 匹配单条
|
||||
if (property === undefined) {
|
||||
throw new Error(
|
||||
`editPrefab [reset-overrides]: 必须提供 property,或显式 all=true 清空全部`
|
||||
);
|
||||
}
|
||||
if (typeof property !== 'string' && !Array.isArray(property)) {
|
||||
throw new Error(`editPrefab [reset-overrides]: property 必须是字符串或数组`);
|
||||
}
|
||||
const propertyPath = Array.isArray(property) ? property : [property];
|
||||
|
||||
// 决定要匹配的 localID[0]
|
||||
// 节点字段 override(无 componentType):嵌套 prefab 内根节点 fileId 是 Cocos 运行时
|
||||
// 实际识别的 key(见 overrides.js 地雷 3)。
|
||||
let targetFileId;
|
||||
if (rawComponentType) {
|
||||
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
|
||||
targetFileId = getNestedCompFileId(prefabData.resolverStartPath, elements, nodeId, componentType, subNode);
|
||||
} else {
|
||||
if (subNode !== null && subNode !== undefined) {
|
||||
throw new Error(
|
||||
`editPrefab [reset-overrides]: subNode 必须与 componentType 一起用(节点字段 override 无嵌套子节点定位)`
|
||||
);
|
||||
}
|
||||
targetFileId = getNestedNodeFileId(prefabData.resolverStartPath, elements, nodeId, null);
|
||||
}
|
||||
|
||||
if (!Array.isArray(prefabInstance.propertyOverrides) || prefabInstance.propertyOverrides.length === 0) {
|
||||
return nodeId; // 无 override 数组,幂等返回
|
||||
}
|
||||
|
||||
const remaining = [];
|
||||
let removed = 0;
|
||||
for (const ref of prefabInstance.propertyOverrides) {
|
||||
if (!ref || typeof ref.__id__ !== 'number') {
|
||||
remaining.push(ref);
|
||||
continue;
|
||||
}
|
||||
const info = elements[ref.__id__];
|
||||
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') {
|
||||
remaining.push(ref);
|
||||
continue;
|
||||
}
|
||||
const tiRef = info.targetInfo;
|
||||
const ti = tiRef && typeof tiRef.__id__ === 'number' ? elements[tiRef.__id__] : null;
|
||||
if (!ti || !Array.isArray(ti.localID) || ti.localID[0] !== targetFileId) {
|
||||
remaining.push(ref);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(info.propertyPath) || info.propertyPath.length !== propertyPath.length) {
|
||||
remaining.push(ref);
|
||||
continue;
|
||||
}
|
||||
if (info.propertyPath.every((p, i) => p === propertyPath[i])) {
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
remaining.push(ref);
|
||||
}
|
||||
|
||||
if (removed === 0 && process.env.CC3_MCP_DEBUG) {
|
||||
console.warn(
|
||||
`[reset-overrides] stub [${nodeId}]: 未找到匹配 propertyPath=${JSON.stringify(propertyPath)} target=${targetFileId} 的 override(无操作)`
|
||||
);
|
||||
}
|
||||
|
||||
prefabInstance.propertyOverrides = remaining;
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execResetOverrides };
|
||||
@@ -0,0 +1,28 @@
|
||||
// set-active: 设置节点 _active
|
||||
// op: { op, node, active }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { setOverrideProperty } = require('../../overrides.js');
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
|
||||
function execSetActive(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, active } = op;
|
||||
|
||||
if (typeof active !== 'boolean') {
|
||||
throw new Error(`editPrefab [set-active]: active 必须是布尔值`);
|
||||
}
|
||||
|
||||
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-active');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
setOverrideProperty(prefabData, id, ['_active'], active);
|
||||
} else {
|
||||
node._active = active;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
module.exports = { execSetActive };
|
||||
@@ -0,0 +1,117 @@
|
||||
// set-anchor: cc.UITransform 锚点便捷写法 + 自动补偿 lpos
|
||||
// op: { op:'set-anchor', node, x?, y?, compensatePosition? }
|
||||
//
|
||||
// - x / y 为新 anchor 值(0~1),任一缺省则保留原值
|
||||
// - compensatePosition: true 时按 anchor 差值 * 节点 size 自动补偿 lpos
|
||||
// 补偿公式:lpos.x += width * (newAnchorX - oldAnchorX)
|
||||
// lpos.y += height * (newAnchorY - oldAnchorY)
|
||||
// 场景:改 anchor 又想保持节点视觉位置不动
|
||||
// - stub 节点:_anchorPoint 走 PrefabInstance.propertyOverrides 写嵌套 UITransform;
|
||||
// compensate 时 _lpos 走 stub 节点自身的 propertyOverrides(节点字段)。
|
||||
// oldA / size 从嵌套 prefab 默认值读(不查 propertyOverrides 历史值)。
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
|
||||
const { setOverrideProperty } = require('../../overrides.js');
|
||||
const { parsePrefab } = require('../../parse.js');
|
||||
const { resolveUuidToPath } = require('../../uuid-resolver.js');
|
||||
|
||||
// 从嵌套 prefab 内读 root UITransform 的 _anchorPoint / _contentSize(默认值)
|
||||
function _readNestedUITransform(hostPath, elements, stubNodeId) {
|
||||
const stub = elements[stubNodeId];
|
||||
const pi = elements[stub._prefab.__id__];
|
||||
const nestedUuid = pi.asset.__uuid__;
|
||||
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
|
||||
const nestedData = parsePrefab(nestedPath);
|
||||
const nEls = nestedData.elements;
|
||||
for (const el of nEls) {
|
||||
if (el && el.__type__ === 'cc.UITransform') {
|
||||
const a = el._anchorPoint || { x: 0.5, y: 0.5 };
|
||||
const s = el._contentSize || { width: 0, height: 0 };
|
||||
return {
|
||||
anchor: { x: a.x || 0, y: a.y || 0 },
|
||||
size: { width: s.width || 0, height: s.height || 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
return { anchor: { x: 0.5, y: 0.5 }, size: { width: 0, height: 0 } };
|
||||
}
|
||||
|
||||
function execSetAnchor(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, x, y, compensatePosition = false } = op;
|
||||
|
||||
if (x === undefined && y === undefined) {
|
||||
throw new Error(`editPrefab [set-anchor]: 至少提供 x 或 y 之一`);
|
||||
}
|
||||
if (x !== undefined && (typeof x !== 'number' || x < 0 || x > 1)) {
|
||||
throw new Error(`editPrefab [set-anchor]: x 必须是 0~1 数字`);
|
||||
}
|
||||
if (y !== undefined && (typeof y !== 'number' || y < 0 || y > 1)) {
|
||||
throw new Error(`editPrefab [set-anchor]: y 必须是 0~1 数字`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-anchor');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
const { anchor: oldA, size } = _readNestedUITransform(
|
||||
prefabData.resolverStartPath, elements, nodeId
|
||||
);
|
||||
const newA = {
|
||||
__type__: 'cc.Vec2',
|
||||
x: x === undefined ? oldA.x : x,
|
||||
y: y === undefined ? oldA.y : y,
|
||||
};
|
||||
const compFileId = getNestedCompFileId(
|
||||
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
|
||||
);
|
||||
setStubCompOverride(prefabData, nodeId, compFileId, ['_anchorPoint'], newA);
|
||||
|
||||
if (compensatePosition) {
|
||||
// stub 节点 _lpos 改值走自身 propertyOverrides(节点字段,不是组件字段)
|
||||
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
|
||||
const dx = size.width * (newA.x - oldA.x);
|
||||
const dy = size.height * (newA.y - oldA.y);
|
||||
const newLpos = {
|
||||
__type__: 'cc.Vec3',
|
||||
x: (lpos.x || 0) + dx,
|
||||
y: (lpos.y || 0) + dy,
|
||||
z: lpos.z || 0,
|
||||
};
|
||||
setOverrideProperty(prefabData, nodeId, ['_lpos'], newLpos);
|
||||
}
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const ut = findComponent(elements, node, 'cc.UITransform');
|
||||
if (!ut) {
|
||||
throw new Error(`editPrefab [set-anchor]: 节点 "${node._name}" 上没有 cc.UITransform`);
|
||||
}
|
||||
|
||||
const oldA = ut._anchorPoint || { x: 0.5, y: 0.5 };
|
||||
const newA = {
|
||||
__type__: 'cc.Vec2',
|
||||
x: x === undefined ? oldA.x : x,
|
||||
y: y === undefined ? oldA.y : y,
|
||||
};
|
||||
ut._anchorPoint = newA;
|
||||
|
||||
if (compensatePosition) {
|
||||
const size = ut._contentSize || { width: 0, height: 0 };
|
||||
const lpos = node._lpos || { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 };
|
||||
const dx = (size.width || 0) * (newA.x - oldA.x);
|
||||
const dy = (size.height || 0) * (newA.y - oldA.y);
|
||||
node._lpos = {
|
||||
__type__: 'cc.Vec3',
|
||||
x: (lpos.x || 0) + dx,
|
||||
y: (lpos.y || 0) + dy,
|
||||
z: lpos.z || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetAnchor };
|
||||
@@ -0,0 +1,52 @@
|
||||
// set-button: 批量设置节点上 cc.Button 的常用字段
|
||||
// op: {
|
||||
// op: 'set-button',
|
||||
// node,
|
||||
// interactable?: boolean
|
||||
// transition?: 0=NONE 1=COLOR 2=SPRITE 3=SCALE
|
||||
// zoomScale?: number(transition=SCALE 时的缩放比例)
|
||||
// duration?: number(过渡动画时长,秒)
|
||||
// }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
|
||||
const FIELD_MAP = {
|
||||
interactable: '_interactable',
|
||||
transition: '_transition',
|
||||
zoomScale: '_zoomScale',
|
||||
duration: '_duration',
|
||||
};
|
||||
|
||||
function execSetButton(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector } = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-button');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [set-button]: 节点是 stub,请用 set-nested-component-field`);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, 'cc.Button');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-button]: 节点 "${node._name}" 上找不到 cc.Button 组件`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const [key, field] of Object.entries(FIELD_MAP)) {
|
||||
if (key in op) {
|
||||
comp[field] = op[key];
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
throw new Error(
|
||||
`editPrefab [set-button]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})`
|
||||
);
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetButton };
|
||||
@@ -0,0 +1,44 @@
|
||||
// set-component-enabled: 改组件 _enabled
|
||||
// op: { op:'set-component-enabled', node, componentType, enabled }
|
||||
//
|
||||
// 普通节点直接改 comp._enabled
|
||||
// stub 节点:写 PrefabInstance.propertyOverrides(与 set-nested-component-field 同模式)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
|
||||
|
||||
function execSetComponentEnabled(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, componentType: rawCompType, enabled, subNode = null } = op;
|
||||
|
||||
if (typeof rawCompType !== 'string' || rawCompType.length === 0) {
|
||||
throw new Error(`editPrefab [set-component-enabled]: componentType 必须是非空字符串`);
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new Error(`editPrefab [set-component-enabled]: enabled 必须是布尔值`);
|
||||
}
|
||||
|
||||
const componentType = normalizeComponentType(rawCompType, prefabData.resolverStartPath);
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-enabled');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
const compFileId = getNestedCompFileId(
|
||||
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
|
||||
);
|
||||
setStubCompOverride(prefabData, nodeId, compFileId, ['_enabled'], enabled);
|
||||
} else {
|
||||
const comp = findComponent(elements, node, componentType);
|
||||
if (!comp) {
|
||||
throw new Error(
|
||||
`editPrefab [set-component-enabled]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
|
||||
);
|
||||
}
|
||||
comp._enabled = enabled;
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetComponentEnabled };
|
||||
@@ -0,0 +1,68 @@
|
||||
// set-component-field: 普通节点改任意组件字段
|
||||
// op: { op:'set-component-field', node, componentType, property, value }
|
||||
//
|
||||
// set-nested-component-field 只覆盖 stub 节点;本 op 是普通节点版本。
|
||||
//
|
||||
// - node: 普通节点选择器(不能是 stub)
|
||||
// - property: 字段名,可以是字符串(顶层字段)或字符串数组(嵌套路径)
|
||||
// 例:'_string' / ['_color', 'r'] / ['_anchorPoint', 'x']
|
||||
// - value: 任意 JSON-serializable 值;改 cc.Vec2 / cc.Vec3 / cc.Size 时需带 __type__
|
||||
|
||||
'use strict';
|
||||
|
||||
const { normalizeComponentType, isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
|
||||
function execSetComponentField(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, componentType: rawComponentType, property, value } = op;
|
||||
|
||||
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
|
||||
throw new Error(`editPrefab [set-component-field]: componentType 必须是非空字符串`);
|
||||
}
|
||||
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
|
||||
if (
|
||||
!(typeof property === 'string' && property.length > 0) &&
|
||||
!(Array.isArray(property) && property.length > 0 && property.every((p) => typeof p === 'string' && p.length > 0))
|
||||
) {
|
||||
throw new Error(`editPrefab [set-component-field]: property 必须是非空字符串或非空字符串数组`);
|
||||
}
|
||||
if (value === undefined) {
|
||||
throw new Error(`editPrefab [set-component-field]: value 不能是 undefined`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-field');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(
|
||||
`editPrefab [set-component-field]: 节点 "${node._name}" 是 stub 代理,请用 set-nested-component-field`
|
||||
);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, componentType);
|
||||
if (!comp) {
|
||||
throw new Error(
|
||||
`editPrefab [set-component-field]: 节点 "${node._name}" 上找不到 ${componentType} 组件`
|
||||
);
|
||||
}
|
||||
|
||||
// 单层 property
|
||||
if (typeof property === 'string') {
|
||||
comp[property] = value;
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
// 嵌套 property 路径:逐层下钻,路径中断时报错(不自动建中间对象,避免悄悄改坏结构)
|
||||
let cursor = comp;
|
||||
for (let i = 0; i < property.length - 1; i++) {
|
||||
const k = property[i];
|
||||
if (cursor[k] === null || cursor[k] === undefined || typeof cursor[k] !== 'object') {
|
||||
throw new Error(
|
||||
`editPrefab [set-component-field]: 路径 ${property.slice(0, i + 1).join('.')} 不是对象(实际值 ${JSON.stringify(cursor[k])}),无法继续下钻`
|
||||
);
|
||||
}
|
||||
cursor = cursor[k];
|
||||
}
|
||||
cursor[property[property.length - 1]] = value;
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetComponentField };
|
||||
@@ -0,0 +1,138 @@
|
||||
// set-component-ref: 把节点上指定组件的 @property 字段序列化指向另一节点/组件
|
||||
// op: { op: 'set-component-ref', node, componentType, property, refNode, refType?, refSubNode? }
|
||||
//
|
||||
// - node: 持有目标组件的节点
|
||||
// - componentType: 目标组件 ccclass 名
|
||||
// - property: @property 字段名,支持以下格式:
|
||||
// "_role" → 普通字段,propertyPath: ["_role"]
|
||||
// "_items.0" → 数组字段第 0 项,propertyPath: ["_items", 0]
|
||||
// "_items[0]" → 同上,[] 写法等价
|
||||
// - refNode: 引用指向的节点(字符串名或 {id:N})
|
||||
// - refType: 缺省或 'cc.Node' 表示字段指向节点本身;否则指向 refNode 上该类型的第一个组件
|
||||
// - refSubNode: refNode 是 stub 时指定嵌套 prefab 内的子节点名(可选)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ref } = require('../../primitives.js');
|
||||
const { normalizeComponentType, isStub, indexOfNode, resolveNode, findComponent } = require('../helpers.js');
|
||||
const { resolveLocalIdChain, addRootTargetOverride } = require('../nested.js');
|
||||
|
||||
// ─── propertyPath 解析 ────────────────────────────────────────
|
||||
//
|
||||
// 把 property 字符串拆成 propertyPath 数组,传给 addRootTargetOverride
|
||||
// 和 setByPropertyPath,统一用数字表示数组索引(Cocos 引擎序列化格式)。
|
||||
//
|
||||
// 例:
|
||||
// "_role" → ["_role"]
|
||||
// "_items.0" → ["_items", 0]
|
||||
// "_items[2]" → ["_items", 2]
|
||||
|
||||
function parsePropertyPath(property) {
|
||||
// [] 下标 → . 分隔:_items[0] → _items.0
|
||||
const normalized = property.replace(/\[(\d+)\]/g, '.$1');
|
||||
const parts = normalized.split('.').filter(p => p.length > 0);
|
||||
return parts.map(p => {
|
||||
const n = Number(p);
|
||||
// 纯整数字符串(不含前导零的,如 "0" "1" "10")→ 数字索引
|
||||
return Number.isInteger(n) && String(n) === p ? n : p;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 按 propertyPath 多层路径赋值 ─────────────────────────────
|
||||
//
|
||||
// 支持数组索引(数字 key)和对象属性(字符串 key)的任意组合。
|
||||
// 中间层不存在时:下一段是数字 → 创建数组;否则 → 创建对象。
|
||||
|
||||
function setByPropertyPath(obj, pathParts, value) {
|
||||
if (pathParts.length === 1) {
|
||||
obj[pathParts[0]] = value;
|
||||
return;
|
||||
}
|
||||
const head = pathParts[0];
|
||||
const tail = pathParts.slice(1);
|
||||
if (!obj[head] || typeof obj[head] !== 'object') {
|
||||
obj[head] = typeof tail[0] === 'number' ? [] : {};
|
||||
}
|
||||
setByPropertyPath(obj[head], tail, value);
|
||||
}
|
||||
|
||||
function execSetComponentRef(prefabData, op) {
|
||||
const { elements, rootId } = prefabData;
|
||||
const {
|
||||
node: nodeSelector,
|
||||
componentType: rawComponentType,
|
||||
property,
|
||||
refNode: refNodeSelector,
|
||||
refType: rawRefType,
|
||||
refSubNode = null,
|
||||
} = op;
|
||||
|
||||
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
|
||||
throw new Error(`editPrefab [set-component-ref]: componentType 必须是非空字符串`);
|
||||
}
|
||||
if (typeof property !== 'string' || property.length === 0) {
|
||||
throw new Error(`editPrefab [set-component-ref]: property 必须是非空字符串`);
|
||||
}
|
||||
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
|
||||
const refType = rawRefType
|
||||
? normalizeComponentType(rawRefType, prefabData.resolverStartPath)
|
||||
: rawRefType;
|
||||
|
||||
// 解析 property 为 propertyPath 数组(支持 "_items.0" / "_items[0]" 数组写法)
|
||||
const propertyPath = parsePropertyPath(property);
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-component-ref');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(
|
||||
`editPrefab [set-component-ref]: 源节点 "${node._name}" 是 stub(嵌套 prefab 代理),` +
|
||||
`对 stub 自身组件挂 @property 字段的场景(需要 TargetOverrideInfo.sourceInfo)暂不支持`
|
||||
);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, componentType);
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-component-ref]: 节点 "${node._name}" 未挂 "${componentType}" 组件`);
|
||||
}
|
||||
|
||||
const { node: refNode, nodeId: refNodeId } = resolveNode(prefabData, refNodeSelector, 'set-component-ref');
|
||||
|
||||
// refNode 是 stub 代理 → 走 cc.TargetOverrideInfo 跨 nested 挂载
|
||||
if (isStub(elements, refNode)) {
|
||||
const targetCompType = !refType || refType === 'cc.Node' ? 'cc.Node' : refType;
|
||||
const localIdChain = resolveLocalIdChain(
|
||||
prefabData.resolverStartPath,
|
||||
elements,
|
||||
refNodeId,
|
||||
targetCompType,
|
||||
refSubNode
|
||||
);
|
||||
|
||||
const compId = indexOfNode(elements, comp);
|
||||
if (compId < 0) {
|
||||
throw new Error(`editPrefab [set-component-ref]: 源组件索引失败(内部错误)`);
|
||||
}
|
||||
// 传入 propertyPath 数组(支持 ["_items", 0] 数组元素挂载)
|
||||
addRootTargetOverride(prefabData, rootId, compId, propertyPath, refNodeId, localIdChain);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
// 普通节点:按 propertyPath 多层路径赋值(支持数组字段 _items[0]/_items[1]...)
|
||||
if (!refType || refType === 'cc.Node') {
|
||||
setByPropertyPath(comp, propertyPath, ref(refNodeId));
|
||||
} else {
|
||||
const refComp = findComponent(elements, refNode, refType);
|
||||
if (!refComp) {
|
||||
throw new Error(`editPrefab [set-component-ref]: 引用节点 "${refNode._name}" 未挂 "${refType}" 组件`);
|
||||
}
|
||||
const refCompId = indexOfNode(elements, refComp);
|
||||
if (refCompId < 0) {
|
||||
throw new Error(`editPrefab [set-component-ref]: 引用组件索引失败(内部错误)`);
|
||||
}
|
||||
setByPropertyPath(comp, propertyPath, ref(refCompId));
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetComponentRef };
|
||||
@@ -0,0 +1,56 @@
|
||||
// set-editbox: 批量设置节点上 cc.EditBox 的常用字段
|
||||
// op: {
|
||||
// op: 'set-editbox',
|
||||
// node,
|
||||
// inputMode?: 0=ANY 1=EMAIL_ADDR 2=NUMERIC 3=PHONE_NUMBER 4=URL 5=DECIMAL 6=SINGLE_LINE
|
||||
// maxLength?: number(-1 无限制)
|
||||
// placeholder?: string
|
||||
// string?: string(当前文字值)
|
||||
// inputFlag?: 0=DEFAULT 1=PASSWORD 2=SENSITIVE 3=INITIAL_CAPS_WORD 4=INITIAL_CAPS_SENTENCE 5=INITIAL_CAPS_ALL_CHARACTERS
|
||||
// fontSize?: number
|
||||
// }
|
||||
//
|
||||
// 至少提供一个可选字段,否则 op 无意义。
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
|
||||
const FIELD_MAP = {
|
||||
inputMode: '_inputMode',
|
||||
maxLength: '_maxLength',
|
||||
placeholder: '_placeholder',
|
||||
string: '_string',
|
||||
inputFlag: '_inputFlag',
|
||||
fontSize: '_fontSize',
|
||||
};
|
||||
|
||||
function execSetEditBox(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector } = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-editbox');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [set-editbox]: 节点是 stub,请用 set-nested-component-field`);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, 'cc.EditBox');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-editbox]: 节点 "${node._name}" 上找不到 cc.EditBox 组件`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const [key, field] of Object.entries(FIELD_MAP)) {
|
||||
if (key in op) {
|
||||
comp[field] = op[key];
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
throw new Error(`editPrefab [set-editbox]: 至少需要提供一个字段(inputMode/maxLength/placeholder/string/inputFlag/fontSize)`);
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetEditBox };
|
||||
@@ -0,0 +1,39 @@
|
||||
// set-label-text: 设置节点上 cc.Label 的 _string
|
||||
// op: { op, node, text, labelNode? }
|
||||
//
|
||||
// 普通节点:直接修改 cc.Label._string
|
||||
// stub 节点:从嵌套 prefab 中找 cc.Label 的 CompPrefabInfo.fileId,
|
||||
// 写入 PrefabInstance.propertyOverrides
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
|
||||
|
||||
function execSetLabelText(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, text, labelNode = null } = op;
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
throw new Error(`editPrefab [set-label-text]: text 必须是字符串`);
|
||||
}
|
||||
|
||||
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-label-text');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
const compFileId = getNestedCompFileId(
|
||||
prefabData.resolverStartPath, elements, id, 'cc.Label', labelNode
|
||||
);
|
||||
setStubCompOverride(prefabData, id, compFileId, ['_string'], text);
|
||||
} else {
|
||||
const comp = findComponent(elements, node, 'cc.Label');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-label-text]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Label 组件`);
|
||||
}
|
||||
comp._string = text;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
module.exports = { execSetLabelText };
|
||||
@@ -0,0 +1,64 @@
|
||||
// set-label: 批量设置节点上 cc.Label 的常用字段
|
||||
// op: {
|
||||
// op: 'set-label',
|
||||
// node,
|
||||
// text?: string(_string)
|
||||
// fontSize?: number
|
||||
// lineHeight?: number(0 = auto)
|
||||
// overflow?: 0=NONE 1=CLAMP 2=SHRINK 3=RESIZE_HEIGHT 4=TRUNCATE
|
||||
// horizontalAlign?: 0=LEFT 1=CENTER 2=RIGHT
|
||||
// verticalAlign?: 0=TOP 1=CENTER 2=BOTTOM
|
||||
// bold?: boolean
|
||||
// italic?: boolean
|
||||
// underline?: boolean
|
||||
// enableWrapText?: boolean(_enableWrapText)
|
||||
// }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
|
||||
const FIELD_MAP = {
|
||||
text: '_string',
|
||||
fontSize: '_fontSize',
|
||||
lineHeight: '_lineHeight',
|
||||
overflow: '_overflow',
|
||||
horizontalAlign: '_horizontalAlign',
|
||||
verticalAlign: '_verticalAlign',
|
||||
bold: '_isBold',
|
||||
italic: '_isItalic',
|
||||
underline: '_isUnderline',
|
||||
enableWrapText: '_enableWrapText',
|
||||
};
|
||||
|
||||
function execSetLabel(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector } = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-label');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [set-label]: 节点是 stub,请用 set-nested-component-field`);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, 'cc.Label');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-label]: 节点 "${node._name}" 上找不到 cc.Label 组件`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const [key, field] of Object.entries(FIELD_MAP)) {
|
||||
if (key in op) {
|
||||
comp[field] = op[key];
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
throw new Error(
|
||||
`editPrefab [set-label]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})`
|
||||
);
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetLabel };
|
||||
@@ -0,0 +1,68 @@
|
||||
// set-layout: 批量设置节点上 cc.Layout 的常用字段
|
||||
// op: {
|
||||
// op: 'set-layout',
|
||||
// node,
|
||||
// type?: 0=NONE 1=HORIZONTAL 2=VERTICAL 3=GRID
|
||||
// resizeMode?: 0=NONE 1=CHILDREN 2=CONTAINER
|
||||
// paddingLeft?: number
|
||||
// paddingRight?: number
|
||||
// paddingTop?: number
|
||||
// paddingBottom?: number
|
||||
// spacingX?: number
|
||||
// spacingY?: number
|
||||
// startAxis?: 0=HORIZONTAL 1=VERTICAL(GRID 模式)
|
||||
// constraint?: 0=NONE 1=FIXED_ROW 2=FIXED_COL(GRID 模式)
|
||||
// constraintNum?: number(constraint 对应的行数/列数)
|
||||
// affectedByScale?: boolean
|
||||
// }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
|
||||
const FIELD_MAP = {
|
||||
type: '_layoutType',
|
||||
resizeMode: '_resizeMode',
|
||||
paddingLeft: '_paddingLeft',
|
||||
paddingRight: '_paddingRight',
|
||||
paddingTop: '_paddingTop',
|
||||
paddingBottom: '_paddingBottom',
|
||||
spacingX: '_spacingX',
|
||||
spacingY: '_spacingY',
|
||||
startAxis: '_startAxis',
|
||||
constraint: '_constraint',
|
||||
constraintNum: '_constraintNum',
|
||||
affectedByScale: '_affectedByScale',
|
||||
};
|
||||
|
||||
function execSetLayout(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector } = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-layout');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [set-layout]: 节点是 stub,请用 set-nested-component-field`);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, 'cc.Layout');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-layout]: 节点 "${node._name}" 上找不到 cc.Layout 组件`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const [key, field] of Object.entries(FIELD_MAP)) {
|
||||
if (key in op) {
|
||||
comp[field] = op[key];
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
throw new Error(
|
||||
`editPrefab [set-layout]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})`
|
||||
);
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetLayout };
|
||||
@@ -0,0 +1,52 @@
|
||||
// set-nested-component-field: 改 stub 节点展开后内部某组件的字段
|
||||
// op: { op, node, componentType, property, value, subNode? }
|
||||
//
|
||||
// - node: stub 节点名或 {id}(必须是 stub 代理)
|
||||
// - componentType: 子 prefab 里目标组件类型(如 'cc.Label')
|
||||
// - property: 字段名(如 '_string' / '_spriteFrame' / 'interactable');支持嵌套路径数组
|
||||
// - value: 要写入的值(raw JSON)
|
||||
// - subNode: 子 prefab 内部节点名(可选,默认 null = 子 prefab root 上第一个匹配组件)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { normalizeComponentType, isStub, resolveNode } = require('../helpers.js');
|
||||
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
|
||||
|
||||
function execSetNestedComponentField(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const {
|
||||
node: nodeSelector,
|
||||
componentType: rawComponentType,
|
||||
property,
|
||||
value,
|
||||
subNode = null,
|
||||
} = op;
|
||||
|
||||
if (typeof rawComponentType !== 'string' || rawComponentType.length === 0) {
|
||||
throw new Error(`editPrefab [set-nested-component-field]: componentType 必须是非空字符串`);
|
||||
}
|
||||
const componentType = normalizeComponentType(rawComponentType, prefabData.resolverStartPath);
|
||||
if (!property || (typeof property !== 'string' && !Array.isArray(property))) {
|
||||
throw new Error(`editPrefab [set-nested-component-field]: property 必须是字符串或数组`);
|
||||
}
|
||||
if (value === undefined) {
|
||||
throw new Error(`editPrefab [set-nested-component-field]: value 不能是 undefined`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-nested-component-field');
|
||||
if (!isStub(elements, node)) {
|
||||
throw new Error(
|
||||
`editPrefab [set-nested-component-field]: 节点 "${node._name}" 不是 stub 代理——` +
|
||||
`普通节点直接用 set-label-text/set-sprite-frame 或在代码里改组件字段`
|
||||
);
|
||||
}
|
||||
|
||||
const compFileId = getNestedCompFileId(
|
||||
prefabData.resolverStartPath, elements, nodeId, componentType, subNode
|
||||
);
|
||||
const propertyPath = Array.isArray(property) ? property : [property];
|
||||
setStubCompOverride(prefabData, nodeId, compFileId, propertyPath, value);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetNestedComponentField };
|
||||
@@ -0,0 +1,42 @@
|
||||
// set-node-color: 设置节点的 _color(cc.Node 自身颜色,影响整棵子树透明度和染色)
|
||||
// op: {
|
||||
// op: 'set-node-color',
|
||||
// node,
|
||||
// r?: number (0-255)
|
||||
// g?: number (0-255)
|
||||
// b?: number (0-255)
|
||||
// a?: number (0-255)
|
||||
// }
|
||||
//
|
||||
// 示例:{ op:'set-node-color', node:'btnClose', a:0 } // 全透明
|
||||
// { op:'set-node-color', node:'bg', r:255, g:200, b:100, a:255 }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { resolveNode, isStub } = require('../helpers.js');
|
||||
|
||||
function execSetNodeColor(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, r, g, b, a } = op;
|
||||
|
||||
if (r === undefined && g === undefined && b === undefined && a === undefined) {
|
||||
throw new Error(`editPrefab [set-node-color]: 至少提供一个分量(r/g/b/a)`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-node-color');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [set-node-color]: 节点是 stub,请用 set-nested-component-field 改节点颜色分量`);
|
||||
}
|
||||
|
||||
if (!node._color || typeof node._color !== 'object') {
|
||||
node._color = { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 };
|
||||
}
|
||||
if (r !== undefined) node._color.r = r;
|
||||
if (g !== undefined) node._color.g = g;
|
||||
if (b !== undefined) node._color.b = b;
|
||||
if (a !== undefined) node._color.a = a;
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetNodeColor };
|
||||
@@ -0,0 +1,29 @@
|
||||
// set-position: 设置节点本地位置
|
||||
// op: { op, node, x, y, z? }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { setOverrideProperty } = require('../../overrides.js');
|
||||
const { isStub, resolveNode } = require('../helpers.js');
|
||||
|
||||
function execSetPosition(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeId, x, y, z = 0 } = op;
|
||||
|
||||
if (typeof x !== 'number' || typeof y !== 'number') {
|
||||
throw new Error(`editPrefab [set-position]: x/y 必须是数字`);
|
||||
}
|
||||
|
||||
const { node, nodeId: id } = resolveNode(prefabData, nodeId, 'set-position');
|
||||
const newLpos = { __type__: 'cc.Vec3', x, y, z };
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
setOverrideProperty(prefabData, id, ['_lpos'], newLpos);
|
||||
} else {
|
||||
node._lpos = newLpos;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
module.exports = { execSetPosition };
|
||||
@@ -0,0 +1,52 @@
|
||||
// set-richtext: 批量设置节点上 cc.RichText 的常用字段
|
||||
// op: {
|
||||
// op: 'set-richtext',
|
||||
// node,
|
||||
// text?: string(_string,支持 BBCode 标签)
|
||||
// maxWidth?: number(0 = 不限制)
|
||||
// fontSize?: number
|
||||
// lineHeight?: number
|
||||
// }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
|
||||
const FIELD_MAP = {
|
||||
text: '_string',
|
||||
maxWidth: '_maxWidth',
|
||||
fontSize: '_fontSize',
|
||||
lineHeight: '_lineHeight',
|
||||
};
|
||||
|
||||
function execSetRichText(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector } = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-richtext');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [set-richtext]: 节点是 stub,请用 set-nested-component-field`);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, 'cc.RichText');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-richtext]: 节点 "${node._name}" 上找不到 cc.RichText 组件`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const [key, field] of Object.entries(FIELD_MAP)) {
|
||||
if (key in op) {
|
||||
comp[field] = op[key];
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
throw new Error(
|
||||
`editPrefab [set-richtext]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})`
|
||||
);
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetRichText };
|
||||
@@ -0,0 +1,78 @@
|
||||
// set-size: 改 cc.UITransform 内容尺寸
|
||||
// op: { op:'set-size', node, width?, height? }
|
||||
//
|
||||
// width / height 任一缺省则保留原值
|
||||
// stub 节点:走 PrefabInstance.propertyOverrides 写嵌套 UITransform._contentSize
|
||||
// - 任一缺省时从嵌套 prefab 读默认值补齐
|
||||
// - 不读 propertyOverrides 里的历史 override(少见且增加复杂度)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
|
||||
const { parsePrefab } = require('../../parse.js');
|
||||
const { resolveUuidToPath } = require('../../uuid-resolver.js');
|
||||
|
||||
// 从嵌套 prefab 内读 root UITransform 默认 _contentSize(用作 stub set-size 的缺省补齐)
|
||||
function _readNestedUITransformSize(hostPath, elements, stubNodeId) {
|
||||
const stub = elements[stubNodeId];
|
||||
const pi = elements[stub._prefab.__id__];
|
||||
const nestedUuid = pi.asset.__uuid__;
|
||||
const nestedPath = resolveUuidToPath(nestedUuid, hostPath);
|
||||
const nestedData = parsePrefab(nestedPath);
|
||||
const nEls = nestedData.elements;
|
||||
for (const el of nEls) {
|
||||
if (el && el.__type__ === 'cc.UITransform') {
|
||||
const s = el._contentSize || { width: 0, height: 0 };
|
||||
return { width: s.width || 0, height: s.height || 0 };
|
||||
}
|
||||
}
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
function execSetSize(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, width, height } = op;
|
||||
|
||||
if (width === undefined && height === undefined) {
|
||||
throw new Error(`editPrefab [set-size]: 至少提供 width 或 height 之一`);
|
||||
}
|
||||
if (width !== undefined && (typeof width !== 'number' || width < 0)) {
|
||||
throw new Error(`editPrefab [set-size]: width 必须是非负数字`);
|
||||
}
|
||||
if (height !== undefined && (typeof height !== 'number' || height < 0)) {
|
||||
throw new Error(`editPrefab [set-size]: height 必须是非负数字`);
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-size');
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
const oldSize = _readNestedUITransformSize(prefabData.resolverStartPath, elements, nodeId);
|
||||
const newSize = {
|
||||
__type__: 'cc.Size',
|
||||
width: width === undefined ? oldSize.width : width,
|
||||
height: height === undefined ? oldSize.height : height,
|
||||
};
|
||||
const compFileId = getNestedCompFileId(
|
||||
prefabData.resolverStartPath, elements, nodeId, 'cc.UITransform', null
|
||||
);
|
||||
setStubCompOverride(prefabData, nodeId, compFileId, ['_contentSize'], newSize);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const ut = findComponent(elements, node, 'cc.UITransform');
|
||||
if (!ut) {
|
||||
throw new Error(`editPrefab [set-size]: 节点 "${node._name}" 上没有 cc.UITransform`);
|
||||
}
|
||||
|
||||
const oldSize = ut._contentSize || { width: 0, height: 0 };
|
||||
ut._contentSize = {
|
||||
__type__: 'cc.Size',
|
||||
width: width === undefined ? oldSize.width : width,
|
||||
height: height === undefined ? oldSize.height : height,
|
||||
};
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetSize };
|
||||
@@ -0,0 +1,36 @@
|
||||
// set-sprite-frame: 设置节点上 cc.Sprite 的 _spriteFrame uuid
|
||||
// op: { op, node, uuid, spriteNode? }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
const { getNestedCompFileId, setStubCompOverride } = require('../nested.js');
|
||||
|
||||
function execSetSpriteFrame(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector, uuid, spriteNode = null } = op;
|
||||
|
||||
if (typeof uuid !== 'string') {
|
||||
throw new Error(`editPrefab [set-sprite-frame]: uuid 必须是字符串`);
|
||||
}
|
||||
|
||||
const { node, nodeId: id } = resolveNode(prefabData, nodeSelector, 'set-sprite-frame');
|
||||
const newFrame = { __uuid__: uuid, __expectedType__: 'cc.SpriteFrame' };
|
||||
|
||||
if (isStub(elements, node)) {
|
||||
const compFileId = getNestedCompFileId(
|
||||
prefabData.resolverStartPath, elements, id, 'cc.Sprite', spriteNode
|
||||
);
|
||||
setStubCompOverride(prefabData, id, compFileId, ['_spriteFrame'], newFrame);
|
||||
} else {
|
||||
const comp = findComponent(elements, node, 'cc.Sprite');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-sprite-frame]: 节点 "${JSON.stringify(nodeSelector)}" 没有 cc.Sprite 组件`);
|
||||
}
|
||||
comp._spriteFrame = newFrame;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
module.exports = { execSetSpriteFrame };
|
||||
@@ -0,0 +1,52 @@
|
||||
// set-sprite: 批量设置节点上 cc.Sprite 的常用字段(不含 spriteFrame,用 set-sprite-frame)
|
||||
// op: {
|
||||
// op: 'set-sprite',
|
||||
// node,
|
||||
// sizeMode?: 0=CUSTOM 1=TRIMMED 2=RAW
|
||||
// type?: 0=SIMPLE 1=SLICED 2=TILED 3=FILLED 4=MESH
|
||||
// grayscale?: boolean(_useGrayscale)
|
||||
// trim?: boolean(_isTrimmedMode)
|
||||
// }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { isStub, resolveNode, findComponent } = require('../helpers.js');
|
||||
|
||||
const FIELD_MAP = {
|
||||
sizeMode: '_sizeMode',
|
||||
type: '_type',
|
||||
grayscale: '_useGrayscale',
|
||||
trim: '_isTrimmedMode',
|
||||
};
|
||||
|
||||
function execSetSprite(prefabData, op) {
|
||||
const { elements } = prefabData;
|
||||
const { node: nodeSelector } = op;
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'set-sprite');
|
||||
if (isStub(elements, node)) {
|
||||
throw new Error(`editPrefab [set-sprite]: 节点是 stub,请用 set-nested-component-field`);
|
||||
}
|
||||
|
||||
const comp = findComponent(elements, node, 'cc.Sprite');
|
||||
if (!comp) {
|
||||
throw new Error(`editPrefab [set-sprite]: 节点 "${node._name}" 上找不到 cc.Sprite 组件`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const [key, field] of Object.entries(FIELD_MAP)) {
|
||||
if (key in op) {
|
||||
comp[field] = op[key];
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
throw new Error(
|
||||
`editPrefab [set-sprite]: 至少需要提供一个字段(${Object.keys(FIELD_MAP).join('/')})。更换图片用 set-sprite-frame`
|
||||
);
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
module.exports = { execSetSprite };
|
||||
@@ -0,0 +1,20 @@
|
||||
// sync-nested-roots: 重建根 PrefabInfo.nestedPrefabInstanceRoots,剔除「删了一半」
|
||||
// 残留的悬空嵌套实例根——节点的父引用已被移除(_parent=null)但根 PrefabInfo 里
|
||||
// 对它的登记还在,导致残留嵌套 prefab 的 asset 仍被当依赖加载(运行时 404 / 加载失败)。
|
||||
//
|
||||
// 只重写 nestedPrefabInstanceRoots 数组(依据当前「有父 + 有 PrefabInfo + instance」的
|
||||
// 实际 stub 节点),不删 elements、不动其他 __id__、不产生 null 槽;被孤立的残留对象
|
||||
// 成为不可达 orphan(软删策略,无害)。
|
||||
//
|
||||
// op: { op: 'sync-nested-roots' } 无参数,作用于 prefab 根。
|
||||
'use strict';
|
||||
|
||||
const { syncNestedRoots } = require('../id-utils.js');
|
||||
|
||||
function execSyncNestedRoots(prefabData) {
|
||||
const { elements, rootId } = prefabData;
|
||||
syncNestedRoots(elements, rootId);
|
||||
return rootId;
|
||||
}
|
||||
|
||||
module.exports = { execSyncNestedRoots };
|
||||
@@ -0,0 +1,97 @@
|
||||
// ============================================================
|
||||
// 确定性 UUID / fileId 生成器(纯 CJS,零三方依赖)
|
||||
// 与 tools/fgui2cc3/src/utils/DeterministicId.ts byte-for-byte 对齐
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createHash } = require('node:crypto');
|
||||
|
||||
/**
|
||||
* 基于种子字符串生成确定性 UUID v4 格式
|
||||
* 使用 SHA-256 哈希前 16 字节,设置 version=4 和 variant=RFC4122
|
||||
* @param {string} seed
|
||||
* @returns {string}
|
||||
*/
|
||||
function deterministicUUID(seed) {
|
||||
const hash = createHash('sha256').update(seed).digest();
|
||||
// 设置 version (4) 和 variant (RFC 4122)
|
||||
hash[6] = (hash[6] & 0x0f) | 0x40;
|
||||
hash[8] = (hash[8] & 0x3f) | 0x80;
|
||||
const hex = hash.subarray(0, 16).toString('hex');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于种子字符串生成确定性 fileId(CC3 使用 base64 编码的 16 字节)
|
||||
* @param {string} seed
|
||||
* @returns {string}
|
||||
*/
|
||||
function deterministicFileId(seed) {
|
||||
const hash = createHash('sha256').update(seed).digest();
|
||||
return hash.subarray(0, 16).toString('base64').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个带自增计数器的 fileId 生成器
|
||||
* 同一组件内每个节点/组件使用递增的序号保证唯一性和稳定性
|
||||
* @param {string} baseSeed
|
||||
* @returns {() => string}
|
||||
*/
|
||||
function createFileIdGenerator(baseSeed) {
|
||||
let counter = 0;
|
||||
return () => deterministicFileId(`${baseSeed}#fid#${counter++}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cocos Creator 压缩 classId 编解码
|
||||
// 标准 base64(A-Z a-z 0-9 + /),前 5 hex 保留,每 3 hex → 2 base64
|
||||
// 产物格式:23 字符 = 5 hex + 18 base64
|
||||
// ============================================================
|
||||
|
||||
const _BASE64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
/**
|
||||
* 把 32/36 位 uuid 压缩为 Cocos ccclass id(23 字符)。
|
||||
*
|
||||
* @param {string} uuid 标准 uuid(含或不含 dash)
|
||||
* @returns {string} 23 字符压缩 classId
|
||||
*/
|
||||
function compressUuid(uuid) {
|
||||
if (typeof uuid !== 'string') {
|
||||
throw new Error(`compressUuid: uuid 必须是字符串,收到 ${typeof uuid}`);
|
||||
}
|
||||
const hex = uuid.replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(hex)) {
|
||||
throw new Error(`compressUuid: 输入不是合法 uuid:${uuid}`);
|
||||
}
|
||||
let out = hex.slice(0, 5);
|
||||
for (let i = 5; i < 32; i += 3) {
|
||||
const code = parseInt(hex.substr(i, 3), 16);
|
||||
out += _BASE64_STD[(code >> 6) & 0x3f];
|
||||
out += _BASE64_STD[code & 0x3f];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否是 Cocos 压缩 classId 格式(23 字符,前 5 hex + 后 18 base64)。
|
||||
* 不做语义合法性校验(不查 classId 是否对应真实类)。
|
||||
*/
|
||||
function isCompressedClassId(str) {
|
||||
return typeof str === 'string' && /^[0-9a-f]{5}[A-Za-z0-9+/]{18}$/.test(str);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deterministicUUID,
|
||||
deterministicFileId,
|
||||
createFileIdGenerator,
|
||||
compressUuid,
|
||||
isCompressedClassId,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
|
||||
// 统一入口:re-export 所有公开 API
|
||||
const { parsePrefab } = require('./parse.js');
|
||||
const { writePrefab } = require('./write.js');
|
||||
const { editPrefab } = require('./editor/index.js');
|
||||
const { queryPrefab } = require('./query/index.js');
|
||||
const { setOverrideProperty, listOverrides } = require('./overrides.js');
|
||||
const { deterministicUUID, deterministicFileId, createFileIdGenerator } = require('./id.js');
|
||||
const primitives = require('./primitives.js');
|
||||
const animPrimitives = require('./anim-primitives.js');
|
||||
|
||||
module.exports = {
|
||||
parsePrefab,
|
||||
writePrefab,
|
||||
editPrefab,
|
||||
queryPrefab,
|
||||
setOverrideProperty,
|
||||
listOverrides,
|
||||
deterministicUUID,
|
||||
deterministicFileId,
|
||||
createFileIdGenerator,
|
||||
...primitives,
|
||||
// .anim 文件对象构建原语(AnimationClip / Track / Curve / Channel),
|
||||
// parse/write 复用 parsePrefab / writePrefab(同为 JSON 数组 + __id__ 引用格式)
|
||||
anim: animPrimitives,
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
// ============================================================
|
||||
// CC3 Prefab PrefabInstance Override 读写(纯 CJS,零三方依赖)
|
||||
//
|
||||
// 三个已知地雷:
|
||||
// 地雷 1:stub 节点(嵌套 prefab 根节点)本身的字段写入无效。
|
||||
// 必须走 PrefabInstance.propertyOverrides,以 CCPropertyOverrideInfo
|
||||
// + TargetInfo 结构写入,才能被 Cocos 编辑器识别。
|
||||
// 地雷 2:新增嵌套 stub 节点后,宿主 prefab 根节点的 cc.PrefabInfo
|
||||
// 的 nestedPrefabInstanceRoots 必须同步追加该 stub 节点的 __id__,
|
||||
// 否则 Cocos 加载时会忽略该嵌套实例的 override。
|
||||
// 地雷 3:propertyOverride.targetInfo.localID 必须是
|
||||
// 「嵌套 prefab 内根节点的 cc.PrefabInfo.fileId」,
|
||||
// 不是「外层 stub 的 cc.PrefabInfo.fileId」。
|
||||
// Cocos 运行时按嵌套 prefab 内部 fileId 建 targetMap,
|
||||
// 外层 stub fileId 在 targetMap 里查不到。
|
||||
// 早期 fgui→cc3 转出的 prefab 设计上让这两个 fileId 一致,
|
||||
// 所以本工具早期版本用 stubFileId 巧合工作;新写或手编的
|
||||
// 嵌套 prefab 一般两个 fileId 不同,必须读嵌套 prefab 拿真值。
|
||||
//
|
||||
// 本工具当前行为(2026-05-20 修后):
|
||||
// - 写入:强制读嵌套 prefab 拿真值,解析失败抛错。
|
||||
// - 自动矫正:识别旧 cli 写入的 stubFileId 条目,命中同
|
||||
// propertyPath 时把 localID 改写为真值(一次性迁移)。
|
||||
// 迁移完成后旧条目不复存在,listOverrides / reset-overrides
|
||||
// 只识别真值,不再兼容历史格式。
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { parsePrefab } = require('./parse.js');
|
||||
const { resolveUuidToPath } = require('./uuid-resolver.js');
|
||||
|
||||
/**
|
||||
* 查找 stub 节点对应的 PrefabInstance 对象
|
||||
*
|
||||
* stub 节点:__type__ = cc.Node,_prefab 指向一个 cc.PrefabInfo,
|
||||
* 该 PrefabInfo.instance 指向 cc.PrefabInstance。
|
||||
*
|
||||
* @param {object[]} elements prefab 数组
|
||||
* @param {number} stubId stub 节点的 __id__(数组下标)
|
||||
* @returns {{ prefabInstance: object, prefabInstanceId: number, prefabInfo: object, prefabInfoId: number } | null}
|
||||
*/
|
||||
function _findStubPrefabInstance(elements, stubId) {
|
||||
const stub = elements[stubId];
|
||||
if (!stub || stub.__type__ !== 'cc.Node') return null;
|
||||
|
||||
const prefabRef = stub._prefab;
|
||||
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return null;
|
||||
|
||||
const prefabInfoId = prefabRef.__id__;
|
||||
const prefabInfo = elements[prefabInfoId];
|
||||
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return null;
|
||||
|
||||
const instanceRef = prefabInfo.instance;
|
||||
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return null;
|
||||
|
||||
const prefabInstanceId = instanceRef.__id__;
|
||||
const prefabInstance = elements[prefabInstanceId];
|
||||
if (!prefabInstance || prefabInstance.__type__ !== 'cc.PrefabInstance') return null;
|
||||
|
||||
return { prefabInstance, prefabInstanceId, prefabInfo, prefabInfoId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载嵌套 prefab 拿其根节点的 cc.PrefabInfo.fileId。
|
||||
* 这是 propertyOverride.targetInfo.localID 正确值(见地雷 3)。
|
||||
*
|
||||
* @param {object} prefabData parsePrefab 返回值(外层 prefab),需含 resolverStartPath
|
||||
* @param {object} prefabInfo stub 节点的 cc.PrefabInfo(包含 asset.__uuid__)
|
||||
* @returns {string} 嵌套 prefab 根节点 PrefabInfo.fileId
|
||||
*
|
||||
* @throws 嵌套 prefab UUID 缺失 / 解析失败 / 找不到根节点 fileId 时抛错
|
||||
*/
|
||||
function _getStubInnerRootFileId(prefabData, prefabInfo) {
|
||||
if (!prefabData || !prefabData.resolverStartPath) {
|
||||
throw new Error(`setOverrideProperty: prefabData 缺 resolverStartPath,无法解析嵌套 prefab`);
|
||||
}
|
||||
|
||||
const assetRef = prefabInfo && prefabInfo.asset;
|
||||
if (!assetRef || typeof assetRef.__uuid__ !== 'string') {
|
||||
throw new Error(`setOverrideProperty: stub PrefabInfo.asset 不是 UUID 引用,无法定位嵌套 prefab`);
|
||||
}
|
||||
|
||||
const nestedPath = resolveUuidToPath(assetRef.__uuid__, prefabData.resolverStartPath);
|
||||
if (typeof nestedPath !== 'string' || nestedPath.length === 0) {
|
||||
throw new Error(`setOverrideProperty: UUID "${assetRef.__uuid__}" 找不到对应 prefab 路径`);
|
||||
}
|
||||
|
||||
const nestedData = parsePrefab(nestedPath);
|
||||
const nEls = nestedData.elements;
|
||||
for (const el of nEls) {
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
// 嵌套 prefab 内根节点 _parent === null
|
||||
if (el._parent !== null && el._parent !== undefined) continue;
|
||||
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
|
||||
const pi = nEls[el._prefab.__id__];
|
||||
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
|
||||
if (typeof pi.fileId === 'string' && pi.fileId.length > 0) {
|
||||
return pi.fileId;
|
||||
}
|
||||
}
|
||||
throw new Error(`setOverrideProperty: 嵌套 prefab "${nestedPath}" 找不到根节点 PrefabInfo.fileId`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 找 PrefabInstance 中已有的 CCPropertyOverrideInfo
|
||||
* 条件:targetInfo 的 localID[0] === targetLocalId,propertyPath[0] === propertyPath
|
||||
*
|
||||
* @param {object[]} elements
|
||||
* @param {object} prefabInstance
|
||||
* @param {string} targetLocalId 目标 fileId(嵌套 prefab 内根节点 fileId)
|
||||
* @param {string[]} propertyPath
|
||||
* @param {string=} legacyLocalId 兼容旧 prefab 写入的 stubFileId(一起匹配)
|
||||
* @returns {{ info: object, infoId: number } | null}
|
||||
*/
|
||||
function _findExistingOverride(elements, prefabInstance, targetLocalId, propertyPath, legacyLocalId) {
|
||||
if (!Array.isArray(prefabInstance.propertyOverrides)) return null;
|
||||
|
||||
for (const overrideRef of prefabInstance.propertyOverrides) {
|
||||
if (typeof overrideRef.__id__ !== 'number') continue;
|
||||
const info = elements[overrideRef.__id__];
|
||||
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
|
||||
|
||||
// 匹配 targetInfo.localID[0]:targetLocalId 优先;legacyLocalId 用于兼容旧版 cli 写入的 stubFileId
|
||||
const targetInfoRef = info.targetInfo;
|
||||
if (!targetInfoRef || typeof targetInfoRef.__id__ !== 'number') continue;
|
||||
const targetInfo = elements[targetInfoRef.__id__];
|
||||
if (!targetInfo || targetInfo.__type__ !== 'cc.TargetInfo') continue;
|
||||
if (!Array.isArray(targetInfo.localID)) continue;
|
||||
const lid = targetInfo.localID[0];
|
||||
if (lid !== targetLocalId && lid !== legacyLocalId) continue;
|
||||
|
||||
// 匹配 propertyPath
|
||||
if (!Array.isArray(info.propertyPath)) continue;
|
||||
if (info.propertyPath.length !== propertyPath.length) continue;
|
||||
if (info.propertyPath.every((p, i) => p === propertyPath[i])) {
|
||||
return { info, infoId: overrideRef.__id__, targetInfo };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 stub 节点(嵌套 prefab 根节点)的属性 override
|
||||
*
|
||||
* 地雷 1:直接写 stub 节点字段无效,必须通过 PrefabInstance.propertyOverrides。
|
||||
*
|
||||
* @param {object} prefabData parsePrefab 的返回值
|
||||
* @param {number} stubNodeId stub 节点的数组下标(__id__)
|
||||
* @param {string[]} propertyPath 属性路径,如 ['_lpos'] 或 ['_name']
|
||||
* @param {*} value 要写入的值
|
||||
* @returns {void}
|
||||
*
|
||||
* @throws 如果 stubNodeId 不是有效 stub 节点
|
||||
*/
|
||||
function setOverrideProperty(prefabData, stubNodeId, propertyPath, value) {
|
||||
const { elements } = prefabData;
|
||||
|
||||
const stubResult = _findStubPrefabInstance(elements, stubNodeId);
|
||||
if (!stubResult) {
|
||||
throw new Error(
|
||||
`setOverrideProperty: index ${stubNodeId} 不是有效的 stub 节点(需要 _prefab → PrefabInfo.instance → PrefabInstance)`
|
||||
);
|
||||
}
|
||||
|
||||
const { prefabInstance, prefabInfo } = stubResult;
|
||||
const stubFileId = prefabInfo.fileId;
|
||||
|
||||
// 嵌套 prefab 内根节点 fileId 是 Cocos 运行时 targetMap 的正确 key(见地雷 3)。
|
||||
// 解析失败抛错(不再 fallback),调用方需保证嵌套 prefab 可用。
|
||||
const targetLocalId = _getStubInnerRootFileId(prefabData, prefabInfo);
|
||||
|
||||
// 查找是否已有对应 override
|
||||
// 自动矫正:当 stubFileId !== targetLocalId 时,识别旧版 cli 写入的 stubFileId 条目,
|
||||
// 命中后把 localID 改写为真值(一次性迁移历史脏数据)。
|
||||
const legacyLocalId = stubFileId !== targetLocalId ? stubFileId : null;
|
||||
const existing = _findExistingOverride(elements, prefabInstance, targetLocalId, propertyPath, legacyLocalId);
|
||||
|
||||
if (existing) {
|
||||
existing.info.value = value;
|
||||
if (legacyLocalId && existing.targetInfo && Array.isArray(existing.targetInfo.localID)) {
|
||||
if (existing.targetInfo.localID[0] === legacyLocalId) {
|
||||
existing.targetInfo.localID[0] = targetLocalId;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 新建 TargetInfo
|
||||
const targetInfo = {
|
||||
__type__: 'cc.TargetInfo',
|
||||
localID: [targetLocalId],
|
||||
};
|
||||
const targetInfoId = elements.length;
|
||||
elements.push(targetInfo);
|
||||
|
||||
// 新建 CCPropertyOverrideInfo
|
||||
const overrideInfo = {
|
||||
__type__: 'CCPropertyOverrideInfo',
|
||||
targetInfo: { __id__: targetInfoId },
|
||||
propertyPath: [...propertyPath],
|
||||
value,
|
||||
};
|
||||
const overrideInfoId = elements.length;
|
||||
elements.push(overrideInfo);
|
||||
|
||||
// 追加到 PrefabInstance.propertyOverrides
|
||||
if (!Array.isArray(prefabInstance.propertyOverrides)) {
|
||||
prefabInstance.propertyOverrides = [];
|
||||
}
|
||||
prefabInstance.propertyOverrides.push({ __id__: overrideInfoId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 stub 节点的所有 propertyOverrides
|
||||
*
|
||||
* @param {object} prefabData parsePrefab 的返回值
|
||||
* @param {number} stubNodeId stub 节点的数组下标
|
||||
* @returns {Array<{ propertyPath: string[], value: *, targetFileId: string }>}
|
||||
*/
|
||||
function listOverrides(prefabData, stubNodeId) {
|
||||
const { elements } = prefabData;
|
||||
|
||||
const stubResult = _findStubPrefabInstance(elements, stubNodeId);
|
||||
if (!stubResult) {
|
||||
throw new Error(
|
||||
`listOverrides: index ${stubNodeId} 不是有效的 stub 节点`
|
||||
);
|
||||
}
|
||||
|
||||
const { prefabInstance, prefabInfo } = stubResult;
|
||||
// stub-node-field override 的 localID 是嵌套 prefab 内根节点 fileId(见地雷 3)。
|
||||
const targetLocalId = _getStubInnerRootFileId(prefabData, prefabInfo);
|
||||
const result = [];
|
||||
|
||||
if (!Array.isArray(prefabInstance.propertyOverrides)) return result;
|
||||
|
||||
for (const overrideRef of prefabInstance.propertyOverrides) {
|
||||
if (typeof overrideRef.__id__ !== 'number') continue;
|
||||
const info = elements[overrideRef.__id__];
|
||||
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
|
||||
|
||||
const targetInfoRef = info.targetInfo;
|
||||
if (!targetInfoRef || typeof targetInfoRef.__id__ !== 'number') continue;
|
||||
const targetInfo = elements[targetInfoRef.__id__];
|
||||
if (!targetInfo || targetInfo.__type__ !== 'cc.TargetInfo') continue;
|
||||
if (!Array.isArray(targetInfo.localID) || targetInfo.localID[0] !== targetLocalId) continue;
|
||||
|
||||
result.push({
|
||||
propertyPath: [...(info.propertyPath || [])],
|
||||
value: info.value,
|
||||
targetFileId: targetInfo.localID[0],
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 nestedPrefabInstanceRoots(地雷 2)
|
||||
*
|
||||
* 宿主 prefab 的根节点 PrefabInfo.nestedPrefabInstanceRoots 必须包含所有嵌套 stub 节点。
|
||||
* 调用此函数后会从 elements 中自动扫描所有 cc.PrefabInstance,
|
||||
* 找到对应的 stub 节点 __id__,并更新根节点 PrefabInfo 的 nestedPrefabInstanceRoots。
|
||||
*
|
||||
* 通常在新增 stub 节点后调用一次即可。
|
||||
*
|
||||
* @param {object} prefabData parsePrefab 的返回值
|
||||
*/
|
||||
function syncNestedPrefabInstanceRoots(prefabData) {
|
||||
const { elements, rootId } = prefabData;
|
||||
|
||||
// 找根节点 PrefabInfo(root 指向自己的那个)
|
||||
const rootNode = elements[rootId];
|
||||
if (!rootNode) throw new Error('syncNestedPrefabInstanceRoots: 根节点不存在');
|
||||
|
||||
const rootPrefabRef = rootNode._prefab;
|
||||
if (!rootPrefabRef || typeof rootPrefabRef.__id__ !== 'number') {
|
||||
throw new Error('syncNestedPrefabInstanceRoots: 根节点没有 _prefab 引用');
|
||||
}
|
||||
|
||||
const rootPrefabInfo = elements[rootPrefabRef.__id__];
|
||||
if (!rootPrefabInfo || rootPrefabInfo.__type__ !== 'cc.PrefabInfo') {
|
||||
throw new Error('syncNestedPrefabInstanceRoots: 根节点 _prefab 不是 cc.PrefabInfo');
|
||||
}
|
||||
|
||||
// 收集所有 stub 节点 __id__(有 PrefabInstance 的 cc.Node)
|
||||
const stubIds = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
if (!el._prefab || typeof el._prefab.__id__ !== 'number') continue;
|
||||
const pi = elements[el._prefab.__id__];
|
||||
if (!pi || pi.__type__ !== 'cc.PrefabInfo') continue;
|
||||
if (!pi.instance || typeof pi.instance.__id__ !== 'number') continue;
|
||||
const inst = elements[pi.instance.__id__];
|
||||
if (!inst || inst.__type__ !== 'cc.PrefabInstance') continue;
|
||||
// 这是一个 stub 节点
|
||||
stubIds.push(i);
|
||||
}
|
||||
|
||||
rootPrefabInfo.nestedPrefabInstanceRoots = stubIds.length > 0
|
||||
? stubIds.map((id) => ({ __id__: id }))
|
||||
: null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setOverrideProperty,
|
||||
listOverrides,
|
||||
syncNestedPrefabInstanceRoots,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
// ============================================================
|
||||
// CC3 Prefab 解析器(纯 CJS,零三方依赖)
|
||||
// 读取 prefab JSON → 构建 __id__ 索引 → 暴露查询接口
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* 解析 prefab 文件
|
||||
*
|
||||
* 返回对象结构:
|
||||
* {
|
||||
* raw: string, // 原始文件内容(供 write.js 保留格式用)
|
||||
* elements: object[], // 顶层数组(原始引用,可直接修改)
|
||||
* rootId: number, // cc.Prefab data 指向的根节点 __id__
|
||||
* findNodeByName(name), // 递归按 _name 查首个匹配节点(返回 element)
|
||||
* findNodesByType(type), // 按 __type__ 查所有匹配 element
|
||||
* getRoot(), // 返回根 cc.Node element
|
||||
* resolveRef(refObj), // { __id__: N } → element
|
||||
* }
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @returns {PrefabData}
|
||||
*/
|
||||
function parsePrefab(filePath) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
let elements;
|
||||
|
||||
try {
|
||||
elements = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`parsePrefab: JSON 解析失败(${filePath}): ${e.message}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(elements)) {
|
||||
throw new Error(`parsePrefab: 顶层不是数组(${filePath})`);
|
||||
}
|
||||
|
||||
// 构建 __id__ → element 的 O(1) 索引
|
||||
// CC3 prefab 数组下标即 __id__,无需额外映射,但封装成函数方便维护
|
||||
// 同时校验数组长度
|
||||
const idIndex = elements; // 直接按下标访问即可
|
||||
|
||||
// 找 cc.Prefab 资产头(通常在 index 0),取 rootId
|
||||
let rootId = -1;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (el && typeof el === 'object' && el.__type__ === 'cc.Prefab') {
|
||||
if (el.data && typeof el.data.__id__ === 'number') {
|
||||
rootId = el.data.__id__;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootId < 0) {
|
||||
throw new Error(`parsePrefab: 未找到 cc.Prefab 头或 data.__id__(${filePath})`);
|
||||
}
|
||||
|
||||
// ─── 辅助:按 __id__ 解引用 ───────────────────────────────
|
||||
function resolveRef(refObj) {
|
||||
if (!refObj || typeof refObj.__id__ !== 'number') {
|
||||
throw new Error(`resolveRef: 参数不是有效引用对象: ${JSON.stringify(refObj)}`);
|
||||
}
|
||||
const el = idIndex[refObj.__id__];
|
||||
if (el === undefined) {
|
||||
throw new Error(`resolveRef: __id__ ${refObj.__id__} 超出数组范围`);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// ─── 辅助:收集节点的所有子节点(递归) ─────────────────
|
||||
function _collectChildren(node, visited) {
|
||||
if (!node || !Array.isArray(node._children)) return [];
|
||||
const result = [];
|
||||
for (const childRef of node._children) {
|
||||
if (typeof childRef.__id__ !== 'number') continue;
|
||||
const id = childRef.__id__;
|
||||
if (visited.has(id)) continue; // 防环
|
||||
visited.add(id);
|
||||
const child = idIndex[id];
|
||||
if (child) {
|
||||
result.push(child);
|
||||
result.push(..._collectChildren(child, visited));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── getRoot ─────────────────────────────────────────────
|
||||
function getRoot() {
|
||||
return idIndex[rootId];
|
||||
}
|
||||
|
||||
// ─── findNodeByName ───────────────────────────────────────
|
||||
// 从根节点递归 DFS,返回第一个 _name 匹配的 cc.Node
|
||||
function findNodeByName(name) {
|
||||
const root = getRoot();
|
||||
if (!root) return null;
|
||||
return _findByName(root, name, new Set([rootId]));
|
||||
}
|
||||
|
||||
function _findByName(node, name, visited) {
|
||||
if (node._name === name) return node;
|
||||
if (!Array.isArray(node._children)) return null;
|
||||
for (const childRef of node._children) {
|
||||
if (typeof childRef.__id__ !== 'number') continue;
|
||||
const id = childRef.__id__;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
const child = idIndex[id];
|
||||
if (!child) continue;
|
||||
const found = _findByName(child, name, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── findNodesByType ──────────────────────────────────────
|
||||
// 遍历整个数组,按 __type__ 过滤(不限于节点树)
|
||||
function findNodesByType(type) {
|
||||
return elements.filter(
|
||||
(el) => el && typeof el === 'object' && el.__type__ === type
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
elements,
|
||||
rootId,
|
||||
findNodeByName,
|
||||
findNodesByType,
|
||||
getRoot,
|
||||
resolveRef,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parsePrefab };
|
||||
@@ -0,0 +1,476 @@
|
||||
// ============================================================
|
||||
// CC3 Prefab 对象构建原语(纯 CJS,零三方依赖)
|
||||
// 输入:朴素参数(name/pos/size 等)
|
||||
// 输出:可直接插入 prefab 数组的裸对象
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 基础数据类型工厂
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/** @param {number} x @param {number} y @param {number} [z] */
|
||||
function vec3(x, y, z = 0) {
|
||||
return { __type__: 'cc.Vec3', x, y, z };
|
||||
}
|
||||
|
||||
/** @param {number} w @param {number} h */
|
||||
function ccSize(w, h) {
|
||||
return { __type__: 'cc.Size', width: w, height: h };
|
||||
}
|
||||
|
||||
/** @param {number} x @param {number} y */
|
||||
function vec2(x, y) {
|
||||
return { __type__: 'cc.Vec2', x, y };
|
||||
}
|
||||
|
||||
/** @param {number} r @param {number} g @param {number} b @param {number} [a] */
|
||||
function ccColor(r, g, b, a = 255) {
|
||||
return { __type__: 'cc.Color', r, g, b, a };
|
||||
}
|
||||
|
||||
/** @param {number} id */
|
||||
function ref(id) {
|
||||
return { __id__: id };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.Node
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造 cc.Node 裸对象
|
||||
* @param {object} opts
|
||||
* @param {string} opts.name - 节点名称
|
||||
* @param {number[]} [opts.pos] - 本地位置 [x, y, z],默认 [0,0,0]
|
||||
* @param {number[]} [opts.scale] - 本地缩放 [x, y, z],默认 [1,1,1]
|
||||
* @param {boolean} [opts.active] - 是否激活,默认 true
|
||||
* @param {number} [opts.layer] - 渲染层,默认 33554432(UI 层)
|
||||
* @param {number|null} [opts.parentId] - 父节点 __id__,null 表示根节点
|
||||
* @param {number[]} [opts.childIds] - 子节点 __id__ 数组
|
||||
* @param {number[]} [opts.componentIds] - 组件 __id__ 数组
|
||||
* @param {number|null} [opts.prefabId] - cc.PrefabInfo 的 __id__
|
||||
* @returns {object}
|
||||
*/
|
||||
function makeNode(opts) {
|
||||
const {
|
||||
name,
|
||||
pos = [0, 0, 0],
|
||||
scale = [1, 1, 1],
|
||||
active = true,
|
||||
layer = 33554432,
|
||||
parentId = null,
|
||||
childIds = [],
|
||||
componentIds = [],
|
||||
prefabId = null,
|
||||
} = opts;
|
||||
|
||||
return {
|
||||
__type__: 'cc.Node',
|
||||
_name: name,
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_parent: parentId !== null ? ref(parentId) : null,
|
||||
_children: childIds.map(ref),
|
||||
_active: active,
|
||||
_components: componentIds.map(ref),
|
||||
_prefab: prefabId !== null ? ref(prefabId) : null,
|
||||
_lpos: vec3(pos[0] ?? 0, pos[1] ?? 0, pos[2] ?? 0),
|
||||
_lrot: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 },
|
||||
_lscale: vec3(scale[0] ?? 1, scale[1] ?? 1, scale[2] ?? 1),
|
||||
_mobility: 0,
|
||||
_layer: layer,
|
||||
_euler: vec3(0, 0, 0),
|
||||
_id: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.UITransform
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造 cc.UITransform 裸对象
|
||||
* @param {object} opts
|
||||
* @param {number} opts.nodeId - 所属节点 __id__
|
||||
* @param {number} opts.width - 宽度
|
||||
* @param {number} opts.height - 高度
|
||||
* @param {number[]} [opts.anchor] - 锚点 [x, y],默认 [0.5, 0.5]
|
||||
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
|
||||
* @returns {object}
|
||||
*/
|
||||
function makeUITransform(opts) {
|
||||
const {
|
||||
nodeId,
|
||||
width,
|
||||
height,
|
||||
anchor = [0.5, 0.5],
|
||||
prefabInfoId = null,
|
||||
} = opts;
|
||||
|
||||
const obj = {
|
||||
__type__: 'cc.UITransform',
|
||||
_name: '',
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: ref(nodeId),
|
||||
_enabled: true,
|
||||
_contentSize: ccSize(width, height),
|
||||
_anchorPoint: vec2(anchor[0] ?? 0.5, anchor[1] ?? 0.5),
|
||||
_id: '',
|
||||
};
|
||||
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.Sprite
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造 cc.Sprite 裸对象
|
||||
* @param {object} opts
|
||||
* @param {number} opts.nodeId - 所属节点 __id__
|
||||
* @param {string} [opts.spriteFrameUuid] - 图片 UUID(格式 "uuid@f9941"),null 表示无图
|
||||
* @param {number} [opts.type] - 0=SIMPLE(默认)/1=SLICED/2=TILED/3=FILLED
|
||||
* @param {number[]} [opts.color] - [r,g,b,a],默认白色不透明
|
||||
* @param {boolean} [opts.isTrimmedMode] - 默认 true
|
||||
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
|
||||
* @returns {object}
|
||||
*/
|
||||
function makeSprite(opts) {
|
||||
const {
|
||||
nodeId,
|
||||
spriteFrameUuid = null,
|
||||
type = 0,
|
||||
color = [255, 255, 255, 255],
|
||||
isTrimmedMode = true,
|
||||
prefabInfoId = null,
|
||||
} = opts;
|
||||
|
||||
const obj = {
|
||||
__type__: 'cc.Sprite',
|
||||
_name: '',
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: ref(nodeId),
|
||||
_enabled: true,
|
||||
_customMaterial: null,
|
||||
_srcBlendFactor: 2,
|
||||
_dstBlendFactor: 4,
|
||||
_color: ccColor(color[0] ?? 255, color[1] ?? 255, color[2] ?? 255, color[3] ?? 255),
|
||||
_spriteFrame: spriteFrameUuid
|
||||
? { __uuid__: spriteFrameUuid, __expectedType__: 'cc.SpriteFrame' }
|
||||
: null,
|
||||
_type: type,
|
||||
_fillType: 0,
|
||||
_sizeMode: 0,
|
||||
_fillCenter: vec2(0, 0),
|
||||
_fillStart: 0,
|
||||
_fillRange: 0,
|
||||
_isTrimmedMode: isTrimmedMode,
|
||||
_useGrayscale: false,
|
||||
_atlas: null,
|
||||
_id: '',
|
||||
};
|
||||
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.Label
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造 cc.Label 裸对象
|
||||
* @param {object} opts
|
||||
* @param {number} opts.nodeId - 所属节点 __id__
|
||||
* @param {string} [opts.string] - 文字内容,默认空字符串
|
||||
* @param {number} [opts.fontSize] - 字号,默认 20
|
||||
* @param {number} [opts.lineHeight] - 行高,0 表示自动
|
||||
* @param {number} [opts.horizontalAlign] - 0=LEFT/1=CENTER/2=RIGHT,默认 1(CENTER)
|
||||
* @param {number} [opts.verticalAlign] - 0=TOP/1=CENTER/2=BOTTOM,默认 1(CENTER)
|
||||
* @param {number} [opts.overflow] - 0=NONE/1=CLAMP/2=SHRINK/3=RESIZE_HEIGHT,默认 0
|
||||
* @param {string|null} [opts.fontUuid] - 字体资产 UUID,null 使用系统字体
|
||||
* @param {number[]} [opts.color] - [r,g,b,a],默认黑色不透明
|
||||
* @param {boolean} [opts.enableOutline] - 是否启用描边,默认 false
|
||||
* @param {number[]} [opts.outlineColor] - 描边颜色 [r,g,b,a]
|
||||
* @param {number} [opts.outlineWidth] - 描边宽度,默认 2
|
||||
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
|
||||
* @returns {object}
|
||||
*/
|
||||
function makeLabel(opts) {
|
||||
const {
|
||||
nodeId,
|
||||
string = '',
|
||||
fontSize = 20,
|
||||
lineHeight = 0,
|
||||
horizontalAlign = 1,
|
||||
verticalAlign = 1,
|
||||
overflow = 0,
|
||||
fontUuid = null,
|
||||
color = [0, 0, 0, 255],
|
||||
enableOutline = false,
|
||||
outlineColor = [0, 0, 0, 255],
|
||||
outlineWidth = 2,
|
||||
prefabInfoId = null,
|
||||
} = opts;
|
||||
|
||||
const obj = {
|
||||
__type__: 'cc.Label',
|
||||
_name: '',
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: ref(nodeId),
|
||||
_enabled: true,
|
||||
_customMaterial: null,
|
||||
_srcBlendFactor: 2,
|
||||
_dstBlendFactor: 4,
|
||||
_color: ccColor(color[0] ?? 0, color[1] ?? 0, color[2] ?? 0, color[3] ?? 255),
|
||||
_string: string,
|
||||
_horizontalAlign: horizontalAlign,
|
||||
_verticalAlign: verticalAlign,
|
||||
_actualFontSize: fontSize,
|
||||
_fontSize: fontSize,
|
||||
_fontFamily: 'Arial',
|
||||
_lineHeight: lineHeight,
|
||||
_overflow: overflow,
|
||||
_enableWrapText: true,
|
||||
_font: fontUuid ? { __uuid__: fontUuid, __expectedType__: 'cc.Font' } : null,
|
||||
_isSystemFontUsed: fontUuid === null,
|
||||
_isItalic: false,
|
||||
_isBold: false,
|
||||
_isUnderline: false,
|
||||
_cacheMode: 0,
|
||||
_enableOutline: enableOutline,
|
||||
_outlineColor: ccColor(
|
||||
outlineColor[0] ?? 0,
|
||||
outlineColor[1] ?? 0,
|
||||
outlineColor[2] ?? 0,
|
||||
outlineColor[3] ?? 255
|
||||
),
|
||||
_outlineWidth: outlineWidth,
|
||||
_id: '',
|
||||
};
|
||||
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.Widget
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造 cc.Widget 裸对象
|
||||
*
|
||||
* alignFlags 位掩码(可组合):
|
||||
* LEFT=1, RIGHT=2, TOP=4, BOTTOM=8, HORIZONTAL_CENTER=16, VERTICAL_CENTER=32
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {number} opts.nodeId - 所属节点 __id__
|
||||
* @param {number} [opts.alignFlags] - 对齐标志位掩码,默认 0(无对齐)
|
||||
* @param {number} [opts.left] - 左边距
|
||||
* @param {number} [opts.right] - 右边距
|
||||
* @param {number} [opts.top] - 上边距
|
||||
* @param {number} [opts.bottom] - 下边距
|
||||
* @param {boolean} [opts.isAbsLeft] - left 是否为绝对像素,默认 true
|
||||
* @param {boolean} [opts.isAbsRight] - right 是否为绝对像素,默认 true
|
||||
* @param {boolean} [opts.isAbsTop] - top 是否为绝对像素,默认 true
|
||||
* @param {boolean} [opts.isAbsBottom] - bottom 是否为绝对像素,默认 true
|
||||
* @param {boolean} [opts.isAbsHorizontalCenter] - horizontalCenter 是否为绝对像素,默认 true
|
||||
* @param {boolean} [opts.isAbsVerticalCenter] - verticalCenter 是否为绝对像素,默认 true
|
||||
* @param {number} [opts.horizontalCenter] - 水平居中偏移
|
||||
* @param {number} [opts.verticalCenter] - 垂直居中偏移
|
||||
* @param {number} [opts.alignMode] - 0=ONCE/1=ON_WINDOW_RESIZE/2=ALWAYS,默认 1
|
||||
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
|
||||
* @returns {object}
|
||||
*/
|
||||
function makeWidget(opts) {
|
||||
const {
|
||||
nodeId,
|
||||
alignFlags = 0,
|
||||
left = 0,
|
||||
right = 0,
|
||||
top = 0,
|
||||
bottom = 0,
|
||||
isAbsLeft = true,
|
||||
isAbsRight = true,
|
||||
isAbsTop = true,
|
||||
isAbsBottom = true,
|
||||
isAbsHorizontalCenter = true,
|
||||
isAbsVerticalCenter = true,
|
||||
horizontalCenter = 0,
|
||||
verticalCenter = 0,
|
||||
alignMode = 1,
|
||||
prefabInfoId = null,
|
||||
} = opts;
|
||||
|
||||
const obj = {
|
||||
__type__: 'cc.Widget',
|
||||
_name: '',
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: ref(nodeId),
|
||||
_enabled: true,
|
||||
_alignFlags: alignFlags,
|
||||
_target: null,
|
||||
_left: left,
|
||||
_right: right,
|
||||
_top: top,
|
||||
_bottom: bottom,
|
||||
_horizontalCenter: horizontalCenter,
|
||||
_verticalCenter: verticalCenter,
|
||||
_isAbsLeft: isAbsLeft,
|
||||
_isAbsRight: isAbsRight,
|
||||
_isAbsTop: isAbsTop,
|
||||
_isAbsBottom: isAbsBottom,
|
||||
_isAbsHorizontalCenter: isAbsHorizontalCenter,
|
||||
_isAbsVerticalCenter: isAbsVerticalCenter,
|
||||
_originalWidth: 0,
|
||||
_originalHeight: 0,
|
||||
_alignMode: alignMode,
|
||||
_lockFlags: 0,
|
||||
_id: '',
|
||||
};
|
||||
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// sp.Skeleton
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造 sp.Skeleton 裸对象
|
||||
*
|
||||
* 给 spine prefab 用。运行时通过 loadAsset<Prefab> + instantiate +
|
||||
* getComponent(sp.Skeleton) 获取并播放动画。
|
||||
*
|
||||
* 字段默认值与 Cocos 编辑器从右键菜单挂 sp.Skeleton 的产物保持一致:
|
||||
* 缓存策略 PRIVATE_CACHE(1)、blend func 2/4、_useTint/_premultipliedAlpha
|
||||
* 等均为 false / 默认。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {number} opts.nodeId - 所属节点 __id__
|
||||
* @param {string} opts.skeletonUuid - .skel 资产 UUID(cc.AssetManager 注册的资源 ID)
|
||||
* @param {number} [opts.prefabInfoId] - cc.CompPrefabInfo 的 __id__
|
||||
* @returns {object}
|
||||
*/
|
||||
function makeSpSkeleton(opts) {
|
||||
const { nodeId, skeletonUuid, prefabInfoId = null } = opts;
|
||||
const obj = {
|
||||
__type__: 'sp.Skeleton',
|
||||
_name: '',
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: ref(nodeId),
|
||||
_enabled: true,
|
||||
_customMaterial: null,
|
||||
_srcBlendFactor: 2,
|
||||
_dstBlendFactor: 4,
|
||||
_color: ccColor(255, 255, 255, 255),
|
||||
_skeletonData: { __uuid__: skeletonUuid, __expectedType__: 'sp.SkeletonData' },
|
||||
defaultSkin: '',
|
||||
defaultAnimation: '',
|
||||
_premultipliedAlpha: false,
|
||||
_timeScale: 1,
|
||||
_preCacheMode: 1,
|
||||
_cacheMode: 1,
|
||||
_defaultCacheMode: 1,
|
||||
_sockets: [],
|
||||
_useTint: false,
|
||||
_debugMesh: false,
|
||||
_debugBones: false,
|
||||
_debugSlots: false,
|
||||
_enableBatch: false,
|
||||
loop: false,
|
||||
_id: '',
|
||||
};
|
||||
if (prefabInfoId !== null) obj.__prefab = ref(prefabInfoId);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// cc.PrefabInfo / cc.CompPrefabInfo
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 构造节点的 cc.PrefabInfo 裸对象(非嵌套普通节点用)
|
||||
* @param {object} opts
|
||||
* @param {number} opts.rootId - 根节点 __id__(通常为 1)
|
||||
* @param {string} opts.fileId - 该节点在 prefab 内的唯一 ID(base64 22-24字符)
|
||||
* @param {number} [opts.assetId] - cc.Prefab 资产 __id__,默认 0
|
||||
* @param {number[]|null} [opts.nestedPrefabInstanceRoots] - 嵌套 stub 节点索引,根节点 PrefabInfo 用
|
||||
* @returns {object}
|
||||
*/
|
||||
function makePrefabInfo(opts) {
|
||||
const {
|
||||
rootId,
|
||||
fileId,
|
||||
assetId = 0,
|
||||
nestedPrefabInstanceRoots = null,
|
||||
} = opts;
|
||||
|
||||
return {
|
||||
__type__: 'cc.PrefabInfo',
|
||||
root: ref(rootId),
|
||||
asset: ref(assetId),
|
||||
fileId,
|
||||
instance: null,
|
||||
targetOverrides: null,
|
||||
nestedPrefabInstanceRoots: nestedPrefabInstanceRoots
|
||||
? nestedPrefabInstanceRoots.map(ref)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造组件的 cc.CompPrefabInfo 裸对象
|
||||
* @param {string} fileId - 该组件在 prefab 内的唯一 ID
|
||||
* @returns {object}
|
||||
*/
|
||||
function makeCompPrefabInfo(fileId) {
|
||||
return { __type__: 'cc.CompPrefabInfo', fileId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 cc.Prefab 文件头对象(下标 0)
|
||||
* @param {object} opts
|
||||
* @param {string} opts.name - prefab 名称
|
||||
* @param {number} opts.rootId - 根节点 __id__(通常为 1)
|
||||
* @returns {object}
|
||||
*/
|
||||
function makePrefabRoot(opts) {
|
||||
const { name, rootId = 1 } = opts;
|
||||
return {
|
||||
__type__: 'cc.Prefab',
|
||||
_name: name,
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_native: '',
|
||||
data: ref(rootId),
|
||||
optimizationPolicy: 0,
|
||||
persistent: false,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// 基础类型工厂(供外部使用)
|
||||
vec3,
|
||||
vec2,
|
||||
ccSize,
|
||||
ccColor,
|
||||
ref,
|
||||
// 节点/组件构建
|
||||
makeNode,
|
||||
makeUITransform,
|
||||
makeSprite,
|
||||
makeLabel,
|
||||
makeWidget,
|
||||
makeSpSkeleton,
|
||||
// Prefab 元信息
|
||||
makePrefabInfo,
|
||||
makeCompPrefabInfo,
|
||||
makePrefabRoot,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// ============================================================
|
||||
// query/comp-fields.js — 组件字段提取(query 共用工具)
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
// 系统级字段(每个 cc 组件都有,对调试无信息量)过滤掉
|
||||
const RESERVED_COMP_FIELDS = new Set([
|
||||
'__type__',
|
||||
'_objFlags',
|
||||
'__editorExtras__',
|
||||
'node',
|
||||
'_enabled',
|
||||
'__prefab',
|
||||
'_id',
|
||||
]);
|
||||
|
||||
/** 提取组件的业务字段(过滤系统字段) */
|
||||
function extractCompFields(comp) {
|
||||
if (!comp || typeof comp !== 'object') return {};
|
||||
const fields = {};
|
||||
for (const key of Object.keys(comp)) {
|
||||
if (RESERVED_COMP_FIELDS.has(key)) continue;
|
||||
fields[key] = comp[key];
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** 节点 _components 列表 → [{type, id, fields}] */
|
||||
function componentDetails(elements, node) {
|
||||
if (!Array.isArray(node._components)) return [];
|
||||
const out = [];
|
||||
for (const ref of node._components) {
|
||||
if (typeof ref.__id__ !== 'number') continue;
|
||||
const comp = elements[ref.__id__];
|
||||
if (!comp) continue;
|
||||
out.push({
|
||||
type: comp.__type__,
|
||||
id: ref.__id__,
|
||||
fields: extractCompFields(comp),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 节点 _components 列表 → 类型名数组(轻量版,不展开字段) */
|
||||
function componentTypes(elements, node) {
|
||||
if (!Array.isArray(node._components)) return [];
|
||||
const types = [];
|
||||
for (const ref of node._components) {
|
||||
if (typeof ref.__id__ !== 'number') continue;
|
||||
const comp = elements[ref.__id__];
|
||||
if (comp && comp.__type__) types.push(comp.__type__);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
/** 判断节点是否是 stub(嵌套 prefab 根节点)— query 内部用 */
|
||||
function isStub(elements, node) {
|
||||
if (!node || node.__type__ !== 'cc.Node') return false;
|
||||
const prefabRef = node._prefab;
|
||||
if (!prefabRef || typeof prefabRef.__id__ !== 'number') return false;
|
||||
const prefabInfo = elements[prefabRef.__id__];
|
||||
if (!prefabInfo || prefabInfo.__type__ !== 'cc.PrefabInfo') return false;
|
||||
const instanceRef = prefabInfo.instance;
|
||||
if (!instanceRef || typeof instanceRef.__id__ !== 'number') return false;
|
||||
const instance = elements[instanceRef.__id__];
|
||||
return !!(instance && instance.__type__ === 'cc.PrefabInstance');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractCompFields,
|
||||
componentDetails,
|
||||
componentTypes,
|
||||
isStub,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// query/field.js — 单组件单字段值(脚本管道用)
|
||||
|
||||
'use strict';
|
||||
|
||||
function queryField(prefabData, args) {
|
||||
const { elements } = prefabData;
|
||||
const { name, componentType, field } = args;
|
||||
if (!name) throw new Error('queryPrefab: selector.type="field" 必须提供 name');
|
||||
if (!componentType) throw new Error('queryPrefab: selector.type="field" 必须提供 componentType');
|
||||
if (!field) throw new Error('queryPrefab: selector.type="field" 必须提供 field');
|
||||
|
||||
const node = prefabData.findNodeByName(name);
|
||||
if (!node) throw new Error(`queryPrefab[field]: 找不到节点 "${name}"`);
|
||||
|
||||
if (!Array.isArray(node._components)) {
|
||||
throw new Error(`queryPrefab[field]: 节点 "${name}" 没有组件`);
|
||||
}
|
||||
for (const ref of node._components) {
|
||||
if (typeof ref.__id__ !== 'number') continue;
|
||||
const comp = elements[ref.__id__];
|
||||
if (!comp || comp.__type__ !== componentType) continue;
|
||||
if (!(field in comp)) {
|
||||
throw new Error(
|
||||
`queryPrefab[field]: 节点 "${name}" 的 ${componentType} 没有字段 "${field}"`
|
||||
);
|
||||
}
|
||||
return comp[field];
|
||||
}
|
||||
throw new Error(`queryPrefab[field]: 节点 "${name}" 没有 ${componentType} 组件`);
|
||||
}
|
||||
|
||||
module.exports = { queryField };
|
||||
@@ -0,0 +1,17 @@
|
||||
// query/find.js — 按 __type__ 列出所有匹配 element 的 id
|
||||
|
||||
'use strict';
|
||||
|
||||
function queryFind(prefabData, nodeType) {
|
||||
const { elements } = prefabData;
|
||||
const ids = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (el && typeof el === 'object' && el.__type__ === nodeType) {
|
||||
ids.push(i);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
module.exports = { queryFind };
|
||||
@@ -0,0 +1,56 @@
|
||||
// ============================================================
|
||||
// query/index.js — 只读查询主入口
|
||||
//
|
||||
// queryPrefab(filePath, selector)
|
||||
// selector.type = 'tree' → 节点树(默认;selector.withComps 展开组件字段)
|
||||
// selector.type = 'node' → 单节点详情(同上 withComps)
|
||||
// selector.type = 'find' → 列所有 __type__ 匹配的 id
|
||||
// selector.type = 'field' → 单组件单字段值
|
||||
// selector.type = 'overrides' → 列 stub 节点 propertyOverrides + root targetOverrides
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const { parsePrefab } = require('../parse.js');
|
||||
const { queryTree } = require('./tree.js');
|
||||
const { queryNode } = require('./node.js');
|
||||
const { queryFind } = require('./find.js');
|
||||
const { queryField } = require('./field.js');
|
||||
const { queryOverrides } = require('./overrides.js');
|
||||
|
||||
function queryPrefab(filePath, selector) {
|
||||
const prefabData = parsePrefab(filePath);
|
||||
// overrides 需要按 uuid 反查嵌套 prefab,必须知道 host path 用作 UuidResolver 起点
|
||||
prefabData.resolverStartPath = filePath;
|
||||
|
||||
const type = selector && selector.type ? selector.type : 'tree';
|
||||
const opts = { withComps: !!(selector && selector.withComps) };
|
||||
|
||||
if (type === 'tree') {
|
||||
return queryTree(prefabData, opts);
|
||||
}
|
||||
|
||||
if (type === 'node') {
|
||||
const name = selector && selector.name;
|
||||
if (!name) throw new Error('queryPrefab: selector.type="node" 时必须提供 selector.name');
|
||||
return queryNode(prefabData, name, opts);
|
||||
}
|
||||
|
||||
if (type === 'find') {
|
||||
const nodeType = selector && selector.nodeType;
|
||||
if (!nodeType) throw new Error('queryPrefab: selector.type="find" 时必须提供 selector.nodeType');
|
||||
return queryFind(prefabData, nodeType);
|
||||
}
|
||||
|
||||
if (type === 'field') {
|
||||
return queryField(prefabData, selector);
|
||||
}
|
||||
|
||||
if (type === 'overrides') {
|
||||
return queryOverrides(prefabData, selector);
|
||||
}
|
||||
|
||||
throw new Error(`queryPrefab: 未知 selector.type "${type}",支持 tree / node / find / field / overrides`);
|
||||
}
|
||||
|
||||
module.exports = { queryPrefab };
|
||||
@@ -0,0 +1,68 @@
|
||||
// query/node.js — 按名称查单节点详情
|
||||
|
||||
'use strict';
|
||||
|
||||
const { listOverrides } = require('../overrides.js');
|
||||
const { isStub, componentTypes, componentDetails } = require('./comp-fields.js');
|
||||
|
||||
function queryNode(prefabData, name, opts) {
|
||||
const { elements } = prefabData;
|
||||
const withComps = !!(opts && opts.withComps);
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (!el || el.__type__ !== 'cc.Node') continue;
|
||||
|
||||
const stub = isStub(elements, el);
|
||||
let resolvedName = el._name;
|
||||
|
||||
// stub 节点的实际名称可能存在 overrides 的 _name 字段
|
||||
if (stub && resolvedName === undefined) {
|
||||
try {
|
||||
const ovs = listOverrides(prefabData, i);
|
||||
const nameOv = ovs.find(
|
||||
(o) => o.propertyPath.length === 1 && o.propertyPath[0] === '_name'
|
||||
);
|
||||
if (nameOv) resolvedName = nameOv.value;
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedName !== name) continue;
|
||||
|
||||
// stub 节点:把身份信息(isStub/stubAsset/overrides)放在 raw 之前,
|
||||
// 避免被 raw 大对象淹没。stub 节点的 _components / _children 字段在 raw 里是空的,
|
||||
// 真实组件/子节点都属于被引用 prefab 内部,不在当前文件里。
|
||||
const result = {
|
||||
id: i,
|
||||
name: resolvedName,
|
||||
type: el.__type__,
|
||||
active: el._active !== undefined ? el._active : null,
|
||||
isStub: stub,
|
||||
};
|
||||
if (stub) {
|
||||
const prefabInfo = elements[el._prefab.__id__];
|
||||
result.stubAsset = prefabInfo && prefabInfo.asset && prefabInfo.asset.__uuid__
|
||||
? prefabInfo.asset.__uuid__
|
||||
: null;
|
||||
try {
|
||||
result.overrides = listOverrides(prefabData, i);
|
||||
} catch (_) {
|
||||
result.overrides = [];
|
||||
}
|
||||
result._note = 'stub 节点本身 _components/_children 为空;真实组件和子树在被引用 prefab 内(见 stubAsset),改 stub 内部字段用 set-nested-component-field / set-component-ref refSubNode';
|
||||
}
|
||||
result.componentTypes = componentTypes(elements, el);
|
||||
if (withComps) {
|
||||
result.components = componentDetails(elements, el);
|
||||
}
|
||||
result.raw = el;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { queryNode };
|
||||
@@ -0,0 +1,158 @@
|
||||
// query/overrides.js — 列出 stub 节点当前所有 propertyOverrides + 关联的 root targetOverrides
|
||||
//
|
||||
// 输出:每条 override 标注落点(stub 自身节点字段 / 嵌套内某组件字段 / 嵌套内某节点字段),
|
||||
// 配合 reset-overrides op 调试/回滚。
|
||||
//
|
||||
// args:
|
||||
// - node: 节点 selector(name / id / {id} / {path});必须是 stub
|
||||
//
|
||||
// 输出结构:
|
||||
// {
|
||||
// stubNodeId, stubFileId, nestedPrefab,
|
||||
// propertyOverrides: [
|
||||
// { target: { kind, ...}, propertyPath, value }
|
||||
// ],
|
||||
// rootTargetOverrides: [
|
||||
// { source: {id}, propertyPath, target: {...}, localIDChain }
|
||||
// ]
|
||||
// }
|
||||
|
||||
'use strict';
|
||||
|
||||
const { parsePrefab } = require('../parse.js');
|
||||
const { resolveUuidToPath } = require('../uuid-resolver.js');
|
||||
const { resolveNode, isStub } = require('../editor/helpers.js');
|
||||
|
||||
function _buildFileIdIndex(nestedElements) {
|
||||
const index = new Map();
|
||||
for (let i = 0; i < nestedElements.length; i++) {
|
||||
const el = nestedElements[i];
|
||||
if (!el) continue;
|
||||
|
||||
// 节点 PrefabInfo.fileId
|
||||
if (el.__type__ === 'cc.Node' && el._prefab && typeof el._prefab.__id__ === 'number') {
|
||||
const pi = nestedElements[el._prefab.__id__];
|
||||
if (pi && pi.__type__ === 'cc.PrefabInfo' && typeof pi.fileId === 'string') {
|
||||
index.set(pi.fileId, {
|
||||
kind: 'nested-node',
|
||||
nodeName: el._name || (el._parent ? null : '(root)'),
|
||||
nodeId: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 组件 CompPrefabInfo.fileId
|
||||
if (el.__type__ && el.__prefab && typeof el.__prefab.__id__ === 'number') {
|
||||
const cpi = nestedElements[el.__prefab.__id__];
|
||||
if (cpi && cpi.__type__ === 'cc.CompPrefabInfo' && typeof cpi.fileId === 'string') {
|
||||
const ownerNode = el.node && typeof el.node.__id__ === 'number'
|
||||
? nestedElements[el.node.__id__] : null;
|
||||
index.set(cpi.fileId, {
|
||||
kind: 'nested-component',
|
||||
componentType: el.__type__,
|
||||
ownerNodeName: ownerNode ? (ownerNode._name || '(root)') : null,
|
||||
ownerNodeId: ownerNode ? el.node.__id__ : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function queryOverrides(prefabData, args) {
|
||||
const { elements } = prefabData;
|
||||
const nodeSelector = args && args.node;
|
||||
if (nodeSelector === undefined || nodeSelector === null) {
|
||||
throw new Error('queryPrefab[overrides]: 必须提供 node');
|
||||
}
|
||||
|
||||
const { node, nodeId } = resolveNode(prefabData, nodeSelector, 'overrides');
|
||||
if (!isStub(elements, node)) {
|
||||
throw new Error(`queryPrefab[overrides]: 节点 [${nodeId}] 不是 stub(无嵌套 prefab)`);
|
||||
}
|
||||
|
||||
const prefabInfo = elements[node._prefab.__id__];
|
||||
const stubFileId = prefabInfo.fileId;
|
||||
const prefabInstance = elements[prefabInfo.instance.__id__];
|
||||
|
||||
// 加载嵌套 prefab,建 fileId 反查索引
|
||||
const nestedUuid = prefabInfo.asset && prefabInfo.asset.__uuid__;
|
||||
let nestedPath = null;
|
||||
let fileIdIndex = new Map();
|
||||
if (typeof nestedUuid === 'string') {
|
||||
try {
|
||||
nestedPath = resolveUuidToPath(nestedUuid, prefabData.resolverStartPath);
|
||||
const nestedData = parsePrefab(nestedPath);
|
||||
fileIdIndex = _buildFileIdIndex(nestedData.elements);
|
||||
} catch (_) {
|
||||
// 嵌套 prefab 加载失败不阻断查询,target 会被标 unknown
|
||||
}
|
||||
}
|
||||
|
||||
const propertyOverrides = [];
|
||||
if (Array.isArray(prefabInstance.propertyOverrides)) {
|
||||
for (const ref of prefabInstance.propertyOverrides) {
|
||||
if (!ref || typeof ref.__id__ !== 'number') continue;
|
||||
const info = elements[ref.__id__];
|
||||
if (!info || info.__type__ !== 'CCPropertyOverrideInfo') continue;
|
||||
const tiRef = info.targetInfo;
|
||||
const ti = tiRef && typeof tiRef.__id__ === 'number' ? elements[tiRef.__id__] : null;
|
||||
const localID = ti && Array.isArray(ti.localID) ? ti.localID : [];
|
||||
const fid = localID[0];
|
||||
|
||||
let target;
|
||||
if (fid === stubFileId) {
|
||||
target = { kind: 'stub-node-field', nodeName: node._name || null };
|
||||
} else {
|
||||
const entry = fileIdIndex.get(fid);
|
||||
target = entry ? { ...entry, fileId: fid } : { kind: 'unknown', fileId: fid };
|
||||
}
|
||||
if (localID.length > 1) {
|
||||
target.localIDChain = [...localID];
|
||||
}
|
||||
|
||||
propertyOverrides.push({
|
||||
target,
|
||||
propertyPath: [...(info.propertyPath || [])],
|
||||
value: info.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 关联此 stub 的 root targetOverrides(cc.TargetOverrideInfo)
|
||||
const rootTargetOverrides = [];
|
||||
const rootNode = elements[prefabData.rootId];
|
||||
if (rootNode && rootNode._prefab && typeof rootNode._prefab.__id__ === 'number') {
|
||||
const rootPi = elements[rootNode._prefab.__id__];
|
||||
if (rootPi && Array.isArray(rootPi.targetOverrides)) {
|
||||
for (const r of rootPi.targetOverrides) {
|
||||
if (!r || typeof r.__id__ !== 'number') continue;
|
||||
const ov = elements[r.__id__];
|
||||
if (!ov || ov.__type__ !== 'cc.TargetOverrideInfo') continue;
|
||||
if (!ov.target || ov.target.__id__ !== nodeId) continue;
|
||||
const ti = ov.targetInfo && typeof ov.targetInfo.__id__ === 'number'
|
||||
? elements[ov.targetInfo.__id__] : null;
|
||||
const localID = ti && Array.isArray(ti.localID) ? [...ti.localID] : [];
|
||||
const fid = localID[0];
|
||||
const entry = fileIdIndex.get(fid);
|
||||
const target = entry ? { ...entry, fileId: fid } : { kind: 'unknown', fileId: fid };
|
||||
rootTargetOverrides.push({
|
||||
source: { compId: ov.source ? ov.source.__id__ : null },
|
||||
propertyPath: [...(ov.propertyPath || [])],
|
||||
target,
|
||||
localIDChain: localID,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stubNodeId: nodeId,
|
||||
stubFileId,
|
||||
nestedPrefab: nestedPath,
|
||||
propertyOverrides,
|
||||
rootTargetOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { queryOverrides };
|
||||
@@ -0,0 +1,79 @@
|
||||
// query/tree.js — 精简节点树(递归 DFS)
|
||||
|
||||
'use strict';
|
||||
|
||||
const { listOverrides } = require('../overrides.js');
|
||||
const { isStub, componentTypes, componentDetails } = require('./comp-fields.js');
|
||||
|
||||
function buildTree(prefabData, nodeId, visited, opts) {
|
||||
const { elements } = prefabData;
|
||||
const node = elements[nodeId];
|
||||
|
||||
const stub = isStub(elements, node);
|
||||
const withComps = !!(opts && opts.withComps);
|
||||
|
||||
// stub 节点真实 _name 字段为 null,需要从 propertyOverrides 里反查 _name override
|
||||
let resolvedName = node._name !== undefined ? node._name : null;
|
||||
if (stub) {
|
||||
try {
|
||||
const ovs = listOverrides(prefabData, nodeId);
|
||||
const nameOv = ovs.find((o) => o.propertyPath.length === 1 && o.propertyPath[0] === '_name');
|
||||
if (nameOv) resolvedName = nameOv.value;
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// stub 节点 name 加 (stub) 后缀,肉眼一眼区分嵌套实例和普通节点
|
||||
// stub 无 _name override 时 resolvedName 为 null,显示纯 "(stub)" 字面值
|
||||
let displayName;
|
||||
if (stub) {
|
||||
displayName = resolvedName !== null ? `${resolvedName} (stub)` : '(stub)';
|
||||
} else {
|
||||
displayName = resolvedName;
|
||||
}
|
||||
|
||||
const treeNode = {
|
||||
id: nodeId,
|
||||
name: displayName,
|
||||
type: node.__type__,
|
||||
active: node._active !== undefined ? node._active : null,
|
||||
isStub: stub,
|
||||
};
|
||||
if (stub) {
|
||||
const prefabInfo = elements[node._prefab.__id__];
|
||||
treeNode.stubAsset = prefabInfo && prefabInfo.asset && prefabInfo.asset.__uuid__
|
||||
? prefabInfo.asset.__uuid__
|
||||
: null;
|
||||
try {
|
||||
treeNode.overrides = listOverrides(prefabData, nodeId);
|
||||
} catch (_) {
|
||||
treeNode.overrides = [];
|
||||
}
|
||||
}
|
||||
treeNode.children = [];
|
||||
if (withComps) {
|
||||
treeNode.components = componentDetails(elements, node);
|
||||
} else {
|
||||
treeNode.componentTypes = componentTypes(elements, node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node._children)) {
|
||||
for (const childRef of node._children) {
|
||||
if (typeof childRef.__id__ !== 'number') continue;
|
||||
const cid = childRef.__id__;
|
||||
if (visited.has(cid)) continue;
|
||||
visited.add(cid);
|
||||
treeNode.children.push(buildTree(prefabData, cid, visited, opts));
|
||||
}
|
||||
}
|
||||
|
||||
return treeNode;
|
||||
}
|
||||
|
||||
function queryTree(prefabData, opts) {
|
||||
const { rootId } = prefabData;
|
||||
const visited = new Set([rootId]);
|
||||
return buildTree(prefabData, rootId, visited, opts);
|
||||
}
|
||||
|
||||
module.exports = { queryTree };
|
||||
@@ -0,0 +1,162 @@
|
||||
// ============================================================
|
||||
// UuidResolver:惰性扫描 assets/ 下所有 .prefab.meta 文件
|
||||
// 构建 uuid → prefab 磁盘路径 索引,仅扫描一次后缓存于内存。
|
||||
//
|
||||
// 设计约束:
|
||||
// - 不引入第三方依赖(纯 Node.js fs + path + child_process)
|
||||
// - uuid 索引只扫一次,重复调用复用缓存
|
||||
// - 解析失败时明确抛错,不静默降级
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ─── 模块级缓存 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 缓存结构:Map<projectRoot, Map<uuid, absolutePrefabPath>>
|
||||
* 按项目根分隔,支持同进程内多项目(虽然实际上只会有一个)。
|
||||
*/
|
||||
const _cache = new Map();
|
||||
|
||||
// ─── 内部:定位 projectRoot ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* 从路径(文件或目录)往上查找项目根(含有 assets/ 子目录 + package.json)
|
||||
*
|
||||
* @param {string} startPath 绝对路径(文件或目录均可)
|
||||
* @returns {string} 项目根绝对路径
|
||||
* @throws 找不到时抛错
|
||||
*/
|
||||
function _findProjectRoot(startPath) {
|
||||
const resolved = path.resolve(startPath);
|
||||
// 若是文件取其目录,若是目录直接用
|
||||
let dir;
|
||||
try {
|
||||
dir = fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
|
||||
} catch (_) {
|
||||
// 路径不存在(如 tmp 文件已被删除)也从 dirname 开始
|
||||
dir = path.dirname(resolved);
|
||||
}
|
||||
|
||||
// 最多向上 20 层
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const hasAssets = fs.existsSync(path.join(dir, 'assets'));
|
||||
const hasPkg = fs.existsSync(path.join(dir, 'package.json'));
|
||||
if (hasAssets && hasPkg) return dir;
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break; // 到达文件系统根
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`UuidResolver: 无法从 "${startPath}" 向上找到项目根(含 assets/ + package.json 的目录)`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 内部:扫描并建立索引 ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 扫描 projectRoot/assets/ 下所有 .prefab.meta,建立 uuid → absolutePath 映射
|
||||
*
|
||||
* @param {string} projectRoot
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
function _buildIndex(projectRoot) {
|
||||
const assetsDir = path.join(projectRoot, 'assets');
|
||||
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
throw new Error(`UuidResolver: assets 目录不存在: ${assetsDir}`);
|
||||
}
|
||||
|
||||
// 用 find 命令比 Node 递归快,且无需手写 readdir 递归
|
||||
let metaFiles;
|
||||
try {
|
||||
const raw = execSync(
|
||||
`find "${assetsDir}" -name "*.prefab.meta" -type f`,
|
||||
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
||||
);
|
||||
metaFiles = raw.trim().split('\n').filter(Boolean);
|
||||
} catch (e) {
|
||||
throw new Error(`UuidResolver: find 命令失败: ${e.message}`);
|
||||
}
|
||||
|
||||
const index = new Map();
|
||||
|
||||
for (const metaFile of metaFiles) {
|
||||
let meta;
|
||||
try {
|
||||
meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
||||
} catch (_) {
|
||||
// meta 损坏时跳过,不中断整体扫描
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof meta.uuid !== 'string') continue;
|
||||
|
||||
// 对应的 prefab 路径 = 去掉 .meta 后缀
|
||||
const prefabPath = metaFile.replace(/\.meta$/, '');
|
||||
index.set(meta.uuid, prefabPath);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
// ─── 公开 API ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 根据路径推断项目根,返回 uuid → prefab 绝对路径 的 Map。
|
||||
* 多次调用同一 projectRoot 时,直接返回缓存,不重复扫描。
|
||||
*
|
||||
* @param {string} startPath 起点路径:可以是宿主 prefab 的绝对路径(从它向上找项目根),
|
||||
* 也可以直接是项目根目录(含 assets/ + package.json)。
|
||||
* 当 filePath 是 /tmp/ 临时文件时,应直接传入项目根目录。
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
function getUuidIndex(startPath) {
|
||||
const projectRoot = _findProjectRoot(startPath);
|
||||
|
||||
if (_cache.has(projectRoot)) {
|
||||
return _cache.get(projectRoot);
|
||||
}
|
||||
|
||||
const index = _buildIndex(projectRoot);
|
||||
_cache.set(projectRoot, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 uuid 解析为 prefab 磁盘路径(绝对路径)。
|
||||
*
|
||||
* @param {string} uuid 资产 UUID
|
||||
* @param {string} startPath 起点路径(宿主 prefab 路径 或 项目根目录),用于推断项目根
|
||||
* @returns {string} prefab 文件绝对路径
|
||||
* @throws uuid 不存在时抛错
|
||||
*/
|
||||
function resolveUuidToPath(uuid, startPath) {
|
||||
const index = getUuidIndex(startPath);
|
||||
const result = index.get(uuid);
|
||||
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`UuidResolver: 找不到 uuid "${uuid}" 对应的 prefab 文件。` +
|
||||
`已扫描项目内 ${index.size} 个 prefab。` +
|
||||
`请确认该 uuid 对应的 prefab 存在于 assets/ 目录下。`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(测试用,正常使用不需要调用)
|
||||
*/
|
||||
function clearCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
module.exports = { getUuidIndex, resolveUuidToPath, clearCache };
|
||||
@@ -0,0 +1,57 @@
|
||||
// ============================================================
|
||||
// CC3 Prefab 格式保真写回(纯 CJS,零三方依赖)
|
||||
// 探测原文件缩进 + 末尾换行字节,minimal-diff 写回
|
||||
// ============================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* 探测原始文件的缩进单位(空格数)
|
||||
* 取所有以空格开头的行中最小缩进数
|
||||
* @param {string} raw
|
||||
* @returns {number} 缩进空格数,默认 2
|
||||
*/
|
||||
function detectIndent(raw) {
|
||||
const matches = [...raw.matchAll(/^( +)\S/gm)].map((m) => m[1].length);
|
||||
if (matches.length === 0) return 2;
|
||||
return Math.min(...matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测原始文件末尾是否有换行符
|
||||
* @param {string} raw
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function detectTrailingNewline(raw) {
|
||||
return raw.length > 0 && (raw[raw.length - 1] === '\n' || raw[raw.length - 1] === '\r');
|
||||
}
|
||||
|
||||
/**
|
||||
* 写回 prefab 文件,保留原始格式(缩进 + 末尾换行)
|
||||
*
|
||||
* @param {string} filePath 写入目标路径(可与读取路径不同,T7 写临时路径)
|
||||
* @param {object[]} data 修改后的 elements 数组
|
||||
* @param {string} originalRaw 原始文件内容(用于探测格式)
|
||||
*/
|
||||
function writePrefab(filePath, data, originalRaw) {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('writePrefab: data 必须是数组');
|
||||
}
|
||||
if (typeof originalRaw !== 'string') {
|
||||
throw new Error('writePrefab: originalRaw 必须是字符串');
|
||||
}
|
||||
|
||||
const indent = detectIndent(originalRaw);
|
||||
const trailingNewline = detectTrailingNewline(originalRaw);
|
||||
|
||||
let newRaw = JSON.stringify(data, null, indent);
|
||||
if (trailingNewline) {
|
||||
newRaw += '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, newRaw, 'utf8');
|
||||
}
|
||||
|
||||
module.exports = { writePrefab, detectIndent, detectTrailingNewline };
|
||||
Reference in New Issue
Block a user