921 lines
35 KiB
JavaScript
921 lines
35 KiB
JavaScript
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 };
|
||
}
|