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

260 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOMParser } from '@xmldom/xmldom';
// SpiritType → cc.* 组件映射表 (数据源). 读取 fixtures/spirit-semantics.json,
// 该文件由 Task 5 (gameabc.min.js 逆向) 维护, 下游不得兜底.
// 路径: scripts/legacy-layer-to-cocos-prefab.mjs → ../framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json
const __dirname = dirname(fileURLToPath(import.meta.url));
const SEMANTICS_PATH = join(
__dirname,
'../framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json'
);
let _semantics = null;
function loadSemantics() {
if (!_semantics) _semantics = JSON.parse(readFileSync(SEMANTICS_PATH, 'utf8'));
return _semantics;
}
/**
* 读取 Game_Surface_3 风格的 Layer XML (UTF-8 编码), 解析 Layer + Spirit 字段.
*
* 协议来源: docs/protocol/ (服务器零改动). XML 结构与 save/Layer*.xml 一致:
* <Layer>
* <PropertyList><Property Name="ID" Value="2"/><Property Name="Name" Value="..."/></PropertyList>
* <SpiritList>
* <Spirit>
* <PropertyList><Property Name="SpiritType" Value="0"/>...</PropertyList>
* </Spirit>
* </SpiritList>
* </Layer>
*
* 数据源权威: 字段缺失/不存在一律返回 undefined (不上游兜底, 由下游显式判错).
*
* @param {string} path - 文件路径 (相对 cwd 或绝对)
* @returns {{ layerId: string|undefined, layerName: string|undefined, spirits: Array<{ id, name, type, imgResID, x, y, w, h, group, indexOfLayer }> }}
*/
export function readLayerXml(path) {
const text = readFileSync(path, 'utf8');
const doc = new DOMParser().parseFromString(text, 'text/xml');
const getProp = (el, name) => {
const prop = Array.from(el.getElementsByTagName('Property'))
.find(p => p.getAttribute('Name') === name);
return prop?.getAttribute('Value');
};
const layerEl = doc.getElementsByTagName('Layer')[0];
const layerPropList = layerEl.getElementsByTagName('PropertyList')[0];
const layerId = getProp(layerPropList, 'ID');
const layerName = getProp(layerPropList, 'Name');
const spiritEls = doc.getElementsByTagName('Spirit');
const spirits = Array.from(spiritEls).map(sp => {
const propList = sp.getElementsByTagName('PropertyList')[0];
return {
id: getProp(propList, 'ID'),
name: getProp(propList, 'Name'),
type: getProp(propList, 'SpiritType'),
imgResID: getProp(propList, 'ImgResID'),
x: getProp(propList, 'X'),
y: getProp(propList, 'Y'),
w: getProp(propList, 'Width'),
h: getProp(propList, 'Height'),
group: getProp(propList, 'BelongGroupID'),
indexOfLayer: getProp(propList, 'IndexOfLayer'),
};
});
return { layerId, layerName, spirits };
}
/**
* 锚点转换: 原坐标系 (left-top, 1280×720 绝对) → Cocos 中心锚点 + Y-up.
*
* 公式 (实测验证 2026-09-02, Login_Layer 渠道logo + 游客登录双节点 _lpos 匹配):
* cocos_lpos.x = original_X + original_W/2 - 640 # 1280/2
* cocos_lpos.y = 360 - (original_Y + original_H/2) # Y-up 翻转 + 720/2
*
* @param {{ x?: string, y?: string, w?: string, h?: string }} spirit
* @returns {{ x: number, y: number, z: 0 }}
*/
export function computeLpos(spirit) {
const x = parseFloat(spirit.x || '0');
const y = parseFloat(spirit.y || '0');
const w = parseFloat(spirit.w || '0');
const h = parseFloat(spirit.h || '0');
// 锚点转换:原 X/Y 是左上角(1280×720 绝对坐标)→ Cocos 中心锚点
return {
x: x + w / 2 - 640, // 原坐标系半宽 1280/2
y: 360 - (y + h / 2), // Y-up 翻转 + 半高 720/2
z: 0,
};
}
/**
* 计算 Spirit 的 contentSize (Cocos UI 节点尺寸).
*
* 数据源权威: SizeCalcMode 缺失视作 0 (直接读 Width/Height); 非 0 抛错,
* 由上游显式决定是否逆向 gameabc.min.js (Task 5 范围, 本任务不支持).
*
* @param {{ w?: string, h?: string, sizeCalcMode?: string, id?: string, name?: string }} spirit
* @returns {{ width: number, height: number }}
*/
export function computeContentSize(spirit) {
const sizeCalcMode = parseInt(spirit.sizeCalcMode || '0', 10);
if (sizeCalcMode !== 0) {
throw new Error(`SizeCalcMode=${sizeCalcMode} not yet supported for Spirit ${spirit.id} (${spirit.name}) - reverse gameabc.min.js first (Task 5)`);
}
return {
width: parseFloat(spirit.w || '0'),
height: parseFloat(spirit.h || '0'),
};
}
/**
* 将 Spirit 记录按 BelongGroupID 归类, 生成 group-X 父节点集合 + Layer 根直挂子节点.
*
* 规则:
* - BelongGroupID === '0' (或缺失) → 直挂 Layer 根, 不进任何 group 父节点
* - BelongGroupID > 0 → 归入对应 group-<N> 父节点
* - group 父节点按 ID 升序排列; 同 group 内子节点按 IndexOfLayer 升序
* - 命名保留原 BelongGroupID 值, 不重排
*
* 数据源权威: IndexOfLayer 缺失视作 0 (XML 中该字段由 readLayerXml 提供, 必有值).
*
* @param {Array<{ id?: string, group?: string, indexOfLayer?: string }>} spirits
* @returns {{ groupParents: Array<{ name: string, childSpiritIds: string[] }>, rootChildSpiritIds: string[] }}
*/
export function buildGroupParents(spirits) {
const groupMap = new Map(); // group_id -> [{ spiritId, index }]
const rootSpirits = [];
for (const s of spirits) {
if (s.group === '0' || !s.group) {
rootSpirits.push({ spiritId: s.id, index: parseInt(s.indexOfLayer || '0', 10) });
} else {
if (!groupMap.has(s.group)) groupMap.set(s.group, []);
groupMap.get(s.group).push({ spiritId: s.id, index: parseInt(s.indexOfLayer || '0', 10) });
}
}
const groupParents = Array.from(groupMap.entries())
.sort(([a], [b]) => parseInt(a, 10) - parseInt(b, 10))
.map(([gid, items]) => ({
name: `group-${gid}`,
childSpiritIds: items.sort((a, b) => a.index - b.index).map(x => x.spiritId),
}));
const rootChildSpiritIds = rootSpirits
.sort((a, b) => a.index - b.index)
.map(x => x.spiritId);
return { groupParents, rootChildSpiritIds };
}
/**
* 将 SpiritType (XML 中的字符串 "0"-"5") 映射到 Cocos 组件类名.
*
* 数据源权威唯一: 读取 fixtures/spirit-semantics.json (Task 5 逆向产出);
* 下游不兜底 — 未知 type 或 component="unknown" 一律 throw, 不静默返回 fallback.
*
* @param {string} type - SpiritType 字符串 (e.g. "0", "1", "3", "4", "5")
* @returns {string} Cocos 组件类名 (e.g. "cc.Sprite", "cc.Label", "cc.ProgressBar", "cc.EditBox")
* @throws {Error} 未知 SpiritType 或 component 字段为 "unknown" 时抛错
*/
export function mapSpiritTypeToComponent(type) {
const semantics = loadSemantics();
const entry = semantics.spirit_types[type];
if (!entry) {
throw new Error(`Unknown SpiritType "${type}" - not in fixtures/spirit-semantics.json`);
}
if (entry.component === 'unknown') {
throw new Error(`SpiritType "${type}" component not yet reverse-engineered - complete Task 5 (gameabc.min.js 逆向) first`);
}
return entry.component;
}
// ImgResID (XML) → SpriteFrame 真 UUID. 数据源: fixtures/gameabc_Image.json + YouleNexus
// `atlas-*` 目录下的 .meta (含 subMetas.f9941.uuid). 下游不兜底: imgResId 不存在 / 对应
// bmp 不在任何 `atlas-*` 下 / .meta 缺 f9941 → 全部 throw.
const IMAGE_MAP_PATH = join(
__dirname,
'../framework-tests/legacy-layer-migration/fixtures/gameabc_Image.json'
);
let _imageMap = null;
function loadImageMap() {
if (!_imageMap) _imageMap = JSON.parse(readFileSync(IMAGE_MAP_PATH, 'utf8'));
return _imageMap;
}
const ATLAS_ROOT = join(
__dirname,
'../YouleNexus/assets/framework/ui'
);
let _bmpToMetaPath = null;
function loadBmpToMetaPath() {
if (_bmpToMetaPath) return _bmpToMetaPath;
const map = new Map();
if (existsSync(ATLAS_ROOT)) {
for (const entry of readdirSync(ATLAS_ROOT)) {
if (!entry.startsWith('atlas-')) continue;
const dir = join(ATLAS_ROOT, entry);
if (!statSync(dir).isDirectory()) continue;
for (const file of readdirSync(dir)) {
if (!file.endsWith('.png.meta')) continue;
const bmpName = file.replace(/\.meta$/, '');
// 同一 bmp 出现在多个 atlas 时, 取首次发现 (字典序);
// YouleNexus 实际数据中无重复, 此为防御性 fallback.
if (!map.has(bmpName)) map.set(bmpName, join(dir, file));
}
}
}
_bmpToMetaPath = map;
return map;
}
/**
* 将 XML 中的 ImgResID 字符串解析为 SpriteFrame 真 UUID (含 @f9941 sub-asset 后缀).
*
* 流程: gameabc_Image.json[id].bmp → `atlas-*`/<bmp>.meta → subMetas.f9941.uuid.
*
* 数据源权威唯一、下游不兜底:
* - imgResId 不在 gameabc_Image.json → throw
* - 对应 bmp 不在任何 `atlas-*` 下 → throw
* - .meta 缺 subMetas.f9941 → throw (说明该资源未走 sprite-frame 导入)
*
* @param {string} imgResId - XML ImgResID 字符串 (如 "20", "21", "25")
* @returns {string} SpriteFrame 真 UUID, 形如 "<uuid>@f9941"
*/
export function resolveSpriteFrameUuid(imgResId) {
const imageMap = loadImageMap();
const list = imageMap.ImageFileList;
if (!Array.isArray(list)) {
throw new Error('gameabc_Image.json missing ImageFileList array');
}
const entry = list.find(e => e && e.id != null && (e.id === Number(imgResId) || String(e.id) === imgResId));
if (!entry || !entry.bmp) {
const known = list.filter(e => e && e.id != null).slice(0, 5).map(e => e.id).join(', ');
throw new Error(`ImgResID "${imgResId}" not in gameabc_Image.json (known ids sample: ${known}...)`);
}
const bmpName = entry.bmp;
const bmpToMeta = loadBmpToMetaPath();
const metaPath = bmpToMeta.get(bmpName);
if (!metaPath) {
throw new Error(`bmp "${bmpName}" (ImgResID ${imgResId}) not found under 'atlas-*' in ${ATLAS_ROOT}`);
}
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
const spriteFrame = meta.subMetas && meta.subMetas.f9941;
if (!spriteFrame || !spriteFrame.uuid) {
throw new Error(`${bmpName}.meta missing subMetas.f9941.uuid (importer not sprite-frame?)`);
}
return spriteFrame.uuid;
}