260 lines
10 KiB
JavaScript
260 lines
10 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'),
|
||
};
|
||
});
|
||
|
||
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;
|
||
}
|