Files
youle_cocos/cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs
T

921 lines
35 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'),
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-<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;
}
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 <bmp>_1.png, <bmp>_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-*`/<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")
* @param {string} [atlasDir] - 可选的 layer atlas 目录, 例如 atlas-login.
* @returns {string} SpriteFrame 真 UUID, 形如 "<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<object>} 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<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 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 };
}