Files
youle_cocos/cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs
T
joywayer e8f4180483 feat(scripts): PoC 阶段 2 - Layer10_Protocol + Widget 锚定
computeWidgetAlignment(spirit) 简化版 (水平+垂直居中):
  _alignFlags = 16 | 8
  _horizontalCenter = x + w/2 - 640
  _verticalCenter = 360 - (y + h/2)

emitPrefabJson 仅在 Spirit 父节点是 group (非 Layer 根) 时调用;
root / group-X envelope 沿用默认 _alignFlags=45 全屏铺满.

PoC 验证: Layer00010.xml Protol_Layer (group 37, 4 spirits)
生成 Protol_Layer.prefab — 6 Widget 节点 (1 root + 1 group-37 + 4 spirit),
4 spirit widget 全部 _alignFlags=24.
2026-09-02 08:35:03 +08:00

767 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOMParser } from '@xmldom/xmldom';
// SpiritType → cc.* 组件映射表 (数据源). 读取 fixtures/spirit-semantics.json,
// 该文件由 Task 5 (gameabc.min.js 逆向) 维护, 下游不得兜底.
// 路径: scripts/legacy-layer-to-cocos-prefab.mjs → ../framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json
const __dirname = dirname(fileURLToPath(import.meta.url));
const SEMANTICS_PATH = join(
__dirname,
'../framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json'
);
let _semantics = null;
function loadSemantics() {
if (!_semantics) _semantics = JSON.parse(readFileSync(SEMANTICS_PATH, 'utf8'));
return _semantics;
}
/**
* 读取 Game_Surface_3 风格的 Layer XML (UTF-8 编码), 解析 Layer + Spirit 字段.
*
* 协议来源: docs/protocol/ (服务器零改动). XML 结构与 save/Layer*.xml 一致:
* <Layer>
* <PropertyList><Property Name="ID" Value="2"/><Property Name="Name" Value="..."/></PropertyList>
* <SpiritList>
* <Spirit>
* <PropertyList><Property Name="SpiritType" Value="0"/>...</PropertyList>
* </Spirit>
* </SpiritList>
* </Layer>
*
* 数据源权威: 字段缺失/不存在一律返回 undefined (不上游兜底, 由下游显式判错).
*
* @param {string} path - 文件路径 (相对 cwd 或绝对)
* @returns {{ layerId: string|undefined, layerName: string|undefined, spirits: Array<{ id, name, type, imgResID, x, y, w, h, group, indexOfLayer }> }}
*/
export function readLayerXml(path) {
const text = readFileSync(path, 'utf8');
const doc = new DOMParser().parseFromString(text, 'text/xml');
const getProp = (el, name) => {
const prop = Array.from(el.getElementsByTagName('Property'))
.find(p => p.getAttribute('Name') === name);
return prop?.getAttribute('Value');
};
const layerEl = doc.getElementsByTagName('Layer')[0];
const layerPropList = layerEl.getElementsByTagName('PropertyList')[0];
const layerId = getProp(layerPropList, 'ID');
const layerName = getProp(layerPropList, 'Name');
const spiritEls = doc.getElementsByTagName('Spirit');
const spirits = Array.from(spiritEls).map(sp => {
const propList = sp.getElementsByTagName('PropertyList')[0];
return {
id: getProp(propList, 'ID'),
name: getProp(propList, 'Name'),
type: getProp(propList, 'SpiritType'),
imgResID: getProp(propList, 'ImgResID'),
x: getProp(propList, 'X'),
y: getProp(propList, 'Y'),
w: getProp(propList, 'Width'),
h: getProp(propList, 'Height'),
group: getProp(propList, 'BelongGroupID'),
indexOfLayer: getProp(propList, 'IndexOfLayer'),
};
});
return { layerId, layerName, spirits };
}
/**
* 锚点转换: 原坐标系 (left-top, 1280×720 绝对) → Cocos 中心锚点 + Y-up.
*
* 公式 (实测验证 2026-09-02, Login_Layer 渠道logo + 游客登录双节点 _lpos 匹配):
* cocos_lpos.x = original_X + original_W/2 - 640 # 1280/2
* cocos_lpos.y = 360 - (original_Y + original_H/2) # Y-up 翻转 + 720/2
*
* @param {{ x?: string, y?: string, w?: string, h?: string }} spirit
* @returns {{ x: number, y: number, z: 0 }}
*/
export function computeLpos(spirit) {
const x = parseFloat(spirit.x || '0');
const y = parseFloat(spirit.y || '0');
const w = parseFloat(spirit.w || '0');
const h = parseFloat(spirit.h || '0');
// 锚点转换:原 X/Y 是左上角(1280×720 绝对坐标)→ Cocos 中心锚点
return {
x: x + w / 2 - 640, // 原坐标系半宽 1280/2
y: 360 - (y + h / 2), // Y-up 翻转 + 半高 720/2
z: 0,
};
}
/**
* 计算 Spirit 的 contentSize (Cocos UI 节点尺寸).
*
* 数据源权威: SizeCalcMode 缺失视作 0 (直接读 Width/Height); 非 0 抛错,
* 由上游显式决定是否逆向 gameabc.min.js (Task 5 范围, 本任务不支持).
*
* @param {{ w?: string, h?: string, sizeCalcMode?: string, id?: string, name?: string }} spirit
* @returns {{ width: number, height: number }}
*/
export function computeContentSize(spirit) {
const sizeCalcMode = parseInt(spirit.sizeCalcMode || '0', 10);
if (sizeCalcMode !== 0) {
throw new Error(`SizeCalcMode=${sizeCalcMode} not yet supported for Spirit ${spirit.id} (${spirit.name}) - reverse gameabc.min.js first (Task 5)`);
}
return {
width: parseFloat(spirit.w || '0'),
height: parseFloat(spirit.h || '0'),
};
}
/**
* 计算 Spirit 在其所属 group 内的 cc.Widget 锚定参数 (PoC 阶段 2 简化版).
*
* 数据源权威: 公式仅处理 Spirit.Y < 360 (上) vs > 360 (下) 与水平偏移, 完全基于
* 已知字段 (x/y/w/h). SizeCalcMode / CoordRelaX / CoordRelaY 等逆向未出的字段
* 暂不支持 — 由 buildSpiritBlock 调用方保证父 group 已经处理 SizeCalcMode ≠ 0.
*
* 简化规则:
* _alignFlags = 16 | 8 # 16=水平居中, 8=垂直居中
* _horizontalCenter = x + w/2 - 640 # 像素偏移, 1280 坐标系水平中心
* _verticalCenter = 360 - (y + h/2) # Y-up 翻转 + 720 坐标系垂直中心
*
* @param {{ x?: string, y?: string, w?: string, h?: string }} spirit
* @returns {object} cc.Widget 字段 (除 __type__/node/__prefab/_id 外)
*/
export function computeWidgetAlignment(spirit) {
const x = parseFloat(spirit.x || '0');
const y = parseFloat(spirit.y || '0');
const w = parseFloat(spirit.w || '0');
const h = parseFloat(spirit.h || '0');
return {
_alignFlags: 16 | 8, // 水平居中 + 垂直居中
_target: null,
_left: 0, _right: 0, _top: 0, _bottom: 0,
_horizontalCenter: x + w / 2 - 640, // 1280 体系半宽
_verticalCenter: 360 - (y + h / 2), // Y-up + 720 体系半高
_isAbsLeft: true, _isAbsRight: true, _isAbsTop: true, _isAbsBottom: true,
_isAbsHorizontalCenter: true, _isAbsVerticalCenter: true,
_originalWidth: 0, _originalHeight: 0,
_alignMode: 2, _lockFlags: 0,
};
}
/**
* 将 Spirit 记录按 BelongGroupID 归类, 生成 group-X 父节点集合 + Layer 根直挂子节点.
*
* 规则:
* - BelongGroupID === '0' (或缺失) → 直挂 Layer 根, 不进任何 group 父节点
* - BelongGroupID > 0 → 归入对应 group-<N> 父节点
* - group 父节点按 ID 升序排列; 同 group 内子节点按 IndexOfLayer 升序
* - 命名保留原 BelongGroupID 值, 不重排
*
* 数据源权威: IndexOfLayer 缺失视作 0 (XML 中该字段由 readLayerXml 提供, 必有值).
*
* @param {Array<{ id?: string, group?: string, indexOfLayer?: string }>} spirits
* @returns {{ groupParents: Array<{ name: string, childSpiritIds: string[] }>, rootChildSpiritIds: string[] }}
*/
export function buildGroupParents(spirits) {
const groupMap = new Map(); // group_id -> [{ spiritId, index }]
const rootSpirits = [];
for (const s of spirits) {
if (s.group === '0' || !s.group) {
rootSpirits.push({ spiritId: s.id, index: parseInt(s.indexOfLayer || '0', 10) });
} else {
if (!groupMap.has(s.group)) groupMap.set(s.group, []);
groupMap.get(s.group).push({ spiritId: s.id, index: parseInt(s.indexOfLayer || '0', 10) });
}
}
const groupParents = Array.from(groupMap.entries())
.sort(([a], [b]) => parseInt(a, 10) - parseInt(b, 10))
.map(([gid, items]) => ({
name: `group-${gid}`,
childSpiritIds: items.sort((a, b) => a.index - b.index).map(x => x.spiritId),
}));
const rootChildSpiritIds = rootSpirits
.sort((a, b) => a.index - b.index)
.map(x => x.spiritId);
return { groupParents, rootChildSpiritIds };
}
/**
* 将 SpiritType (XML 中的字符串 "0"-"5") 映射到 Cocos 组件类名.
*
* 数据源权威唯一: 读取 fixtures/spirit-semantics.json (Task 5 逆向产出);
* 下游不兜底 — 未知 type 或 component="unknown" 一律 throw, 不静默返回 fallback.
*
* @param {string} type - SpiritType 字符串 (e.g. "0", "1", "3", "4", "5")
* @returns {string} Cocos 组件类名 (e.g. "cc.Sprite", "cc.Label", "cc.ProgressBar", "cc.EditBox")
* @throws {Error} 未知 SpiritType 或 component 字段为 "unknown" 时抛错
*/
export function mapSpiritTypeToComponent(type) {
const semantics = loadSemantics();
const entry = semantics.spirit_types[type];
if (!entry) {
throw new Error(`Unknown SpiritType "${type}" - not in fixtures/spirit-semantics.json`);
}
if (entry.component === 'unknown') {
throw new Error(`SpiritType "${type}" component not yet reverse-engineered - complete Task 5 (gameabc.min.js 逆向) first`);
}
return entry.component;
}
// ImgResID (XML) → SpriteFrame 真 UUID. 数据源: fixtures/gameabc_Image.json + YouleNexus
// `atlas-*` 目录下的 .meta (含 subMetas.f9941.uuid). 下游不兜底: imgResId 不存在 / 对应
// bmp 不在任何 `atlas-*` 下 / .meta 缺 f9941 → 全部 throw.
const IMAGE_MAP_PATH = join(
__dirname,
'../framework-tests/legacy-layer-migration/fixtures/gameabc_Image.json'
);
let _imageMap = null;
function loadImageMap() {
if (!_imageMap) _imageMap = JSON.parse(readFileSync(IMAGE_MAP_PATH, 'utf8'));
return _imageMap;
}
const ATLAS_ROOT = join(
__dirname,
'../YouleNexus/assets/framework/ui'
);
let _bmpToMetaPath = null;
function loadBmpToMetaPath() {
if (_bmpToMetaPath) return _bmpToMetaPath;
const map = new Map();
if (existsSync(ATLAS_ROOT)) {
for (const entry of readdirSync(ATLAS_ROOT)) {
if (!entry.startsWith('atlas-')) continue;
const dir = join(ATLAS_ROOT, entry);
if (!statSync(dir).isDirectory()) continue;
for (const file of readdirSync(dir)) {
if (!file.endsWith('.png.meta')) continue;
const bmpName = file.replace(/\.meta$/, '');
// 同一 bmp 出现在多个 atlas 时, 取首次发现 (字典序);
// YouleNexus 实际数据中无重复, 此为防御性 fallback.
if (!map.has(bmpName)) map.set(bmpName, join(dir, file));
}
}
}
_bmpToMetaPath = map;
return map;
}
/**
* 将 XML 中的 ImgResID 字符串解析为 SpriteFrame 真 UUID (含 @f9941 sub-asset 后缀).
*
* 流程: gameabc_Image.json[id].bmp → `atlas-*`/<bmp>.meta → subMetas.f9941.uuid.
*
* 数据源权威唯一、下游不兜底:
* - imgResId 不在 gameabc_Image.json → throw
* - 对应 bmp 不在任何 `atlas-*` 下 → throw
* - .meta 缺 subMetas.f9941 → throw (说明该资源未走 sprite-frame 导入)
*
* @param {string} imgResId - XML ImgResID 字符串 (如 "20", "21", "25")
* @returns {string} SpriteFrame 真 UUID, 形如 "<uuid>@f9941"
*/
export function resolveSpriteFrameUuid(imgResId) {
const imageMap = loadImageMap();
const list = imageMap.ImageFileList;
if (!Array.isArray(list)) {
throw new Error('gameabc_Image.json missing ImageFileList array');
}
const entry = list.find(e => e && e.id != null && (e.id === Number(imgResId) || String(e.id) === imgResId));
if (!entry || !entry.bmp) {
const known = list.filter(e => e && e.id != null).slice(0, 5).map(e => e.id).join(', ');
throw new Error(`ImgResID "${imgResId}" not in gameabc_Image.json (known ids sample: ${known}...)`);
}
const bmpName = entry.bmp;
const bmpToMeta = loadBmpToMetaPath();
const metaPath = bmpToMeta.get(bmpName);
if (!metaPath) {
throw new Error(`bmp "${bmpName}" (ImgResID ${imgResId}) not found under 'atlas-*' in ${ATLAS_ROOT}`);
}
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
const spriteFrame = meta.subMetas && meta.subMetas.f9941;
if (!spriteFrame || !spriteFrame.uuid) {
throw new Error(`${bmpName}.meta missing subMetas.f9941.uuid (importer not sprite-frame?)`);
}
return spriteFrame.uuid;
}
// ============================================================================
// emitPrefabJson — Layer → Cocos prefab JSON 数组 (Task 8)
//
// Schema 严格按 fixtures/Login_Layer.prefab (已实测): cc.Prefab 头 + cc.Node 根 +
// group-X 父节点 + 每个 Spirit (Node + UITransform + CompPrefabInfo + 主组件 +
// CompPrefabInfo + Widget + CompPrefabInfo + PrefabInfo) + 末尾 group/root 的
// UITransform/Widget/PrefabInfo 块.
//
// ID 分配: cc.Prefab 在 array[0] (无 __id__ 字段, 仅 data.__id__=1 引用根);
// 从 array[1] 开始 __id__ = array-index. root=1, group-N=2..K, 然后 spirits
// 按 group-then-IndexOfLayer 顺序, 每个 spirit 占 8 个 id (1 Node + 1 UIT +
// 1 CompInfo + 1 主组件 + 1 CompInfo + 1 Widget + 1 CompInfo + 1 PrefabInfo).
// group-N 的 UIT/Widget/PrefabInfo 排在所有 spirits 之后, root 的排在最后.
//
// 设计分辨率: 1600×720 (YouleNexus project.json), 与 Login_Layer.prefab 中
// group/root UITransform._contentSize 一致.
// ============================================================================
const DESIGN_WIDTH = 1600;
const DESIGN_HEIGHT = 720;
function randomFileId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 22; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
function makeNode(name, parentRef) {
return {
__type__: 'cc.Node',
_name: name,
_objFlags: 0,
__editorExtras__: {},
_parent: parentRef,
_children: [],
_active: true,
_components: [],
_prefab: null,
_lpos: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 },
_lrot: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 },
_lscale: { __type__: 'cc.Vec3', x: 1, y: 1, z: 1 },
_mobility: 0,
_layer: 1073741824,
_euler: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 },
_id: '',
};
}
function makeCompPrefabInfo() {
return {
__type__: 'cc.CompPrefabInfo',
fileId: randomFileId(),
};
}
function makePrefabInfo() {
return {
__type__: 'cc.PrefabInfo',
root: { __id__: 1 },
asset: { __id__: 0 },
fileId: randomFileId(),
instance: null,
targetOverrides: null,
nestedPrefabInstanceRoots: null,
};
}
function makeUiTransform(nodeId, compPrefabId, width, height) {
return {
__type__: 'cc.UITransform',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: { __id__: nodeId },
_enabled: true,
__prefab: { __id__: compPrefabId },
_contentSize: { __type__: 'cc.Size', width, height },
_anchorPoint: { __type__: 'cc.Vec2', x: 0.5, y: 0.5 },
_id: '',
};
}
function makeWidget(nodeId, compPrefabId) {
return {
__type__: 'cc.Widget',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: { __id__: nodeId },
_enabled: true,
__prefab: { __id__: compPrefabId },
_alignFlags: 45,
_target: null,
_left: 0, _right: 0, _top: 0, _bottom: 0,
_horizontalCenter: 0, _verticalCenter: 0,
_isAbsLeft: true, _isAbsRight: true, _isAbsTop: true, _isAbsBottom: true,
_isAbsHorizontalCenter: true, _isAbsVerticalCenter: true,
_originalWidth: 0, _originalHeight: 0,
_alignMode: 2,
_lockFlags: 0,
_id: '',
};
}
// Spirit 专用的 Widget 工厂: 调用 computeWidgetAlignment 计算锚定参数.
// 用法: buildSpiritBlock 在父节点是 group 时调用此工厂, 替代默认 makeWidget.
// 注意: 此工厂只应在 parent 确实是 group 容器 (而非 Layer 根) 时调用 — 见
// buildSpiritBlock 的入参语义. Layer 根与 group-X 末尾 envelope 仍用 makeWidget.
function makeSpiritWidget(nodeId, compPrefabId, spirit) {
const align = computeWidgetAlignment(spirit);
return {
__type__: 'cc.Widget',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: { __id__: nodeId },
_enabled: true,
__prefab: { __id__: compPrefabId },
...align, // _alignFlags, _horizontal/verticalCenter...
_id: '',
};
}
function makeSprite(nodeId, compPrefabId, spriteFrameUuid, type = 0) {
return {
__type__: 'cc.Sprite',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: { __id__: nodeId },
_enabled: true,
__prefab: { __id__: compPrefabId },
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 },
_spriteFrame: spriteFrameUuid
? { __uuid__: spriteFrameUuid, __expectedType__: 'cc.SpriteFrame' }
: null,
_type: type, // 0=SIMPLE, 1=SLICED, 2=TILED, 3=FILLED
_fillType: 0,
_sizeMode: 0,
_fillCenter: { __type__: 'cc.Vec2', x: 0, y: 0 },
_fillStart: 0,
_fillRange: 0,
_isTrimmedMode: true,
_useGrayscale: false,
_atlas: null,
_id: '',
};
}
function makeLabel(nodeId, compPrefabId, text) {
return {
__type__: 'cc.Label',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: { __id__: nodeId },
_enabled: true,
__prefab: { __id__: compPrefabId },
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 },
_string: text || '',
_horizontalAlign: 1,
_verticalAlign: 1,
_actualFontSize: 40,
_fontSize: 40,
_fontFamily: 'Arial',
_lineHeight: 40,
_overflow: 0,
_enableWrapText: true,
_font: null,
_isSystemFontUsed: true,
_spacingX: 0,
_isItalic: false, _isBold: false, _isUnderline: false,
_underlineHeight: 2,
_cacheMode: 0,
_enableOutline: false,
_outlineColor: { __type__: 'cc.Color', r: 0, g: 0, b: 0, a: 255 },
_outlineWidth: 2,
_enableShadow: false,
_shadowColor: { __type__: 'cc.Color', r: 0, g: 0, b: 0, a: 255 },
_shadowOffset: { __type__: 'cc.Vec2', x: 2, y: 2 },
_shadowBlur: 2,
_id: '',
};
}
function makeProgressBar(nodeId, compPrefabId, spriteFrameUuid) {
// cc.ProgressBar 简化 schema — 真实 fixtures 中 Layer 含 type=3 spirit 但
// Login_Layer.prefab fixture 无 ProgressBar 节点. 字段尽量按 Cocos 3.8 实际 schema 写,
// 缺资源时降级为 null (非 unknown; 数据源权威缺失由调用方上游补).
return {
__type__: 'cc.ProgressBar',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: { __id__: nodeId },
_enabled: true,
__prefab: { __id__: compPrefabId },
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 },
_spriteFrame: spriteFrameUuid
? { __uuid__: spriteFrameUuid, __expectedType__: 'cc.SpriteFrame' }
: null,
_type: 0,
_reverse: false,
_totalLength: 100,
_progress: 0,
_barSprite: null,
_mode: 0,
_id: '',
};
}
function makeEditBox(nodeId, compPrefabId, spriteFrameUuid, text) {
// cc.EditBox 简化 schema — 同上, fixture 无 EditBox 节点, 字段按 Cocos 3.8 实际 schema.
return {
__type__: 'cc.EditBox',
_name: '',
_objFlags: 0,
__editorExtras__: {},
node: { __id__: nodeId },
_enabled: true,
__prefab: { __id__: compPrefabId },
_customMaterial: null,
_srcBlendFactor: 2,
_dstBlendFactor: 4,
_color: { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 },
_string: text || '',
_placeholder: '',
_placeholderColor: { __type__: 'cc.Color', r: 127, g: 127, b: 127, a: 255 },
_maxLength: 20,
_inputMode: 0,
_inputFlag: 0,
_returnType: 0,
_backgroundImage: spriteFrameUuid
? { __uuid__: spriteFrameUuid, __expectedType__: 'cc.SpriteFrame' }
: null,
_fontColor: { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 },
_fontSize: 20,
_lineHeight: 40,
_fontFamily: 'Arial',
_isSystemFontUsed: true,
_tabIndex: 0,
_editingDidBegin: null,
_textChanged: null,
_editingDidEnded: null,
_editingReturn: null,
_stayOnTop: false,
_id: '',
};
}
/**
* 将 parsed Layer 对象转为 Cocos prefab JSON 数组, schema 严格按
* fixtures/Login_Layer.prefab.
*
* 字段需求 (数据源权威唯一):
* layer.layerName — 必有; cc.Prefab._name / 根节点._name
* layer.spirits[*].id — 必有; spirit 唯一标识
* layer.spirits[*].name — 必有; 节点._name
* layer.spirits[*].type — 必有; 走 mapSpiritTypeToComponent (未知 throw)
* layer.spirits[*].x/y/w/h — 必有; 走 computeLpos + computeContentSize
* layer.spirits[*].group/indexOfLayer — 必有; 走 buildGroupParents
* layer.spirits[*].imgResID — Sprite (type=0/5) 必有; 走 resolveSpriteFrameUuid
*
* ID 分配策略 (与 Login_Layer.prefab 1:1 对齐):
* - cc.Prefab 在 array[0], 无 __id__ (data.__id__=1 引用根)
* - 数组下标 i ≥ 1 的对象 __id__ = i
* - 根节点 = array[1] (id=1)
* - group-X 父节点顺序: 按 group ID 升序排, 紧随根节点 (id=2..K)
* - Spirit 顺序: 按 group 升序, group 内按 IndexOfLayer 升序
* 每个 spirit 占 8 个 id: Node UIT CompInfo 主组件 CompInfo Widget CompInfo PrefabInfo
* - group 末尾追加: group-X 的 UIT CompInfo Widget CompInfo PrefabInfo (5 id)
* - root 末尾追加: root 的 UIT CompInfo Widget CompInfo PrefabInfo (5 id)
*
* @param {{ layerId?: string, layerName: string, spirits: Array<{ id: string, name: string, type: string, imgResID: string, x: string, y: string, w: string, h: string, group: string, indexOfLayer: string }> }} layer
* @returns {Array<object>} Cocos prefab JSON 数组
*/
export function emitPrefabJson(layer) {
if (!layer || !layer.layerName) {
throw new Error('emitPrefabJson: layer.layerName is required');
}
if (!Array.isArray(layer.spirits)) {
throw new Error('emitPrefabJson: layer.spirits must be an array');
}
const out = [];
// ── 1. cc.Prefab 头 ─────────────────────────────────────────────
out.push({
__type__: 'cc.Prefab',
_name: layer.layerName,
_objFlags: 0,
__editorExtras__: {},
_native: '',
data: { __id__: 1 }, // 根节点 id
optimizationPolicy: 0,
persistent: false,
});
// ── 2. 根节点 cc.Node (id=1) ────────────────────────────────────
const rootNode = makeNode(layer.layerName, null);
out.push(rootNode);
const rootId = out.length - 1; // =1
// ── 3. group-X 父节点 ──────────────────────────────────────────
const { groupParents, rootChildSpiritIds } = buildGroupParents(layer.spirits);
const spiritById = new Map(layer.spirits.map(s => [s.id, s]));
const groupInfo = []; // [{ group, node, nodeId }]
for (const g of groupParents) {
const node = makeNode(g.name, { __id__: rootId });
out.push(node);
groupInfo.push({ group: g, node, nodeId: out.length - 1 });
}
// ── 4. Spirit 子节点 + 组件 ────────────────────────────────────
const spiritInfo = new Map(); // spiritId → { node, nodeId, prefabInfoId }
for (const gi of groupInfo) {
for (const spiritId of gi.group.childSpiritIds) {
const spirit = spiritById.get(spiritId);
if (!spirit) {
throw new Error(`emitPrefabJson: spirit id ${spiritId} referenced by ${gi.group.name} but not found in layer.spirits`);
}
const info = buildSpiritBlock(spirit, { __id__: gi.nodeId }, out, { parentIsGroup: true });
spiritInfo.set(spiritId, info);
gi.node._children.push({ __id__: info.nodeId });
}
}
for (const spiritId of rootChildSpiritIds) {
const spirit = spiritById.get(spiritId);
if (!spirit) {
throw new Error(`emitPrefabJson: root child spirit id ${spiritId} not found in layer.spirits`);
}
const info = buildSpiritBlock(spirit, { __id__: rootId }, out, { parentIsGroup: false });
spiritInfo.set(spiritId, info);
rootNode._children.push({ __id__: info.nodeId });
}
// ── 5. group-X 末尾 UITransform + Widget + PrefabInfo ──────────
for (const gi of groupInfo) {
appendNodeEnvelope(gi.node, gi.nodeId, out);
}
// ── 6. 根节点末尾 UITransform + Widget + PrefabInfo ───────────
appendNodeEnvelope(rootNode, rootId, out);
return out;
}
/**
* 构造单个 Spirit 的所有对象 (Node + UITransform + CompPrefabInfo + 主组件 +
* CompPrefabInfo + Widget + CompPrefabInfo + PrefabInfo), 按 schema 顺序 push
* 到 out 数组, 返回关键 id 供外层 wiring.
*
* @param {object} spirit - Spirit 字段
* @param {{ __id__: number }} parentRef - 父节点引用
* @param {Array<object>} out - 输出 JSON 数组
* @param {{ parentIsGroup: boolean }} [opts] - parentIsGroup=true 时 Widget
* 走 computeWidgetAlignment (锚定 = 水平/垂直居中); false (Layer 根直挂)
* 走默认 makeWidget (_alignFlags=45 全屏铺满).
*/
function buildSpiritBlock(spirit, parentRef, out, opts = { parentIsGroup: false }) {
const lpos = computeLpos(spirit);
const size = computeContentSize(spirit);
const componentName = mapSpiritTypeToComponent(spirit.type);
// 1. cc.Node (主组件 id=K+0)
const node = makeNode(spirit.name, parentRef);
node._lpos = { __type__: 'cc.Vec3', x: lpos.x, y: lpos.y, z: lpos.z };
out.push(node);
const nodeId = out.length - 1;
// 2. cc.UITransform (K+1)
const uiCompInfo = makeCompPrefabInfo();
out.push(uiCompInfo);
const uiCompInfoId = out.length - 1;
const uiTransform = makeUiTransform(nodeId, uiCompInfoId, size.width, size.height);
out.push(uiTransform);
const uiId = out.length - 1;
// 3. 主组件 (K+3) — CompPrefabInfo 先 push (K+2), 主组件 __prefab → 该 CompPrefabInfo
const mainCompInfo = makeCompPrefabInfo();
out.push(mainCompInfo);
const mainCompInfoId = out.length - 1;
const spriteFrameUuid = (spirit.type === '0' || spirit.type === '3' || spirit.type === '5')
? resolveSpriteFrameUuid(spirit.imgResID)
: null;
let mainComp;
if (componentName === 'cc.Sprite') {
const spriteType = spirit.type === '5' ? 1 : 0; // 5=SLICED, else SIMPLE
mainComp = makeSprite(nodeId, mainCompInfoId, spriteFrameUuid, spriteType);
} else if (componentName === 'cc.Label') {
mainComp = makeLabel(nodeId, mainCompInfoId, spirit.name);
} else if (componentName === 'cc.ProgressBar') {
mainComp = makeProgressBar(nodeId, mainCompInfoId, spriteFrameUuid);
} else if (componentName === 'cc.EditBox') {
mainComp = makeEditBox(nodeId, mainCompInfoId, spriteFrameUuid, spirit.name);
} else {
// 数据源权威唯一: mapSpiritTypeToComponent 已 throw "unknown" 情况, 此处兜底是
// 防御性 — 若新增组件类型但未在 makeXxx 注册, 显式报错暴露.
throw new Error(`emitPrefabJson: component "${componentName}" not yet supported by emitPrefabJson — add makeXxx helper for SpiritType ${spirit.type}`);
}
out.push(mainComp);
const mainCompId = out.length - 1;
// 4. cc.Widget (K+5) — CompPrefabInfo 先 push (K+4)
const widgetCompInfo = makeCompPrefabInfo();
out.push(widgetCompInfo);
const widgetCompInfoId = out.length - 1;
// parentIsGroup 时走 computeWidgetAlignment (水平+垂直居中); Layer 根直挂时
// 用默认 _alignFlags=45 (全屏铺满占位, 后续 PoC 阶段再细化).
const widget = opts.parentIsGroup
? makeSpiritWidget(nodeId, widgetCompInfoId, spirit)
: makeWidget(nodeId, widgetCompInfoId);
out.push(widget);
const widgetId = out.length - 1;
// 5. cc.PrefabInfo (K+7) — Node 的 _prefab 指向这里
const prefabInfo = makePrefabInfo();
out.push(prefabInfo);
const prefabInfoId = out.length - 1;
// ── Wire up Node ────────────────────────────────────────────
node._components.push({ __id__: uiId }, { __id__: mainCompId }, { __id__: widgetId });
node._prefab = { __id__: prefabInfoId };
return { node, nodeId, prefabInfoId };
}
/**
* 给非 Spirit 节点 (root / group-X) 追加末尾的 UITransform + Widget + PrefabInfo
* 块, 并把 node._components / node._prefab 补齐.
*/
function appendNodeEnvelope(node, nodeId, out) {
// 1. UITransform + CompPrefabInfo
const uiCompInfo = makeCompPrefabInfo();
out.push(uiCompInfo);
const uiCompInfoId = out.length - 1;
const uiTransform = makeUiTransform(nodeId, uiCompInfoId, DESIGN_WIDTH, DESIGN_HEIGHT);
out.push(uiTransform);
const uiId = out.length - 1;
// 2. Widget + CompPrefabInfo
const widgetCompInfo = makeCompPrefabInfo();
out.push(widgetCompInfo);
const widgetCompInfoId = out.length - 1;
const widget = makeWidget(nodeId, widgetCompInfoId);
out.push(widget);
const widgetId = out.length - 1;
// 3. PrefabInfo
const prefabInfo = makePrefabInfo();
out.push(prefabInfo);
const prefabInfoId = out.length - 1;
node._components.push({ __id__: uiId }, { __id__: widgetId });
node._prefab = { __id__: prefabInfoId };
}