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 一致: * * * * * ... * * * * * 数据源权威: 字段缺失/不存在一律返回 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'), text: getProp(propList, 'Text'), sizeCalcMode: getProp(propList, 'SizeCalcMode'), sizeParentID: getProp(propList, 'SizeParentID'), widthCalcMode: getProp(propList, 'WidthCalcMode'), heightCalcMode: getProp(propList, 'HeightCalcMode'), sizeRelaWidth: getProp(propList, 'SizeRelaWidth'), sizeRelaHeight: getProp(propList, 'SizeRelaHeight'), coordSelfPos: getProp(propList, 'CoordSelfPos'), coordRelaX: getProp(propList, 'CoordRelaX'), coordRelaY: getProp(propList, 'CoordRelaY'), coordParentID: getProp(propList, 'CoordParentID'), belongLayerID: getProp(propList, 'BelongLayerID'), fontSize: getProp(propList, 'FontSize'), fontColor: getProp(propList, 'FontColor'), alignHorz: getProp(propList, 'AlignHorz'), alignVert: getProp(propList, 'AlignVert'), fontBold: getProp(propList, 'FontBold'), wordWrap: getProp(propList, 'WordWrap'), lineSpace: getProp(propList, 'LineSpace'), rowSpace: getProp(propList, 'RowSpace'), }; }); 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; SizeCalcMode=2 按 * ImgResID 对应的 image natural size × SizeRela/10000; 其他未知模式显式报错. * * @param {{ w?: string, h?: string, sizeCalcMode?: string, id?: string, name?: string }} spirit * @returns {{ width: number, height: number }} */ export function computeContentSize(spirit, naturalSize = null) { const sizeCalcMode = parseInt(spirit.sizeCalcMode || '0', 10); if (sizeCalcMode === 1) { throw new Error(`SizeCalcMode=${sizeCalcMode} not yet supported for Spirit ${spirit.id} (${spirit.name}) - reverse gameabc.min.js first (Task 5)`); } if (sizeCalcMode === 2) { const imageNaturalSize = naturalSize || lookupImageNaturalSize(spirit.imgResID); const widthMode = parseInt(spirit.widthCalcMode || '0', 10); const heightMode = parseInt(spirit.heightCalcMode || '0', 10); const widthRatio = parseFloat(spirit.sizeRelaWidth); const heightRatio = parseFloat(spirit.sizeRelaHeight); if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio)) { throw new Error(`Spirit ${spirit.id} (${spirit.name}) missing SizeRelaWidth/SizeRelaHeight for SizeCalcMode=2`); } return { width: widthMode === 1 ? imageNaturalSize.width * widthRatio / 10000 : parseFloat(spirit.w || '0'), height: heightMode === 1 ? imageNaturalSize.height * heightRatio / 10000 : parseFloat(spirit.h || '0'), }; } 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 简化版). * * 数据源权威: 按节点尺寸与 name 几何规则区分 full-screen (45), * top-left (9), 以及 normal (16) 三种 Widget 对齐契约. * * @param {{ x?: string, y?: string, w?: string, h?: string }} spirit * @returns {object} cc.Widget 字段 (除 __type__/node/__prefab/_id 外) */ export function computeWidgetAlignment(spirit) { const name = String(spirit.name || ''); const width = parseFloat(spirit.w || '0'); const height = parseFloat(spirit.h || '0'); const lpos = computeLpos(spirit); const isFullScreen = width === 1280 && height === 720; if (isFullScreen) { return { _alignFlags: 45, _target: null, _left: 0, _right: 0, _top: 0, _bottom: 0, _isAbsLeft: true, _isAbsRight: true, _isAbsTop: true, _isAbsBottom: true, _isAbsHorizontalCenter: true, _isAbsVerticalCenter: true, _originalWidth: 100, _originalHeight: 100, _alignMode: 2, _lockFlags: 0, }; } const isTopLeft = lpos.y > 0 && lpos.x < 0 || /背景|bg/i.test(name); if (isTopLeft) { return { _alignFlags: 9, _target: null, _left: lpos.x, _top: lpos.y, _isAbsLeft: true, _isAbsRight: true, _isAbsTop: true, _isAbsBottom: true, _isAbsHorizontalCenter: true, _isAbsVerticalCenter: true, _originalWidth: 0, _originalHeight: 0, _alignMode: 2, _lockFlags: 0, }; } return { _alignFlags: 16, _target: null, _horizontalCenter: lpos.x, _verticalCenter: 0, _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- 父节点 * - 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; } let _imageNaturalSizeMap = null; function loadImageNaturalSizeMap() { if (_imageNaturalSizeMap) return _imageNaturalSizeMap; const list = loadImageMap().ImageFileList; if (!Array.isArray(list)) { throw new Error('gameabc_Image.json missing ImageFileList array'); } _imageNaturalSizeMap = new Map(); for (const entry of list) { if (!entry || entry.id == null) continue; const width = Number(entry.w_all); const height = Number(entry.h_all); if (!Number.isFinite(width) || !Number.isFinite(height)) continue; _imageNaturalSizeMap.set(String(entry.id), { width, height }); } return _imageNaturalSizeMap; } function lookupImageNaturalSize(imgResId) { const key = String(imgResId); const naturalSize = loadImageNaturalSizeMap().get(key); if (!naturalSize) { throw new Error(`ImgResID "${imgResId}" missing natural size in gameabc_Image.json`); } return naturalSize; } const ATLAS_ROOT = join( __dirname, '../YouleNexus/assets/framework/ui' ); const ATLAS_BY_LAYER_NAME = [ ['MainScene_PlayerInfo', 'atlas-hall'], ['BackHall', 'atlas-hall'], ['Login', 'atlas-login'], ['Notice', 'atlas-login'], ['Protol', 'atlas-login'], ['Loading', 'atlas-login'], ['Kick', 'atlas-login'], ['Reconnect', 'atlas-login'], ['Setting', 'atlas-login'], ['Tips', 'atlas-login'], ['updateRoomCard', 'atlas-login'], ['phoneInfo', 'atlas-login'], ['recordLayer', 'atlas-login'], ['MenuPlayerInfo', 'atlas-login'], ]; export function selectAtlasForLayerName(layerName) { const normalizedName = String(layerName || '').toLowerCase(); const match = ATLAS_BY_LAYER_NAME.find(([keyword]) => normalizedName.includes(keyword.toLowerCase())); return match ? match[1] : 'atlas-login'; } function listAtlasDirectories() { if (!existsSync(ATLAS_ROOT)) return []; return readdirSync(ATLAS_ROOT) .filter(entry => entry.startsWith('atlas-') && statSync(join(ATLAS_ROOT, entry)).isDirectory()); } const _bmpToMetaPath = new Map(); function loadBmpToMetaPath(preferredAtlas = null) { const cacheKey = preferredAtlas || 'default'; if (_bmpToMetaPath.has(cacheKey)) return _bmpToMetaPath.get(cacheKey); const map = new Map(); const directories = listAtlasDirectories(); const orderedDirectories = [ ...(preferredAtlas ? [preferredAtlas] : []), ...(preferredAtlas === 'atlas-login' || !preferredAtlas ? ['atlas-login'] : []), ...directories.filter(entry => entry !== preferredAtlas && entry !== 'atlas-login'), ]; for (const entry of orderedDirectories) { const dir = join(ATLAS_ROOT, entry); for (const file of readdirSync(dir)) { if (!file.endsWith('.png.meta')) continue; const bmpName = file.replace(/\.meta$/, ''); if (!map.has(bmpName)) map.set(bmpName, join(dir, file)); } } // Cocos imports multi-frame source BMPs as _1.png, _2.png, ... // Keep the first frame deterministic when a legacy image has no exact BMP basename. const imageMap = loadImageMap(); const imageList = Array.isArray(imageMap.ImageFileList) ? imageMap.ImageFileList : []; for (const image of imageList) { if (!image?.bmp || map.has(image.bmp)) continue; const baseName = image.bmp.replace(/\.png$/i, ''); const frameCount = Number.isInteger(Number(image.frame_all)) && Number(image.frame_all) > 0 ? Number(image.frame_all) : 1; for (let frame = 1; frame <= frameCount; frame += 1) { const frameName = `${baseName}_${frame}.png`; if (map.has(frameName)) { map.set(image.bmp, map.get(frameName)); break; } } } _bmpToMetaPath.set(cacheKey, map); return map; } /** * 将 XML 中的 ImgResID 字符串解析为 SpriteFrame 真 UUID (含 @f9941 sub-asset 后缀). * * 流程: gameabc_Image.json[id].bmp → `atlas-*`/.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") * @param {string} [atlasDir] - 可选的 layer atlas 目录, 例如 atlas-login. * @returns {string} SpriteFrame 真 UUID, 形如 "@f9941" */ export function resolveSpriteFrameUuid(imgResId, atlasDir = null) { 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(atlasDir); 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, originalWidth = 100, originalHeight = 100) { 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: originalWidth, _originalHeight: originalHeight, _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, spirit) { const fontSize = Number.isFinite(Number(spirit.fontSize)) ? Number(spirit.fontSize) : 40; const lineSpace = Number.isFinite(Number(spirit.lineSpace)) ? Number(spirit.lineSpace) : fontSize; const rowSpace = Number.isFinite(Number(spirit.rowSpace)) ? Number(spirit.rowSpace) : 0; const color = Number.isInteger(Number(spirit.fontColor)) ? Number(spirit.fontColor) : 0xffffff; const horizontalAlign = Number.isInteger(Number(spirit.alignHorz)) ? Number(spirit.alignHorz) : 1; const verticalAlign = Number.isInteger(Number(spirit.alignVert)) ? Number(spirit.alignVert) : 1; 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: (color >> 16) & 255, g: (color >> 8) & 255, b: color & 255, a: 255, }, _string: spirit.text || spirit.name || '', _horizontalAlign: horizontalAlign, _verticalAlign: verticalAlign, _actualFontSize: fontSize, _fontSize: fontSize, _fontFamily: 'Arial', _lineHeight: lineSpace, _overflow: 0, _enableWrapText: spirit.wordWrap !== undefined && spirit.wordWrap !== '0', _font: null, _isSystemFontUsed: true, _spacingX: rowSpace, _isItalic: false, _isBold: spirit.fontBold !== undefined && spirit.fontBold !== '0', _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} Cocos prefab JSON 数组 */ export function emitPrefabJson(layer, opts = {}) { 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 = []; const targetName = opts.targetName || layer.layerName; const atlasName = opts.atlasName || selectAtlasForLayerName(targetName); // ── 1. cc.Prefab 头 ───────────────────────────────────────────── out.push({ __type__: 'cc.Prefab', _name: targetName, _objFlags: 0, __editorExtras__: {}, _native: '', data: { __id__: 1 }, // 根节点 id optimizationPolicy: 0, persistent: false, }); // ── 2. 根节点 cc.Node (id=1) ──────────────────────────────────── const rootNode = makeNode(targetName, 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); const nodeId = out.length - 1; rootNode._children.push({ __id__: nodeId }); // wire group node into root._children (Task 9 fix round 1) groupInfo.push({ group: g, node, nodeId }); } // ── 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, atlasName }); 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, atlasName }); 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} 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 width = parseFloat(spirit.w || '0'); const height = parseFloat(spirit.h || '0'); const isFullScreen = width === 1280 && height === 720; const size = isFullScreen ? { width: 1600, height: 720 } : 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(); const uiTransform = makeUiTransform(nodeId, out.length + 1, size.width, size.height); out.push(uiTransform); const uiId = out.length - 1; out.push(uiCompInfo); const uiCompInfoId = out.length - 1; // 3. 主组件 (K+3) — component 先 push, CompPrefabInfo follows it. const mainCompInfo = makeCompPrefabInfo(); const mainCompInfoId = out.length + 1; const spriteFrameUuid = (spirit.type === '0' || spirit.type === '3' || spirit.type === '5') ? resolveSpriteFrameUuid(spirit.imgResID, opts.atlasName) : 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); } 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; out.push(mainCompInfo); // 4. cc.Widget (K+5) — component 先 push, CompPrefabInfo follows it. const widgetCompInfo = makeCompPrefabInfo(); // parentIsGroup 时走 computeWidgetAlignment; Layer 根直挂时使用全屏 Widget. const widget = opts.parentIsGroup ? makeSpiritWidget(nodeId, out.length + 1, spirit) : makeWidget(nodeId, out.length + 1); out.push(widget); const widgetId = out.length - 1; out.push(widgetCompInfo); const widgetCompInfoId = 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(); const uiTransform = makeUiTransform(nodeId, out.length + 1, DESIGN_WIDTH, DESIGN_HEIGHT); out.push(uiTransform); const uiId = out.length - 1; out.push(uiCompInfo); const uiCompInfoId = out.length - 1; // 2. Widget + CompPrefabInfo const widgetCompInfo = makeCompPrefabInfo(); const widget = makeWidget(nodeId, out.length + 1); out.push(widget); const widgetId = out.length - 1; out.push(widgetCompInfo); const widgetCompInfoId = 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 }; }