界面迁移更新

This commit is contained in:
2026-09-05 00:12:11 +08:00
parent e2762d631d
commit 0aa66e1ec7
3187 changed files with 667915 additions and 211 deletions
@@ -0,0 +1,33 @@
# Monorepo 工具链
所有命令在 `cocoscreator_projects/` 下用 `npm run <script>` 执行。
| 命令 | 作用 |
|------|------|
| `npm run new-game <name>` | 从 `templates/game-seed` 克隆一个子游戏工程到 `games/<name>`,重置身份、铺 `game/` 骨架、挂 framework junction |
| `npm run setup-links` | 为 `games/*` 重建 `assets/framework` junction(clone 仓库后必跑一次) |
| `npm run check-cocos` | 校验所有子游戏工程的 `creator.version` 与宿主 `YouleNexus` 一致(不一致退出码 1) |
| `npm run bump-cocos <version>` | 批量改宿主+所有子游戏的 `creator.version`(之后仍需逐个用编辑器打开迁移) |
| `npm test` | 运行工具链全部单元测试 |
| `npm run check-skin <name>` | 校验子游戏 `assets/game/override/` 是否合法(孤儿/尺寸不一致/Spine 缺件),不构建,秒级反馈 |
| `npm run build-game <name> [-- --platform android]` | 完整构建流水线:版本校验 → 实体化到 `build-workspace/` → 皮肤合成 → Cocos CLI 构建 → 写 `build-info.json` |
## 关键约束
- **framework 真源**唯一存放于 `YouleNexus/assets/framework`;各子游戏的 `assets/framework` 是指向它的 junction(不入库,见根 `.gitignore`)。clone 后跑 `setup-links` 重建。
- **Cocos 版本必须全 monorepo 统一**(junction 共享同一份资源,跨版本不兼容)。升级走 `bump-cocos` + 逐工程编辑器迁移,详见 spec §8。
- 子游戏私有内容只放 `games/<name>/assets/game/`;协议一律引用 `docs/protocol/`(spec §0.1)。
- **皮肤在构建期合成**,不是运行时覆盖。子游戏把同名替换资源放 `assets/game/override/`,
路径须与 `framework/ui/` 逐字对应;只换内容、不带 `.meta`(尺寸须一致),确需改尺寸才走
带 meta 的例外通道。详见 spec `2026-08-27-ui-asset-and-skin-design.md`。
- **框架真源全程只读**:合成只发生在 `build-workspace/` 的临时工程副本里。
- **发布链路不依赖 junction**:`build-game` 直接从 `YouleNexus/assets/framework` 拷贝真源,
CI clone 后无需先跑 `setup-links`。
- 构建需环境变量 `COCOS_CREATOR` 指向 Cocos Creator 可执行文件(唯一来源,勿在脚本硬编码)。
## ⚠️ 删除子游戏工程的注意事项
子游戏的 `assets/framework` 是指向框架真源的 junction。**不要用 `rm -rf` 删除整个子游戏工程**——git-bash 的 `rm -rf` 可能穿透 junction 误删框架真源。请用 Node 安全删除(对 junction 不穿透):
```bash
node -e "require('fs').rmSync('games/<name>', { recursive: true, force: true })"
```
或先单独移除 junction(`rmdir games/<name>/assets/framework`)再删工程目录。
@@ -0,0 +1,107 @@
#!/usr/bin/env node
import { join } from 'node:path';
import { existsSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { HOST_PROJECT, GAMES_DIR, FRAMEWORK_SRC, BUILD_WORKSPACE, readCreatorVersion } from './lib/paths.mjs';
import { materialize, composeSkin, SkinValidationError } from './lib/materialize.mjs';
/** 生产用构建实现:调 Cocos CLI。路径只从环境变量取,缺失即显式报错(第二准则)。 */
function runCocosBuild(projectDir, platform) {
const exe = process.env.COCOS_CREATOR;
if (!exe) {
throw new Error(
'未设置环境变量 COCOS_CREATOR(Cocos Creator 可执行文件路径)。' +
'这是构建的唯一来源,请在环境中配置,不要在脚本里硬编码。',
);
}
execFileSync(exe, ['--project', projectDir, '--build', `platform=${platform}`], { stdio: 'inherit' });
}
/**
* 构建一款子游戏:版本校验 → 实体化 → 皮肤合成 → 构建 → 写 build-info。
* opts 可注入路径与 runBuild 用于测试。
*/
export function buildGame(name, opts = {}) {
const gamesDir = opts.gamesDir ?? GAMES_DIR;
const frameworkSrc = opts.frameworkSrc ?? FRAMEWORK_SRC;
const hostProject = opts.hostProject ?? HOST_PROJECT;
const workspace = opts.workspace ?? BUILD_WORKSPACE;
const targetPlatform = opts.platform ?? 'android';
const runBuild = opts.runBuild ?? runCocosBuild;
const gameDir = join(gamesDir, name);
if (!existsSync(gameDir)) throw new Error(`找不到子游戏工程:${gameDir}`);
// 1) 引擎版本一致性(junction 共享资源,版本分叉会损坏真源)
const expected = readCreatorVersion(hostProject);
const actual = readCreatorVersion(gameDir);
if (actual !== expected) {
throw new Error(`引擎版本不一致:${name} 为 ${actual},宿主为 ${expected}`);
}
// 2) 实体化到干净的工作区
const workspaceDir = join(workspace, name);
rmSync(workspaceDir, { recursive: true, force: true });
materialize(gameDir, frameworkSrc, workspaceDir);
// 3) 皮肤合成(校验不过会抛 SkinValidationError,构建不会被调用)
const skin = composeSkin(workspaceDir);
// 4) 构建
runBuild(workspaceDir, targetPlatform);
// 5) 产物追溯
const buildInfo = {
game: name,
frameworkCommit: opts.frameworkCommit ?? readFrameworkCommit(),
builtAt: opts.now ?? new Date().toISOString(),
platform: targetPlatform,
skin: { matched: skin.matched.length, frameworkTotal: skin.frameworkTotal },
};
writeFileSync(join(workspaceDir, 'build-info.json'), `${JSON.stringify(buildInfo, null, 2)}\n`);
return { workspaceDir, skin: buildInfo.skin, buildInfo };
}
function readFrameworkCommit() {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {
return null; // 非 git 环境:如实记 null,不编造
}
}
// CLI 入口(argv[1] 守卫与仓库其余脚本一致:无脚本名时不进 CLI 分支)
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const name = process.argv[2];
if (!name) {
console.error('用法: npm run build-game <game> [-- --platform android]');
process.exit(1);
}
const pIdx = process.argv.indexOf('--platform');
let targetPlatform;
if (pIdx > -1) {
const val = process.argv[pIdx + 1];
// 用户明确写了 --platform 却没给值(或紧跟下一个 flag):显式报错,
// 不能落到 buildGame 的 `?? 'android'` 默认值——那会把"用户指定"悄悄变成"猜的"(第二准则)。
if (!val || val.startsWith('--')) {
console.error('用法: npm run build-game <game> [-- --platform <platform>](--platform 后必须跟平台名,如 android)');
process.exit(1);
}
targetPlatform = val;
}
try {
const r = buildGame(name, targetPlatform ? { platform: targetPlatform } : {});
console.log(`皮肤命中 ${r.skin.matched} 处,框架可覆盖资源共 ${r.skin.frameworkTotal} 个`);
console.log(`构建完成:${r.workspaceDir}`);
} catch (e) {
if (e instanceof SkinValidationError) {
console.error(`\n${e.message}(子游戏 ${name}):`);
for (const err of e.errors) console.error(` [${err.code}] ${err.path}\n ${err.message}`);
} else {
console.error(e.message);
}
process.exit(1);
}
}
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import { join } from 'node:path';
import { readFileSync, writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { HOST_PROJECT, GAMES_DIR, listGameProjects } from './lib/paths.mjs';
// 批量把若干工程的 creator.version 改为 version,返回每个工程的 from/to
export function bumpVersion(projectDirs, version) {
const changed = [];
for (const dir of projectDirs) {
const pkgPath = join(dir, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
if (!pkg.creator) pkg.creator = {};
const from = pkg.creator.version ?? null;
pkg.creator.version = version;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
changed.push({ dir, from, to: version });
}
return changed;
}
// CLI 入口:bump 宿主 + 所有子游戏到指定版本
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const version = process.argv[2];
if (!version) {
console.error('用法: npm run bump-cocos <version> 例: npm run bump-cocos 3.8.9');
process.exit(1);
}
const targets = [HOST_PROJECT, ...listGameProjects(GAMES_DIR)];
const changed = bumpVersion(targets, version);
for (const c of changed) {
console.log(`${c.dir}: ${c.from} → ${c.to}`);
}
console.log('\n⚠️ 仍需用新版 Cocos Creator 逐个打开各工程执行迁移(library 重建)。');
}
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { HOST_PROJECT, GAMES_DIR, readCreatorVersion, listGameProjects } from './lib/paths.mjs';
// 比对各子游戏工程与宿主工程的 creator.version
export function checkVersions(hostProject, gamesDir) {
const expected = readCreatorVersion(hostProject);
const mismatches = [];
for (const proj of listGameProjects(gamesDir)) {
const version = readCreatorVersion(proj);
if (version !== expected) {
mismatches.push({ project: proj, version, expected });
}
}
return { expected, mismatches };
}
// CLI 入口:有不一致则退出码 1
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const { expected, mismatches } = checkVersions(HOST_PROJECT, GAMES_DIR);
if (expected == null) {
console.error('错误: 宿主工程缺少 creator.version');
process.exit(1);
}
console.log(`宿主 Cocos 版本: ${expected}`);
if (mismatches.length === 0) {
console.log('OK: 所有子游戏工程版本一致。');
process.exit(0);
}
console.error('版本不一致:');
for (const m of mismatches) {
console.error(` ${m.project}: ${m.version}(应为 ${m.expected})`);
}
process.exit(1);
}
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { FRAMEWORK_UI_DIR, GAMES_DIR, gameOverrideDir } from './lib/paths.mjs';
import { listCoverableAssets, validateOverrides } from './lib/skin.mjs';
/** 校验某子游戏的皮肤覆盖。opts 可注入路径用于测试。 */
export function checkSkin(gameName, opts = {}) {
const gamesDir = opts.gamesDir ?? GAMES_DIR;
const frameworkUiDir = opts.frameworkUiDir ?? FRAMEWORK_UI_DIR;
const projectDir = join(gamesDir, gameName);
if (!existsSync(projectDir)) {
throw new Error(`找不到子游戏工程:${projectDir}`);
}
// override 目录不存在 = 该子游戏不换肤,合法
const { matched, errors } = validateOverrides(frameworkUiDir, gameOverrideDir(projectDir));
return { matched, errors, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
}
// CLI 入口:有错误则退出码 1
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const name = process.argv[2];
if (!name) {
console.error('用法: npm run check-skin <game>');
process.exit(1);
}
const { matched, errors, frameworkTotal } = checkSkin(name);
console.log(`皮肤命中 ${matched.length} 处,框架可覆盖资源共 ${frameworkTotal} 个`);
if (errors.length === 0) {
console.log('OK: override 全部合法。');
process.exit(0);
}
console.error(`\n发现 ${errors.length} 处问题(子游戏 ${name}):`);
for (const e of errors) {
console.error(` [${e.code}] ${e.path}\n ${e.message}`);
}
process.exit(1);
}
@@ -0,0 +1,93 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { ROOT, HOST_PROJECT, frameworkUiDirOf } from './lib/paths.mjs';
import { loadSource } from './ui-migration/gameabc-source.mjs';
import { imageBuckets } from './ui-migration/buckets.mjs';
import { convertAll } from './ui-migration/convert.mjs';
import { splitSheet } from './ui-migration/frame-io.mjs';
import { frameNames } from './ui-migration/frames.mjs';
// 与 verify-frames.mjs 一致:相对 ROOT 推导,不硬编码绝对路径。
const DEFAULT_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3');
/**
* 全量转换:产出中间描述 + 切好的散图。
* 幂等:每次先清空产出目录(spec §4.4),避免残留帧文件混进图集。
* 清空对象包括中间描述目录,以及本次实际会写入的每个 atlas-{bucket} 目录
* (按 bucket 惰性清空一次,防止在同一次运行中被同 bucket 的后续图片覆盖式清掉)。
*/
export function runConvert(opts = {}) {
const projectDir = opts.projectDir ?? DEFAULT_SOURCE;
const intermediateDir = opts.intermediateDir ?? join(ROOT, 'ui-migration', 'intermediate');
const uiDir = opts.uiDir ?? frameworkUiDirOf(HOST_PROJECT);
const source = loadSource(join(projectDir, 'output'));
const bucketMap = imageBuckets(source);
const { layers, manifest, warnings } = convertAll(source);
// 1) 中间描述
rmSync(intermediateDir, { recursive: true, force: true });
mkdirSync(intermediateDir, { recursive: true });
writeFileSync(join(intermediateDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(intermediateDir, 'warnings.json'), `${JSON.stringify(warnings, null, 2)}\n`);
for (const doc of layers) {
const entry = manifest.layers.find((l) => l.layerId === doc.layerId);
writeFileSync(join(intermediateDir, entry.file), `${JSON.stringify(doc, null, 2)}\n`);
}
// 2) 散图:多帧图切分,单帧图直拷
let frameCount = 0;
const cleanedBuckets = new Set();
for (const image of source.images.values()) {
const bucket = bucketMap.get(image.id) ?? 'unassigned';
const outDir = join(uiDir, `atlas-${bucket}`);
if (!cleanedBuckets.has(bucket)) {
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
cleanedBuckets.add(bucket);
}
const src = join(projectDir, 'assets', 'bmp', image.bmp);
if (!existsSync(src)) {
throw new Error(`图片文件不存在: ${src}(图 id=${image.id})`);
}
if (image.frame_all <= 1) {
copyFileSync(src, join(outDir, image.bmp));
frameCount++;
continue;
}
const names = frameNames(image);
splitSheet(readFileSync(src), image).forEach((buf, i) => {
writeFileSync(join(outDir, names[i]), buf);
frameCount++;
});
}
const unassigned = layers.filter((l) => l.bucket === 'unassigned').map((l) => l.name);
return {
layerCount: layers.length,
nodeCount: layers.reduce((s, l) => s + l.nodes.length, 0),
imageCount: source.images.size,
frameCount,
unassigned,
warningCount: warnings.length,
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const r = runConvert();
console.log(`界面 ${r.layerCount} 个 / 节点 ${r.nodeCount} 个`);
console.log(`图片 ${r.imageCount} 张 → 散图 ${r.frameCount} 张`);
if (r.unassigned.length) {
console.warn(`\n⚠️ ${r.unassigned.length} 个层的 bucket 未定,资源已落入 atlas-unassigned,需人工归位:`);
for (const n of r.unassigned) console.warn(` ${n}`);
}
if (r.warningCount > 0) {
console.warn(
`\n⚠️ ${r.warningCount} 条已知的源数据遗留问题(FrameIndex 超出图帧数,已回退为帧 1),`
+ '详情见 ui-migration/intermediate/warnings.json',
);
}
}
@@ -0,0 +1,54 @@
#!/usr/bin/env node
// CLI 入口: Layer XML → Cocos prefab JSON (+ 对比 diff, 如提供 YouleNexus 现存 prefab).
//
// 用法: node legacy-layer-to-cocos-prefab-cli.mjs <input.xml> [<youle-prefab>] <output-dir> [--target-name <name>]
//
// 设计要点:
// - 只输出到隔离目录 (out/legacy-migration/), 不直接覆盖 YouleNexus 原 prefab.
// - targetName 只影响输出的 prefab/root 名称与 atlas 选择; 默认使用 XML layerName.
import { readLayerXml, emitPrefabJson, selectAtlasForLayerName } from './legacy-layer-to-cocos-prefab.mjs';
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join } from 'node:path';
const args = process.argv.slice(2);
let inputXml = null;
let youlePrefab = null;
let outputDir = null;
let targetName = null;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--target-name') {
targetName = args[++i];
if (!targetName) throw new Error('--target-name requires a value');
continue;
}
if (!inputXml) inputXml = arg;
else if (!youlePrefab) youlePrefab = arg;
else if (!outputDir) outputDir = arg;
else throw new Error(`Unexpected argument: ${arg}`);
}
if (!inputXml || !outputDir) {
console.error('Usage: node legacy-layer-to-cocos-prefab-cli.mjs <input.xml> <youle-prefab-path?> <output-dir> [--target-name <name>]');
process.exit(1);
}
const layer = readLayerXml(inputXml);
const outputName = targetName || layer.layerName;
const prefabJson = emitPrefabJson(layer, {
targetName: outputName,
atlasName: selectAtlasForLayerName(outputName),
});
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const outputPath = join(outputDir, `${outputName}.prefab`);
writeFileSync(outputPath, JSON.stringify(prefabJson, null, 2));
console.log(`Generated: ${outputPath}`);
if (youlePrefab && existsSync(youlePrefab)) {
const diffPath = `${outputPath}.diff`;
// `|| true` 确保 diff 找到差异时返回非零退出码不致 CLI 崩溃.
const diff = execSync(`diff -u "${youlePrefab}" "${outputPath}" || true`, { encoding: 'utf8' });
writeFileSync(diffPath, diff);
console.log(`Diff: ${diffPath} (${diff.split('\n').length} lines)`);
}
@@ -0,0 +1,920 @@
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 };
}
@@ -0,0 +1,29 @@
import { symlinkSync, lstatSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { platform } from 'node:process';
// Windows 用 junction(免管理员、仅限目录);其它平台用 dir 符号链接
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
export function isLink(p) {
try {
return lstatSync(p).isSymbolicLink();
} catch {
return false;
}
}
// 在 linkPath 建一个指向 targetPath 的目录链接。
// 返回 'created' | 'exists';目标不存在或 linkPath 已被真实文件占用则抛错。
export function ensureJunction(linkPath, targetPath) {
const target = resolve(targetPath); // junction 要求绝对目标
if (!existsSync(target)) {
throw new Error(`junction target 不存在: ${target}`);
}
if (isLink(linkPath)) return 'exists';
if (existsSync(linkPath)) {
throw new Error(`路径已存在且非链接,拒绝覆盖: ${linkPath}`);
}
symlinkSync(target, linkPath, LINK_TYPE);
return 'created';
}
@@ -0,0 +1,101 @@
import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
import { join, posix, dirname, relative, sep, resolve, basename } from 'node:path';
import { listCoverableAssets, validateOverrides } from './skin.mjs';
import { gameOverrideDir, frameworkUiDirOf } from './paths.mjs';
/**
* 工程缓存目录:实体化时跳过。
* **只匹配工程根的直接子目录**——绝不可按目录名在任意层级匹配,否则会连
* `assets/game/native/` 这类业务目录一起吞掉(与 .gitignore 无锚点模式同一类错误,见 Task 1)。
*/
const CACHE_DIRS = new Set(['library', 'temp', 'local', 'build', 'native', 'profiles', 'node_modules']);
export class SkinValidationError extends Error {
constructor(errors) {
super(`皮肤覆盖校验未通过(${errors.length} 处问题)`);
this.name = 'SkinValidationError';
this.errors = errors;
}
}
/**
* 把子游戏工程实体化到 destDir:
* `assets/framework` 由 junction 变成对 frameworkSrc 的真实目录拷贝;跳过缓存目录。
* **frameworkSrc 全程只读**(spec §5.1 ①)。
*/
export function materialize(gameDir, frameworkSrc, destDir) {
if (existsSync(destDir)) throw new Error(`目标已存在:${destDir}`);
mkdirSync(destDir, { recursive: true });
// 1) 拷工程本体,跳过缓存目录与 assets/framework(后者单独从真源拷)
const frameworkRel = join('assets', 'framework');
const gameDirNorm = resolve(gameDir);
cpSync(gameDir, destDir, {
recursive: true,
dereference: true,
filter: (src) => {
// 处理 Windows UNC 长路径前缀 (\\?\):cpSync 传入的 src 在 Windows 上
// 每一层都带该前缀,若不归一化,下面任何基于 rel 的比较都会恒假
// (包括曾经直接 `src === frameworkLink` 的写法——那是 Critical 1 的成因)。
const srcNorm = resolve(src.replace(/^\\\\\?\\/, ''));
const rel = relative(gameDirNorm, srcNorm);
if (!rel) return true; // gameDir 自身
if (rel === frameworkRel) return false; // junction:单独从真源拷贝,不经此路径解引用
return !CACHE_DIRS.has(rel.split(sep)[0]); // 只看第一段:锚定到工程根
},
});
// 2) 从真源实体化 framework(不依赖 junction 存在,spec §5.1 ②)
cpSync(frameworkSrc, join(destDir, 'assets', 'framework'), { recursive: true, dereference: true });
}
/**
* 把临时工程内的 `assets/game/override/**` 合成到 `assets/framework/ui/**`。
* 先整体校验,不通过则抛错且**不写任何文件**(避免半成品)。
*/
export function composeSkin(destDir) {
// 路径推导唯一权威在 paths.mjs(check-skin 用同一套),composeSkin 不再自己拼一份地图
const frameworkUiDir = frameworkUiDirOf(destDir);
const overrideDir = gameOverrideDir(destDir);
const gameName = basename(destDir);
const { matched, errors } = validateOverrides(frameworkUiDir, overrideDir);
if (errors.length) throw new SkinValidationError(errors);
for (const rel of matched) {
const parts = rel.split(posix.sep);
const from = join(overrideDir, ...parts);
const to = join(frameworkUiDir, ...parts);
mkdirSync(dirname(to), { recursive: true });
copyFileSync(from, to);
// 例外通道:override 自带 meta 时,把 uuid 改回框架原值以保持引用
const ovMeta = `${from}.meta`;
const fwMeta = `${to}.meta`;
if (existsSync(ovMeta) && existsSync(fwMeta)) {
const fwUuid = JSON.parse(readFileSync(fwMeta, 'utf8')).uuid;
if (!fwUuid) {
// 静默通过会写出没有 uuid 的 .meta——Cocos 重新生成 UUID,框架 Prefab 对该
// 资源的引用在发布包里断裂,这正是"保留 .meta"整个设计要防的唯一失效模式。
throw new SkinValidationError([{
path: rel,
code: 'meta-missing-uuid',
message: `${gameName}: 框架侧 .meta 没有 uuid(${fwMeta}),无法为例外通道保留引用,请先修复框架该文件的 .meta`,
}]);
}
const merged = JSON.parse(readFileSync(ovMeta, 'utf8'));
merged.uuid = fwUuid;
writeFileSync(fwMeta, `${JSON.stringify(merged, null, 2)}\n`);
} else if (existsSync(ovMeta) && !existsSync(fwMeta)) {
// 框架侧没有 .meta 时整段合并被跳过,override 自带的 meta 会被无声丢弃——
// 同样违反"下游不兜底":必须显式报错,而不是悄悄忽略 override 的意图。
throw new SkinValidationError([{
path: rel,
code: 'meta-orphan',
message: `${gameName}: override 提供了 ${rel}.meta,但框架侧没有对应的 .meta(${fwMeta}),无法合并 uuid(例外通道要求框架侧资源本身已有 .meta)`,
}]);
}
}
return { matched, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
}
@@ -0,0 +1,41 @@
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
// 本文件位于 <monorepo>/scripts/lib/paths.mjs → 上溯两级为 monorepo 根
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const HOST_PROJECT = join(ROOT, 'YouleNexus');
export const FRAMEWORK_SRC = join(HOST_PROJECT, 'assets', 'framework');
export const GAMES_DIR = join(ROOT, 'games');
export const SEED_DIR = join(ROOT, 'templates', 'game-seed');
export const BUILD_WORKSPACE = join(ROOT, 'build-workspace');
// 子游戏的皮肤覆盖目录
export function gameOverrideDir(projectDir) {
return join(projectDir, 'assets', 'game', 'override');
}
// 某工程(宿主真源,或实体化后的临时工作区)的 framework/ui 目录。
// check-skin 与 build-game(composeSkin) 共用同一份推导,不各自拼路径(唯一权威)。
export function frameworkUiDirOf(projectDir) {
return join(projectDir, 'assets', 'framework', 'ui');
}
export const FRAMEWORK_UI_DIR = frameworkUiDirOf(HOST_PROJECT);
export function readCreatorVersion(projectDir) {
try {
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
return pkg?.creator?.version ?? null;
} catch {
return null;
}
}
export function listGameProjects(gamesDir = GAMES_DIR) {
if (!existsSync(gamesDir)) return [];
return readdirSync(gamesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => join(gamesDir, d.name))
.filter((dir) => existsSync(join(dir, 'package.json')));
}
@@ -0,0 +1,53 @@
import { cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
const CACHE_DIRS = ['library', 'temp', 'local', 'build'];
// 复制种子工程到 destDir,并清理可能残留的缓存目录
export function copySeed(seedDir, destDir) {
if (existsSync(destDir)) throw new Error(`目标已存在: ${destDir}`);
cpSync(seedDir, destDir, { recursive: true });
for (const c of CACHE_DIRS) {
rmSync(join(destDir, c), { recursive: true, force: true });
}
}
// 修改工程 package.json 的 name 并分配全新 uuid(保留 creator.version 等其它字段)
export function setProjectIdentity(projectDir, name) {
const pkgPath = join(projectDir, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
pkg.name = name;
pkg.uuid = randomUUID();
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
return pkg;
}
// 铺出子游戏私有 game/ 骨架:IGameModule 占位 + theme 占位 + override 目录
export function scaffoldGameAssets(projectDir, name) {
const gameDir = join(projectDir, 'assets', 'game');
mkdirSync(join(gameDir, 'override'), { recursive: true });
writeFileSync(join(gameDir, 'override', '.gitkeep'), '');
writeFileSync(join(gameDir, 'GameModule.ts'), gameModuleStub(name));
writeFileSync(join(gameDir, 'theme.ts'), themeStub(name));
}
function gameModuleStub(name) {
return `/**
* ${name} 子游戏模块骨架。
* TODO(Plan 5): 待 framework/sdk 的 IGameModule / GameContext 接口定稿后实现
* (route / onEnter / onReceive / onReconnect 等)。
* 协议字段一律参见 docs/protocol/(spec §0.1:只引用、不内联复制)。
*/
export const GAME_ROUTE = '${name}';
`;
}
function themeStub(name) {
return `/**
* ${name} 主题覆盖骨架。
* TODO(Plan 4): 待 framework/ui 的皮肤变量清单定稿后填写主色/字体/图集映射等。
*/
export const theme = {};
`;
}
@@ -0,0 +1,130 @@
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
import { join, posix, sep, extname, relative } from 'node:path';
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const SPINE_ROOT = 'spine'; // framework/ui/spine/<资源名>/ 为一套 Spine
const SKIPPED = new Set(['.gitkeep']);
/**
* 可被子游戏覆盖的框架资源目录(spec §2 规则 3 枚举的 8 个目录)。
* `theme/` 不在其中——那是 §4.2 规定的框架 TS 代码位置,不是资源,不可覆盖。
*/
const COVERABLE_DIRS = new Set([
'atlas-common', 'atlas-login', 'atlas-hall', 'atlas-room',
'standalone', 'spine', 'audio', 'font',
]);
/** 列出目录下所有资源的相对路径(正斜杠),排除 .meta 与 .gitkeep。 */
export function listAssets(dir) {
if (!existsSync(dir)) return [];
const out = [];
(function walk(cur) {
for (const name of readdirSync(cur)) {
const p = join(cur, name);
if (statSync(p).isDirectory()) { walk(p); continue; }
if (name.endsWith('.meta') || SKIPPED.has(name)) continue;
out.push(relative(dir, p).split(sep).join(posix.sep));
}
})(dir);
return out;
}
/**
* 列出框架 UI 目录下**可被覆盖**的资源(白名单目录,见 COVERABLE_DIRS)。
* `frameworkUiDir` 下还有 `theme/`、`README.md` 等非资源内容,
* 不属于可覆盖范围,也不计入"框架可覆盖资源共 N 个"的统计。
*/
export function listCoverableAssets(frameworkUiDir) {
return listAssets(frameworkUiDir).filter((rel) => COVERABLE_DIRS.has(rel.split(posix.sep)[0]));
}
/** 读 PNG 宽高;非 PNG 或读取失败返回 null。 */
export function readPngSize(file) {
try {
const b = readFileSync(file);
if (b.length < 24 || b.toString('ascii', 1, 4) !== 'PNG') return null;
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
} catch { return null; }
}
const isImage = (rel) => IMAGE_EXTS.has(extname(rel).toLowerCase());
const isSpine = (rel) => rel.split(posix.sep)[0] === SPINE_ROOT;
/** Spine 资源的分组键:spine/<资源名> */
const spineGroup = (rel) => rel.split(posix.sep).slice(0, 2).join(posix.sep);
/**
* 校验 override 是否合法。规则见 spec §3.2。
* 返回 { matched: 相对路径[], errors: {path, code, message}[] }。
*/
export function validateOverrides(frameworkUiDir, overrideDir) {
const fwList = listCoverableAssets(frameworkUiDir);
const fwSet = new Set(fwList);
const ovList = listAssets(overrideDir);
const ovSet = new Set(ovList);
const matched = [];
const errors = [];
// 1) 逐个 override 校验:孤儿 / 尺寸
for (const rel of ovList) {
if (!fwSet.has(rel)) {
const base = rel.slice(0, rel.length - extname(rel).length);
const alt = fwList.find((f) => f.slice(0, f.length - extname(f).length) === base);
errors.push({
path: rel,
code: 'orphan',
message: alt
? `框架里没有 ${rel},但存在同名的 ${alt}——扩展名对不上`
: `框架里没有对应资源:${rel}`,
});
continue;
}
// Spine 贴图豁免尺寸校验(区域坐标在 .atlas 内,整套替换时自洽)
const metaPath = join(overrideDir, ...`${rel}.meta`.split(posix.sep));
const needSizeCheck =
isImage(rel) && !isSpine(rel) && !existsSync(metaPath);
if (needSizeCheck) {
const a = readPngSize(join(frameworkUiDir, ...rel.split(posix.sep)));
const b = readPngSize(join(overrideDir, ...rel.split(posix.sep)));
if (!a || !b) {
const side = !a ? '框架' : 'override';
errors.push({
path: rel,
code: 'size-unreadable',
message:
`框架 UI 图片必须是 PNG(无法读取尺寸:${side})。` +
`请改用 PNG,或连 .meta 一起提供(例外通道)`,
});
continue;
}
if (a.width !== b.width || a.height !== b.height) {
errors.push({
path: rel,
code: 'size-mismatch',
message:
`尺寸不一致:框架 ${a.width}x${a.height},override ${b.width}x${b.height}。` +
`请改为同尺寸,或连 .meta 一起提供(例外通道)`,
});
continue;
}
}
matched.push(rel);
}
// 2) Spine 整套完整性:override 动了某套,就必须整套都在
const touchedGroups = new Set(ovList.filter(isSpine).map(spineGroup));
for (const g of touchedGroups) {
const required = fwList.filter((f) => isSpine(f) && spineGroup(f) === g);
const missing = required.filter((f) => !ovSet.has(f));
if (missing.length) {
errors.push({
path: g,
code: 'spine-incomplete',
message: `Spine 必须整套覆盖,缺少:${missing.join('、')}`,
});
}
}
return { matched, errors };
}
@@ -0,0 +1,76 @@
// 真机联调脚本(手动跑,非单测):复用框架代码,对真实配置服 + 本地游戏服跑 player_login 往返。
// 运行:node --import tsx scripts/login-live.ts (cwd: cocoscreator_projects)
import WebSocket from 'ws';
import { resolveBootstrap } from '../YouleNexus/assets/framework/config/bootstrap.ts';
import { HttpConfigFetcher } from '../YouleNexus/assets/framework/config/remote-config-fetcher.ts';
import { resolveRuntimeMode } from '../YouleNexus/assets/framework/config/runtime-mode.ts';
import { NetClient } from '../YouleNexus/assets/framework/net/net-client.ts';
import { parseLoginResponse } from '../YouleNexus/assets/framework/protocol/login.ts';
import type { Transport } from '../YouleNexus/assets/framework/net/transport.ts';
/** Node 侧 Transport:用 ws 包实现框架的 Transport 接口(等价生产的 CocosWebSocketTransport)。 */
class WsTransport implements Transport {
private ws: WebSocket | null = null;
private openCb?: () => void;
private msgCb?: (f: string) => void;
private closeCb?: () => void;
connect(url: string): void {
const ws = new WebSocket(url);
this.ws = ws;
ws.on('open', () => this.openCb?.());
ws.on('message', (data: any) => this.msgCb?.(data.toString()));
ws.on('close', () => this.closeCb?.());
ws.on('error', (e: any) => console.log('[ws error]', e?.message ?? e));
}
send(frame: string): void { this.ws?.send(frame); }
close(): void { try { this.ws?.close(); } catch { /* ignore */ } }
onOpen(cb: () => void): void { this.openCb = cb; }
onMessage(cb: (f: string) => void): void { this.msgCb = cb; }
onClose(cb: () => void): void { this.closeCb = cb; }
}
async function main(): Promise<void> {
const mode = resolveRuntimeMode(true, 'debug'); // 强制 debug
console.log('== 联调开始 (mode=' + mode + ', profile=staging) ==');
const result = await resolveBootstrap({
mode,
win: {},
search: '?profile=staging',
fetcher: new HttpConfigFetcher(),
isNative: false,
});
console.log('profile :', result.profile.name);
console.log('servers :', result.servers, '(serverOverride 生效)');
console.log('identity :', result.identity);
console.log('account :', result.account);
const data = (result.rawConfig as any)?.data;
console.log('rawConfig :', data ? '顶层键=' + Object.keys(data).join(',') : null);
const net = new NetClient({
servers: result.servers,
transportFactory: () => new WsTransport(),
debug: true,
});
net.setIdentity({ ...result.identity, ...(result.account ?? {}) } as any);
net.on('open', () => console.log('[event] open(已连上本地服)'));
net.on('login', (loginData: any) => {
const parsed = parseLoginResponse(loginData);
console.log('[event] login → parseLoginResponse:', parsed);
console.log(parsed.ok ? `✅ 登录成功 playerid=${parsed.playerid} inRoom=${parsed.inRoom} hasBattle=${parsed.hasBattle}` : '❌ 登录失败 state≠0');
setTimeout(() => { net.stop(); process.exit(0); }, 500);
});
net.on('message', (m: any) => console.log('[event] message route=' + m.route + ' rpc=' + m.rpc));
net.on('slow', () => console.log('[event] slow(收包超时)'));
net.on('reconnecting', (s: any) => console.log('[event] reconnecting →', s));
net.on('serverSwitch', (s: any) => console.log('[event] serverSwitch →', s));
net.on('kicked', (d: any) => console.log('[event] kicked', d));
net.start();
setTimeout(() => { console.log('⏱ 15s 内未完成登录,结束。'); net.stop(); process.exit(2); }, 15000);
}
main().catch((e) => { console.error('❌ 联调异常:', e?.message ?? e); process.exit(1); });
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { SEED_DIR, GAMES_DIR, FRAMEWORK_SRC } from './lib/paths.mjs';
import { copySeed, setProjectIdentity, scaffoldGameAssets } from './lib/scaffold.mjs';
import { ensureJunction } from './lib/junction.mjs';
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
// 新建一款子游戏工程。opts 可注入路径用于测试。
export function newGame(name, opts = {}) {
const seedDir = opts.seedDir ?? SEED_DIR;
const gamesDir = opts.gamesDir ?? GAMES_DIR;
const frameworkSrc = opts.frameworkSrc ?? FRAMEWORK_SRC;
if (!name || !NAME_RE.test(name)) {
throw new Error(`非法子游戏名称: "${name}"(仅限小写字母/数字/-/_,且以字母数字开头)`);
}
const projectDir = join(gamesDir, name);
copySeed(seedDir, projectDir); // 目标已存在会抛错
setProjectIdentity(projectDir, name);
scaffoldGameAssets(projectDir, name);
const link = join(projectDir, 'assets', 'framework');
ensureJunction(link, frameworkSrc);
return { projectDir, link };
}
// CLI 入口
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const name = process.argv[2];
if (!name) {
console.error('用法: npm run new-game <name>');
process.exit(1);
}
const { projectDir } = newGame(name);
console.log(`已创建子游戏工程: ${projectDir}`);
console.log(`下一步: 用 Cocos Creator 打开该目录`);
}
@@ -0,0 +1,25 @@
// 递归收集 framework-tests 下所有 *.test.{ts,mjs} 交给 node 测试运行器。
// node --test 的 glob 模式需 v20.14+,本仓库当前 node 20.13.x 不支持,
// 故用此零依赖、跨平台 runner 显式枚举文件,保持 test:framework 行为一致。
// *.test.mjs: legacy-layer-migration 等纯 ESM 脚本测试 (Tasks 9-12 legacy 迁移管线).
import { readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
const root = 'framework-tests';
const files = [];
(function walk(dir) {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) walk(p);
else if (p.endsWith('.test.ts') || p.endsWith('.test.mjs')) files.push(p);
}
})(root);
if (files.length === 0) {
console.error(`[run-framework-tests] 未发现任何 *.test.{ts,mjs}(root=${root})`);
process.exit(1);
}
const r = spawnSync(process.execPath, ['--import', 'tsx', '--test', ...files], { stdio: 'inherit' });
process.exit(r.status ?? 1);
@@ -0,0 +1,62 @@
#!/usr/bin/env node
// 批量跑 legacy-layer → Cocos prefab CLI。
// 每条 target: <source xml> → <youle prefab> → <OUT_DIR/prefab>.prefab + .diff.
//
// 设计要点:
// - 全部输出到隔离 OUT_DIR (out/legacy-migration/), 不直接覆盖 YouleNexus 现有 prefab.
// - 单个 target 失败 (例如 ImgResID 未导入 sprite) 不阻塞后续 target, 日志记录后继续.
// - per "下游不兜底" 原则: CLI 本身必须 throw 让错误暴露; batch 仅负责捕获 + 汇总, 不做修补.
import { execSync } from 'node:child_process';
import { join } from 'node:path';
import { ROOT } from './lib/paths.mjs';
const OUT_DIR = join(ROOT, 'YouleNexus', 'out', 'legacy-migration');
const SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'save');
// 按 spec 已核实映射表 (11 个 prefab). Layer10_Protocol + Login_Layer + Layer619_BackOtherGame 三个 PoC 已存在,
// 但 batch 仍会重新生成它们 (idempotent — 覆盖 out/legacy-migration/*.prefab + .diff).
const targets = [
{ xml: 'Layer00002.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab', name: 'Login_Layer', note: 'PoC 3 (Layer00002)' },
{ xml: 'Layer00017.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/views/PlayerInfoView.prefab', name: 'MenuPlayerInfo_Layer', note: '拍板 A (Layer00017)' },
{ xml: 'Layer00029.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Group106_PhoneVerify.prefab', name: 'phoneInfo' },
{ xml: 'Layer00010.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer10_Protocol.prefab', name: 'Protol_Layer', note: 'PoC 2 (Layer00010)' },
{ xml: 'Layer00609.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer609_Setting.prefab', name: 'Setting_Layer' },
{ xml: 'Layer00612.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer612_Tips.prefab', name: 'Tips_Layer' },
{ xml: 'Layer00613.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer613_RoomCardUpdate.prefab', name: 'updateRoomCard_Layer' },
{ xml: 'Layer00614.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer614_Loading.prefab', name: 'Loading_Layer' },
{ xml: 'Layer00615.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer615_Reconnect.prefab', name: 'Reconnect_Layer' },
{ xml: 'Layer00616.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer616_Kick.prefab', name: 'Kick_Layer' },
{ xml: 'Layer00008.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer617_Notice.prefab', name: 'Notice_Layer', note: '拍板 B (Layer00008)' },
{ xml: 'Layer00619.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer619_BackOtherGame.prefab', name: 'BackHall_Layer', note: 'PoC 1 (Layer00619)' },
{ xml: 'Layer00620.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer620_Record.prefab', name: 'recordLayer' },
];
const results = { ok: [], failed: [] };
console.log(`Generating ${targets.length} prefabs to ${OUT_DIR}\n`);
for (const t of targets) {
console.log(`\n=== Processing ${t.name} (${t.xml}) ===`);
try {
execSync(
`node scripts/legacy-layer-to-cocos-prefab-cli.mjs "${SOURCE}/${t.xml}" "${ROOT}/${t.prefab}" "${OUT_DIR}"`,
{ stdio: 'inherit' }
);
results.ok.push(t.name);
} catch (e) {
console.error(` ⚠️ FAILED: ${t.name}: ${(e.message || String(e)).slice(0, 200)}`);
results.failed.push({ name: t.name, xml: t.xml, error: (e.message || String(e)).slice(0, 200) });
// Continue with next target — partial success acceptable per "不静默兜底" + batch resilience
}
}
console.log('\n=== Batch complete ===');
console.log(`Output dir: ${OUT_DIR}`);
console.log(`OK: ${results.ok.length} | FAILED: ${results.failed.length}`);
if (results.failed.length > 0) {
console.log('\nFailed targets:');
for (const f of results.failed) {
console.log(` - ${f.name} (${f.xml}): ${f.error}`);
}
}
console.log('\nNext step: human reviews each .diff and decides whether to overwrite YouleNexus original prefabs.');
process.exitCode = results.failed.length > 0 ? 1 : 0;
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { join } from 'node:path';
import { mkdirSync, existsSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { GAMES_DIR, FRAMEWORK_SRC, listGameProjects } from './lib/paths.mjs';
import { ensureJunction } from './lib/junction.mjs';
// 为 gamesDir 下每个工程在 assets/framework 建指向 frameworkSrc 的 junction
export function setupLinks(gamesDir, frameworkSrc) {
const results = [];
for (const proj of listGameProjects(gamesDir)) {
const assetsDir = join(proj, 'assets');
if (!existsSync(assetsDir)) mkdirSync(assetsDir, { recursive: true });
const link = join(assetsDir, 'framework');
const status = ensureJunction(link, frameworkSrc);
results.push({ project: proj, link, status });
}
return results;
}
// CLI 入口
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const results = setupLinks(GAMES_DIR, FRAMEWORK_SRC);
if (results.length === 0) {
console.log('games/ 下暂无子游戏工程。');
}
for (const r of results) {
console.log(`[${r.status}] ${r.link}`);
}
}
@@ -0,0 +1,147 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { platform } from 'node:process';
import { deflateSync } from 'node:zlib';
import { buildGame } from '../build-game.mjs';
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
function crc32(buf) {
let c = ~0;
for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }
return ~c >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, c]);
}
function makePng(w, h, rgb = [0, 0, 0]) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.alloc(h * (1 + w * 3));
for (let y = 0; y < h; y++) {
const off = y * (1 + w * 3);
raw[off] = 0;
for (let x = 0; x < w; x++) {
raw[off + 1 + x * 3] = rgb[0]; raw[off + 2 + x * 3] = rgb[1]; raw[off + 3 + x * 3] = rgb[2];
}
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
]);
}
const DEFAULT_BTN = makePng(64, 64, [1, 2, 3]);
const SKINNED = makePng(64, 64, [9, 9, 9]);
function put(dir, rel, content) {
const p = join(dir, ...rel.split('/'));
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, content);
}
function scaffold(gameVersion = '3.8.8') {
const root = mkdtempSync(join(tmpdir(), 'youle-build-'));
const hostProject = join(root, 'nexus');
const frameworkSrc = join(hostProject, 'assets', 'framework');
mkdirSync(frameworkSrc, { recursive: true });
writeFileSync(join(hostProject, 'package.json'), JSON.stringify({ name: 'YouleNexus', creator: { version: '3.8.8' } }));
put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
// junction 故意指向一个诱饵目录而非 frameworkSrc 自身,与 materialize.test.mjs 同理:
// 否则"经 junction 拷来的字节"与"从真源拷来的字节"完全相同,测不出 Critical 1 那类 bug。
const frameworkDecoy = join(root, 'decoy', 'assets', 'framework');
mkdirSync(frameworkDecoy, { recursive: true });
put(frameworkDecoy, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
put(frameworkDecoy, 'ui/atlas-hall/__decoy_marker__.png', DEFAULT_BTN);
const gamesDir = join(root, 'games');
const gameDir = join(gamesDir, 'demo');
mkdirSync(join(gameDir, 'assets'), { recursive: true });
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: gameVersion } }));
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
return { root, hostProject, frameworkSrc, frameworkDecoy, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
function baseOpts(s, extra = {}) {
return {
gamesDir: s.gamesDir, frameworkSrc: s.frameworkSrc, hostProject: s.hostProject,
workspace: s.workspace, platform: 'web-mobile',
frameworkCommit: 'abc1234', now: '2026-08-27T00:00:00.000Z',
runBuild: () => {},
...extra,
};
}
test('buildGame 走通全流程:实体化 → 合成 → 调构建 → 写 build-info', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED);
const calls = [];
const r = buildGame('demo', baseOpts(s, { runBuild: (dir, p) => calls.push([dir, p]) }));
assert.ok(readFileSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png')).equals(SKINNED));
assert.ok(readFileSync(join(s.frameworkSrc, 'ui', 'atlas-hall', 'btn.png')).equals(DEFAULT_BTN), '框架真源必须只读:合成只能发生在临时工作区');
assert.ok(!existsSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
'framework 必须来自 frameworkSrc 真源,不能残留 junction 诱饵目标独有的文件(Critical 1)');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], r.workspaceDir);
assert.equal(calls[0][1], 'web-mobile');
const info = JSON.parse(readFileSync(join(r.workspaceDir, 'build-info.json'), 'utf8'));
assert.equal(info.game, 'demo');
assert.equal(info.frameworkCommit, 'abc1234');
assert.equal(info.platform, 'web-mobile');
assert.deepEqual(info.skin, { matched: 1, frameworkTotal: 1 });
} finally { cleanup(s.root); }
});
test('buildGame 在引擎版本不一致时中止,且不调用构建', () => {
const s = scaffold('3.8.6');
try {
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/引擎版本不一致/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 在皮肤校验失败时中止,且不调用构建', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X');
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/皮肤覆盖校验未通过/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 重复构建:清掉旧工作区后重建(幂等)', () => {
const s = scaffold();
try {
const opts = baseOpts(s);
buildGame('demo', opts);
const r2 = buildGame('demo', opts); // 不应因目标已存在而抛错
assert.ok(existsSync(join(r2.workspaceDir, 'build-info.json')));
} finally { cleanup(s.root); }
});
test('buildGame 对不存在的子游戏抛错', () => {
const s = scaffold();
try {
assert.throws(() => buildGame('nosuch', baseOpts(s)), /找不到子游戏工程/);
} finally { cleanup(s.root); }
});
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync, writeFileSync } from 'node:fs';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { bumpVersion } from '../bump-cocos.mjs';
test('bumpVersion 改写各工程 creator.version 并报告 from/to', () => {
const mono = makeTempMonorepo();
try {
const a = makeGameProject(mono.games, 'a', '3.8.8');
const changed = bumpVersion([mono.host, a], '3.8.9');
assert.equal(changed.length, 2);
assert.equal(changed[1].from, '3.8.8');
assert.equal(changed[1].to, '3.8.9');
const pkgA = JSON.parse(readFileSync(join(a, 'package.json'), 'utf8'));
assert.equal(pkgA.creator.version, '3.8.9');
const pkgHost = JSON.parse(readFileSync(join(mono.host, 'package.json'), 'utf8'));
assert.equal(pkgHost.creator.version, '3.8.9');
} finally {
cleanup(mono.root);
}
});
test('bumpVersion 对缺 creator 字段的工程补建后写入', () => {
const mono = makeTempMonorepo();
try {
const a = makeGameProject(mono.games, 'a', '3.8.8');
writeFileSync(join(a, 'package.json'), JSON.stringify({ name: 'a' }));
bumpVersion([a], '3.8.9');
const pkgA = JSON.parse(readFileSync(join(a, 'package.json'), 'utf8'));
assert.equal(pkgA.creator.version, '3.8.9');
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { checkVersions } from '../check-cocos-version.mjs';
test('checkVersions 全部一致时 mismatches 为空', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'a', '3.8.8');
makeGameProject(mono.games, 'b', '3.8.8');
const r = checkVersions(mono.host, mono.games);
assert.equal(r.expected, '3.8.8');
assert.deepEqual(r.mismatches, []);
} finally {
cleanup(mono.root);
}
});
test('checkVersions 报告与宿主不一致的工程', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'a', '3.8.8');
makeGameProject(mono.games, 'b', '3.8.6');
const r = checkVersions(mono.host, mono.games);
assert.equal(r.mismatches.length, 1);
assert.match(r.mismatches[0].project, /[\\/]b$/);
assert.equal(r.mismatches[0].version, '3.8.6');
assert.equal(r.mismatches[0].expected, '3.8.8');
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { deflateSync } from 'node:zlib';
import { checkSkin } from '../check-skin.mjs';
function crc32(buf) {
let c = ~0;
for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }
return ~c >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, c]);
}
function makePng(w, h) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.alloc(h * (1 + w * 3));
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
]);
}
function put(dir, rel, content) {
const p = join(dir, ...rel.split('/'));
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, content);
}
function scaffold() {
const root = mkdtempSync(join(tmpdir(), 'youle-checkskin-'));
const frameworkUiDir = join(root, 'ui');
const gamesDir = join(root, 'games');
mkdirSync(frameworkUiDir, { recursive: true });
mkdirSync(join(gamesDir, 'demo', 'assets', 'game', 'override'), { recursive: true });
put(frameworkUiDir, 'atlas-hall/btn.png', makePng(64, 64));
put(frameworkUiDir, 'atlas-hall/bg.png', makePng(64, 64));
return { root, frameworkUiDir, gamesDir, overrideDir: join(gamesDir, 'demo', 'assets', 'game', 'override') };
}
test('checkSkin 统计命中数与框架资源总数', () => {
const s = scaffold();
try {
put(s.overrideDir, 'atlas-hall/btn.png', makePng(64, 64));
const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir });
assert.deepEqual(r.errors, []);
assert.deepEqual(r.matched, ['atlas-hall/btn.png']);
assert.equal(r.frameworkTotal, 2);
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
test('checkSkin 报出孤儿 override', () => {
const s = scaffold();
try {
put(s.overrideDir, 'atlas-hall/nope.png', makePng(64, 64));
const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir });
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'orphan');
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
test('checkSkin 对不存在的子游戏抛错', () => {
const s = scaffold();
try {
assert.throws(
() => checkSkin('nosuch', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir }),
/找不到子游戏工程/,
);
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
test('checkSkin 对没有 override 目录的子游戏返回零命中而非报错', () => {
const s = scaffold();
try {
rmSync(s.overrideDir, { recursive: true, force: true });
const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir });
assert.deepEqual(r.matched, []);
assert.deepEqual(r.errors, []);
} finally { rmSync(s.root, { recursive: true, force: true }); }
});
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
const scriptsDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const CLI = [
'setup-links.mjs', 'new-game.mjs', 'check-cocos-version.mjs', 'bump-cocos.mjs',
'check-skin.mjs', 'build-game.mjs',
];
for (const name of CLI) {
test(`${name} 被动态 import 时不崩溃(argv[1] undefined)`, () => {
const url = pathToFileURL(join(scriptsDir, name)).href;
const r = spawnSync(process.execPath,
['--input-type=module', '-e', `import(${JSON.stringify(url)}).then(()=>process.exit(0))`],
{ encoding: 'utf8' });
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.ok(!/TypeError/.test(r.stderr), `不应出现 TypeError: ${r.stderr}`);
});
}
@@ -0,0 +1,47 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// 构造一个临时 monorepo:YouleNexus(host) + framework 真源 + 空 games/
export function makeTempMonorepo() {
const root = mkdtempSync(join(tmpdir(), 'youle-mono-'));
const host = join(root, 'YouleNexus');
const framework = join(host, 'assets', 'framework');
mkdirSync(framework, { recursive: true });
writeFileSync(
join(host, 'package.json'),
JSON.stringify({ name: 'YouleNexus', uuid: 'host-uuid', creator: { version: '3.8.8' } }, null, 2)
);
writeFileSync(join(framework, 'README.md'), '# framework src\n');
const games = join(root, 'games');
mkdirSync(games, { recursive: true });
return { root, host, framework, games };
}
// 在 gamesDir 下造一个子游戏工程(含 package.json)
export function makeGameProject(gamesDir, name, version = '3.8.8') {
const dir = join(gamesDir, name);
mkdirSync(join(dir, 'assets'), { recursive: true });
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name, uuid: `${name}-uuid`, creator: { version } }, null, 2)
);
return dir;
}
// 造一个最小种子工程
export function makeSeed(root) {
const seed = join(root, 'templates', 'game-seed');
mkdirSync(join(seed, 'assets'), { recursive: true });
mkdirSync(join(seed, 'settings'), { recursive: true });
writeFileSync(
join(seed, 'package.json'),
JSON.stringify({ name: 'game-seed', uuid: 'seed-uuid', creator: { version: '3.8.8' } }, null, 2)
);
writeFileSync(join(seed, 'settings', 'v2.json'), '{}\n');
return seed;
}
export function cleanup(root) {
rmSync(root, { recursive: true, force: true });
}
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { makeTempMonorepo, cleanup } from './helpers.mjs';
import { ensureJunction, isLink } from '../lib/junction.mjs';
test('ensureJunction 创建链接后可经链接读到真源文件', () => {
const mono = makeTempMonorepo();
try {
const link = join(mono.games, 'demo-framework');
const status = ensureJunction(link, mono.framework);
assert.equal(status, 'created');
assert.ok(isLink(link));
assert.equal(readFileSync(join(link, 'README.md'), 'utf8'), '# framework src\n');
} finally {
cleanup(mono.root);
}
});
test('ensureJunction 对已存在链接返回 exists 且不抛错', () => {
const mono = makeTempMonorepo();
try {
const link = join(mono.games, 'demo-framework');
ensureJunction(link, mono.framework);
assert.equal(ensureJunction(link, mono.framework), 'exists');
} finally {
cleanup(mono.root);
}
});
test('ensureJunction 目标不存在时抛错', () => {
const mono = makeTempMonorepo();
try {
assert.throws(() => ensureJunction(join(mono.games, 'x'), join(mono.root, 'no-such')), /target 不存在/);
} finally {
cleanup(mono.root);
}
});
test('ensureJunction 拒绝覆盖已存在的真实目录/文件', () => {
const mono = makeTempMonorepo();
try {
const link = join(mono.games, 'realfile');
writeFileSync(link, 'x');
assert.throws(() => ensureJunction(link, mono.framework), /非链接/);
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,230 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { platform } from 'node:process';
import { deflateSync } from 'node:zlib';
import { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs';
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
function crc32(buf) {
let c = ~0;
for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }
return ~c >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, c]);
}
function makePng(w, h, rgb = [0, 0, 0]) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.alloc(h * (1 + w * 3));
for (let y = 0; y < h; y++) {
const off = y * (1 + w * 3);
raw[off] = 0;
for (let x = 0; x < w; x++) {
raw[off + 1 + x * 3] = rgb[0]; raw[off + 2 + x * 3] = rgb[1]; raw[off + 3 + x * 3] = rgb[2];
}
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
]);
}
const DEFAULT_BTN = makePng(64, 64, [1, 2, 3]);
const DEFAULT_BG = makePng(64, 64, [4, 5, 6]);
const SKINNED_BTN = makePng(64, 64, [9, 9, 9]);
function put(dir, rel, content) {
const p = join(dir, ...rel.split('/'));
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, content);
}
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
function readBinary(dir, rel) { return readFileSync(join(dir, ...rel.split('/'))); }
/**
* 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境。
*
* junction **故意不指向 frameworkSrc 本身**,而是指向一个内容相似、但多一个
* 标记文件的「诱饵」目录(frameworkDecoy)。这样"实体化后的 framework 到底是
* 从 junction 解引用来的,还是从真源 frameworkSrc 单独拷的"才是可判定的——
* 若两者是同一目录,字节完全相同,Critical 1(junction 排除分支在 Windows 上
* 从未生效)这个 bug 永远不会被测出来。
*/
function scaffold() {
const root = mkdtempSync(join(tmpdir(), 'youle-mat-'));
const frameworkSrc = join(root, 'nexus', 'assets', 'framework');
mkdirSync(frameworkSrc, { recursive: true });
put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
put(frameworkSrc, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
put(frameworkSrc, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkSrc, 'core/net.ts', 'export const x=1;');
// 诱饵:junction 实际指向这里。内容与 frameworkSrc 一致,但多一个标记文件。
const frameworkDecoy = join(root, 'decoy', 'assets', 'framework');
mkdirSync(frameworkDecoy, { recursive: true });
put(frameworkDecoy, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
put(frameworkDecoy, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
put(frameworkDecoy, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkDecoy, 'core/net.ts', 'export const x=1;');
put(frameworkDecoy, 'ui/atlas-hall/__decoy_marker__.png', DEFAULT_BG); // 只存在于诱饵里
const gameDir = join(root, 'games', 'demo');
mkdirSync(join(gameDir, 'assets'), { recursive: true });
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: '3.8.8' } }));
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
put(gameDir, 'assets/game/theme.ts', 'export const theme={};');
mkdirSync(join(gameDir, 'library'), { recursive: true }); // 缓存目录,应被跳过
writeFileSync(join(gameDir, 'library', 'junk'), 'x');
return { root, frameworkSrc, frameworkDecoy, gameDir, dest: join(root, 'workspace', 'demo') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
test('materialize 把 junction 变成真实目录拷贝,并跳过缓存目录', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '框架 btn.png 被正确拷贝');
assert.equal(read(s.dest, 'assets/game/theme.ts'), 'export const theme={};', '游戏资源被拷贝');
assert.ok(!existsSync(join(s.dest, 'library')), 'library 应被跳过');
} finally { cleanup(s.root); }
});
test('materialize 只跳过工程根的缓存目录,不吞 assets 下的同名业务目录', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/native/bridge.ts', 'BRIDGE');
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.equal(read(s.dest, 'assets/game/native/bridge.ts'), 'BRIDGE', 'assets 下的 native 是业务目录,必须保留');
assert.ok(!existsSync(join(s.dest, 'library')), '工程根的 library 仍应被跳过');
} finally { cleanup(s.root); }
});
test('materialize 后写临时工程不影响框架真源', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), Buffer.from('MUTATED'));
assert.ok(readBinary(s.frameworkSrc, 'ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '真源必须只读');
} finally { cleanup(s.root); }
});
test('materialize 的 framework 来自真源 frameworkSrc,而非 junction 解引用(诱饵标记文件必须不存在)', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.ok(
!existsSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
'framework 必须单独从 frameworkSrc 真源拷贝;诱饵(junction 目标)独有的标记文件不应出现在工作区里',
);
} finally { cleanup(s.root); }
});
test('materialize 拒绝覆盖已存在的目标目录', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.throws(() => materialize(s.gameDir, s.frameworkSrc, s.dest), /已存在/);
} finally { cleanup(s.root); }
});
test('composeSkin 用 override 覆盖框架资源内容,保留框架 .meta', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN);
materialize(s.gameDir, s.frameworkSrc, s.dest);
const r = composeSkin(s.dest);
assert.deepEqual(r.matched, ['atlas-hall/btn.png'], 'matched 返回覆盖的文件');
assert.equal(r.frameworkTotal, 2, '框架资源总数');
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(SKINNED_BTN), '框架文件被 override 替换');
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'), '{"uuid":"fw-btn-uuid"}', 'meta 文件不变');
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/bg.png').equals(DEFAULT_BG), '未覆盖的保持默认');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架的值', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png.meta', '{"uuid":"game-own-uuid","ver":"1.0.0"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
composeSkin(s.dest);
const meta = JSON.parse(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'));
assert.equal(meta.uuid, 'fw-btn-uuid', 'uuid 必须沿用框架的');
assert.equal(meta.ver, '1.0.0', 'override meta 的其余字段保留');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧 .meta 没有 uuid 时显式抛错(不得写出没有 uuid 的 .meta)', () => {
const s = scaffold();
try {
// 框架 bg.png 本身没有 .meta;给它补一份没有 uuid 字段的 .meta,
// 模拟 fwUuid === undefined 的场景(JSON.stringify 会静默丢掉 undefined 的键)
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"ver":"1.0.0"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
// 框架侧补一份没有 uuid 的 .meta(模拟框架 meta 本身缺 uuid 的坏数据)
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'bg.png.meta'), '{"ver":"0.9.0"}');
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-missing-uuid');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /uuid/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧没有 .meta 时显式抛错(不得静默丢弃 override 的 meta)', () => {
const s = scaffold();
try {
// bg.png 在框架侧没有 .meta,但 override 给它带了一份 —— 之前的实现会静默跳过整段合并
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"uuid":"game-own-uuid"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-orphan');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /\.meta/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => {
const s = scaffold();
try {
// 同时放入一个合法的 override 和一个孤儿 override
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN); // 合法:框架里有此文件
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', DEFAULT_BG); // 孤儿:框架里无此文件
materialize(s.gameDir, s.frameworkSrc, s.dest);
let validationErr;
assert.throws(() => {
try {
composeSkin(s.dest);
} catch (err) {
validationErr = err;
throw err;
}
}, SkinValidationError);
// 验证错误中确实有 orphan 错误指向孤儿文件
assert.ok(validationErr.errors.some(e => e.code === 'orphan' && e.path === 'atlas-hall/typo.png'), 'orphan 错误必须被检测');
// 验证即使合法的 btn.png 也没有被写入(校验失败时不得部分写入)
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '校验失败时不得部分写入:btn.png 应仍为框架默认值');
} finally { cleanup(s.root); }
});
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { makeTempMonorepo, makeSeed, cleanup } from './helpers.mjs';
import { newGame } from '../new-game.mjs';
import { isLink } from '../lib/junction.mjs';
test('newGame 生成工程:改身份 + 挂 framework junction + 铺 game 骨架', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const result = newGame('doudizhu', { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework });
const dest = join(mono.games, 'doudizhu');
assert.equal(result.projectDir, dest);
const pkg = JSON.parse(readFileSync(join(dest, 'package.json'), 'utf8'));
assert.equal(pkg.name, 'doudizhu');
assert.notEqual(pkg.uuid, 'seed-uuid');
const link = join(dest, 'assets', 'framework');
assert.ok(isLink(link));
assert.equal(readFileSync(join(link, 'README.md'), 'utf8'), '# framework src\n');
assert.ok(existsSync(join(dest, 'assets', 'game', 'GameModule.ts')));
} finally {
cleanup(mono.root);
}
});
test('newGame 拒绝重名工程', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const opts = { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework };
newGame('doudizhu', opts);
assert.throws(() => newGame('doudizhu', opts), /已存在/);
} finally {
cleanup(mono.root);
}
});
test('newGame 校验名称非空且合法', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const opts = { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework };
assert.throws(() => newGame('', opts), /名称/);
assert.throws(() => newGame('Bad Name!', opts), /名称/);
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join, sep } from 'node:path';
import { writeFileSync, mkdirSync } from 'node:fs';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { readCreatorVersion, listGameProjects, BUILD_WORKSPACE, GAMES_DIR } from '../lib/paths.mjs';
test('readCreatorVersion 读取 package.json 的 creator.version', () => {
const mono = makeTempMonorepo();
try {
assert.equal(readCreatorVersion(mono.host), '3.8.8');
} finally {
cleanup(mono.root);
}
});
test('readCreatorVersion 缺字段返回 null', () => {
const mono = makeTempMonorepo();
try {
const dir = makeGameProject(mono.games, 'nover');
// 覆写成无 creator 字段
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'nover' }));
assert.equal(readCreatorVersion(dir), null);
} finally {
cleanup(mono.root);
}
});
test('listGameProjects 只列出含 package.json 的子目录', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'doudizhu');
makeGameProject(mono.games, 'niuniu');
mkdirSync(join(mono.games, 'not-a-project')); // 无 package.json,应被忽略
const found = listGameProjects(mono.games).map((p) => p.split(/[\\/]/).pop()).sort();
assert.deepEqual(found, ['doudizhu', 'niuniu']);
} finally {
cleanup(mono.root);
}
});
test('listGameProjects 对不存在的目录返回空数组', () => {
assert.deepEqual(listGameProjects('/no/such/dir'), []);
});
test('BUILD_WORKSPACE 不在 GAMES_DIR 之下(红线③:临时工程不得置于 games/ 下)', () => {
assert.ok(
BUILD_WORKSPACE !== GAMES_DIR && !BUILD_WORKSPACE.startsWith(GAMES_DIR + sep),
`BUILD_WORKSPACE(${BUILD_WORKSPACE}) 不应落在 GAMES_DIR(${GAMES_DIR}) 之下`,
);
});
@@ -0,0 +1,69 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { makeTempMonorepo, makeSeed, cleanup } from './helpers.mjs';
import { copySeed, setProjectIdentity, scaffoldGameAssets } from '../lib/scaffold.mjs';
test('copySeed 复制种子并清理缓存目录', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
mkdirSync(join(seed, 'library'), { recursive: true }); // 模拟缓存
writeFileSync(join(seed, 'library', 'x'), '1');
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
assert.ok(existsSync(join(dest, 'package.json')));
assert.ok(existsSync(join(dest, 'settings', 'v2.json')));
assert.ok(!existsSync(join(dest, 'library')), 'library 应被清理');
} finally {
cleanup(mono.root);
}
});
test('copySeed 拒绝覆盖已存在目标', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
assert.throws(() => copySeed(seed, dest), /已存在/);
} finally {
cleanup(mono.root);
}
});
test('setProjectIdentity 改 name 并生成新 uuid', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
const pkg = setProjectIdentity(dest, 'doudizhu');
assert.equal(pkg.name, 'doudizhu');
assert.notEqual(pkg.uuid, 'seed-uuid');
assert.match(pkg.uuid, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
const onDisk = JSON.parse(readFileSync(join(dest, 'package.json'), 'utf8'));
assert.equal(onDisk.name, 'doudizhu');
assert.equal(onDisk.creator.version, '3.8.8'); // 引擎版本保留
} finally {
cleanup(mono.root);
}
});
test('scaffoldGameAssets 铺出 game 骨架(GameModule/theme/override)', () => {
const mono = makeTempMonorepo();
try {
const seed = makeSeed(mono.root);
const dest = join(mono.games, 'demo');
copySeed(seed, dest);
scaffoldGameAssets(dest, 'doudizhu');
const gm = readFileSync(join(dest, 'assets', 'game', 'GameModule.ts'), 'utf8');
assert.match(gm, /doudizhu/);
assert.match(gm, /docs\/protocol/); // 提醒协议 SSOT
assert.ok(existsSync(join(dest, 'assets', 'game', 'theme.ts')));
assert.ok(existsSync(join(dest, 'assets', 'game', 'override', '.gitkeep')));
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { setupLinks } from '../setup-links.mjs';
import { isLink } from '../lib/junction.mjs';
test('setupLinks 为每个子游戏工程建立 assets/framework junction', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'doudizhu');
makeGameProject(mono.games, 'niuniu');
const results = setupLinks(mono.games, mono.framework);
assert.equal(results.length, 2);
for (const r of results) {
assert.equal(r.status, 'created');
assert.ok(isLink(r.link));
assert.equal(readFileSync(join(r.link, 'README.md'), 'utf8'), '# framework src\n');
}
} finally {
cleanup(mono.root);
}
});
test('setupLinks 二次运行幂等(已存在则 exists)', () => {
const mono = makeTempMonorepo();
try {
makeGameProject(mono.games, 'doudizhu');
setupLinks(mono.games, mono.framework);
const second = setupLinks(mono.games, mono.framework);
assert.equal(second[0].status, 'exists');
} finally {
cleanup(mono.root);
}
});
@@ -0,0 +1,244 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { deflateSync } from 'node:zlib';
import { listAssets, listCoverableAssets, readPngSize, validateOverrides } from '../lib/skin.mjs';
// ---- 测试辅助:零依赖生成指定尺寸的 PNG ----
function crc32(buf) {
let c = ~0;
for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }
return ~c >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, c]);
}
function makePng(w, h) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.alloc(h * (1 + w * 3));
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
]);
}
function writeFile(dir, rel, content) {
const p = join(dir, ...rel.split('/'));
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, content);
return p;
}
function png(dir, rel, w = 64, h = 64) { return writeFile(dir, rel, makePng(w, h)); }
function makeDirs() {
const root = mkdtempSync(join(tmpdir(), 'youle-skin-'));
const fw = join(root, 'ui'); const ov = join(root, 'override');
mkdirSync(fw, { recursive: true }); mkdirSync(ov, { recursive: true });
return { root, fw, ov };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
// ---- listAssets ----
test('listAssets 返回正斜杠相对路径,排除 .meta 与 .gitkeep', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png');
writeFile(d.fw, 'atlas-hall/btn.png.meta', '{}');
writeFile(d.fw, 'audio/.gitkeep', '');
writeFile(d.fw, 'audio/click.wav', 'x');
assert.deepEqual(listAssets(d.fw).sort(), ['atlas-hall/btn.png', 'audio/click.wav']);
} finally { cleanup(d.root); }
});
test('listAssets 对不存在的目录返回空数组', () => {
assert.deepEqual(listAssets('/no/such/dir'), []);
});
// ---- listCoverableAssets:Important 3——可覆盖范围须限定为 spec §2 规则 3 的 8 个目录 ----
test('listCoverableAssets 排除 theme/ 与 README.md,只保留 8 个白名单目录下的资源', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png');
writeFile(d.fw, 'README.md', '# framework ui');
writeFile(d.fw, 'theme/default-theme.ts', 'export const DEFAULT_THEME = {};');
writeFile(d.fw, 'theme/types.ts', 'export interface ThemeConfig {}');
const covered = listCoverableAssets(d.fw).sort();
assert.deepEqual(covered, ['atlas-hall/btn.png']);
assert.ok(!covered.some((f) => f.startsWith('theme/')), 'theme/ 下的框架代码不属于可覆盖资源');
assert.ok(!covered.includes('README.md'), 'README.md 不是可覆盖资源');
} finally { cleanup(d.root); }
});
test('validateOverrides:override 放一个 .ts 到 theme/ 会被判为 orphan(不在可覆盖范围内)', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png');
writeFile(d.fw, 'theme/resolve-theme.ts', 'export function resolveTheme() {}');
// 子游戏企图覆盖框架代码
writeFile(d.ov, 'theme/resolve-theme.ts', 'export function resolveTheme() { /* hijacked */ }');
const r = validateOverrides(d.fw, d.ov);
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'orphan');
assert.equal(r.errors[0].path, 'theme/resolve-theme.ts');
assert.equal(r.matched.length, 0, 'theme 覆盖不应被当作合法命中');
} finally { cleanup(d.root); }
});
// ---- readPngSize ----
test('readPngSize 读出宽高;非 PNG 返回 null', () => {
const d = makeDirs();
try {
const p = png(d.fw, 'a.png', 128, 64);
assert.deepEqual(readPngSize(p), { width: 128, height: 64 });
const w = writeFile(d.fw, 'b.wav', 'not a png');
assert.equal(readPngSize(w), null);
} finally { cleanup(d.root); }
});
// ---- validateOverrides:正常命中 ----
test('同名同尺寸图片:命中,无错误', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png', 64, 64);
png(d.ov, 'atlas-hall/btn.png', 64, 64);
const r = validateOverrides(d.fw, d.ov);
assert.deepEqual(r.errors, []);
assert.deepEqual(r.matched, ['atlas-hall/btn.png']);
} finally { cleanup(d.root); }
});
test('框架有、子游戏未覆盖:不是错误', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/a.png');
png(d.fw, 'atlas-hall/b.png');
png(d.ov, 'atlas-hall/a.png');
const r = validateOverrides(d.fw, d.ov);
assert.deepEqual(r.errors, []);
assert.deepEqual(r.matched, ['atlas-hall/a.png']);
} finally { cleanup(d.root); }
});
// ---- validateOverrides:孤儿 ----
test('override 在框架里找不到对应路径 → orphan', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png');
png(d.ov, 'atlas-hall/typo.png');
const r = validateOverrides(d.fw, d.ov);
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'orphan');
assert.equal(r.errors[0].path, 'atlas-hall/typo.png');
} finally { cleanup(d.root); }
});
test('同名不同扩展名 → orphan,且提示框架里的实际扩展名', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png');
writeFile(d.ov, 'atlas-hall/btn.jpg', 'x');
const r = validateOverrides(d.fw, d.ov);
assert.equal(r.errors[0].code, 'orphan');
assert.match(r.errors[0].message, /btn\.png/);
} finally { cleanup(d.root); }
});
// ---- validateOverrides:尺寸 ----
test('图片尺寸不同且未带 meta → size-mismatch', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png', 64, 64);
png(d.ov, 'atlas-hall/btn.png', 128, 128);
const r = validateOverrides(d.fw, d.ov);
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'size-mismatch');
assert.match(r.errors[0].message, /64x64/);
assert.match(r.errors[0].message, /128x128/);
} finally { cleanup(d.root); }
});
test('图片尺寸不同但自带 meta(例外通道)→ 通过', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png', 64, 64);
png(d.ov, 'atlas-hall/btn.png', 128, 128);
writeFile(d.ov, 'atlas-hall/btn.png.meta', '{"ver":"1.0.0"}');
const r = validateOverrides(d.fw, d.ov);
assert.deepEqual(r.errors, []);
assert.deepEqual(r.matched, ['atlas-hall/btn.png']);
} finally { cleanup(d.root); }
});
test('非图片资源(音效)不做尺寸校验', () => {
const d = makeDirs();
try {
// 用真实的、有效的、尺寸不同的 PNG 字节,但扩展名是 .wav
// 如果 isImage() 门控被删掉,因为尺寸不同会报 size-mismatch,用例失败
writeFile(d.fw, 'audio/click.wav', makePng(64, 64));
writeFile(d.ov, 'audio/click.wav', makePng(256, 256));
const r = validateOverrides(d.fw, d.ov);
assert.deepEqual(r.errors, []);
} finally { cleanup(d.root); }
});
// ---- validateOverrides:尺寸不可读 ----
test('图片尺寸无法读取(非 PNG)→ size-unreadable', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png', 64, 64);
writeFile(d.ov, 'atlas-hall/btn.png', 'this is not a png but it is long enough to pass the 24-byte length check');
const r = validateOverrides(d.fw, d.ov);
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'size-unreadable');
assert.match(r.errors[0].message, /无法读取尺寸/);
assert.match(r.errors[0].message, /PNG/);
assert.equal(r.matched.length, 0);
} finally { cleanup(d.root); }
});
test('readPngSize 长度足够但签名不对 → 返回 null', () => {
const d = makeDirs();
try {
// 构造一个 24 字节以上、但 PNG 签名不对的数据
const buf = Buffer.alloc(30, 0x00);
buf.write('NOTPNG', 1, 'ascii');
const p = writeFile(d.fw, 'test.png', buf);
assert.equal(readPngSize(p), null);
} finally { cleanup(d.root); }
});
// ---- validateOverrides:Spine ----
test('Spine 整套覆盖 → 通过,且贴图豁免尺寸校验', () => {
const d = makeDirs();
try {
writeFile(d.fw, 'spine/hero/hero.json', '{}');
writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas');
png(d.fw, 'spine/hero/hero.png', 64, 64);
writeFile(d.ov, 'spine/hero/hero.json', '{"v":2}');
writeFile(d.ov, 'spine/hero/hero.atlas', 'atlas2');
png(d.ov, 'spine/hero/hero.png', 256, 256); // 尺寸不同也应通过
const r = validateOverrides(d.fw, d.ov);
assert.deepEqual(r.errors, []);
assert.equal(r.matched.length, 3);
} finally { cleanup(d.root); }
});
test('Spine 缺件 → spine-incomplete,并列出缺少的文件', () => {
const d = makeDirs();
try {
writeFile(d.fw, 'spine/hero/hero.json', '{}');
writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas');
png(d.fw, 'spine/hero/hero.png', 64, 64);
png(d.ov, 'spine/hero/hero.png', 64, 64); // 只换贴图
const r = validateOverrides(d.fw, d.ov);
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'spine-incomplete');
assert.match(r.errors[0].message, /hero\.json/);
assert.match(r.errors[0].message, /hero\.atlas/);
} finally { cleanup(d.root); }
});
@@ -0,0 +1,82 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { layerBucket, imageBuckets } from '../ui-migration/buckets.mjs';
import { loadSource } from '../ui-migration/gameabc-source.mjs';
import { ROOT } from '../lib/paths.mjs';
const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output');
test('layerBucket 按名单判 login', () => {
assert.equal(layerBucket(1, 'Logo_Layer'), 'login');
assert.equal(layerBucket(2, 'Login_Layer'), 'login');
assert.equal(layerBucket(18, 'Auth_layer'), 'login');
assert.equal(layerBucket(16, 'Bind_Layer'), 'login');
assert.equal(layerBucket(29, 'phoneInfo'), 'login');
});
test('layerBucket 按 ID 段位判 hall / room / common', () => {
assert.equal(layerBucket(4, 'MainMenu_Layer'), 'hall');
assert.equal(layerBucket(26, 'Task_Layer'), 'hall');
assert.equal(layerBucket(50, 'MainScene_Layer'), 'room');
assert.equal(layerBucket(202, 'Player_Head_Score_Layer'), 'room');
assert.equal(layerBucket(418, 'ChatPannel_Layer'), 'room');
assert.equal(layerBucket(602, 'inputPannel'), 'common');
assert.equal(layerBucket(614, 'Loading_Layer'), 'common');
});
test('layerBucket 对命名笼统的层返回 unassigned(不猜)', () => {
for (const [id, name] of [[6, 'Layer6'], [13, 'Layer13'], [403, 'Layer403'],
[411, 'Layer411'], [601, 'Layer601'], [603, 'Layer603'], [618, 'Layer618']]) {
assert.equal(layerBucket(id, name), 'unassigned', `${name} 应为 unassigned`);
}
});
test('layerBucket 的 unassigned 优先于段位规则', () => {
// Layer601 若只按段位会判成 common,但名字笼统 → 必须 unassigned
assert.equal(layerBucket(601, 'Layer601'), 'unassigned');
});
test('imageBuckets 单一 bucket 的图归该 bucket', () => {
const source = {
objects: new Map([[1, { ObjectID: 1, ImageFileID: 7, BelongLayerID: 2 }]]),
layers: [{ layerId: 2, name: 'Login_Layer', objectIds: [1] }],
images: new Map([[7, { id: 7, bmp: 'a.png', frame_all: 1 }]]),
groups: [],
};
assert.equal(imageBuckets(source).get(7), 'login');
});
test('imageBuckets 跨 bucket 的图归 common', () => {
const source = {
objects: new Map([
[1, { ObjectID: 1, ImageFileID: 7, BelongLayerID: 2 }],
[2, { ObjectID: 2, ImageFileID: 7, BelongLayerID: 4 }],
]),
layers: [
{ layerId: 2, name: 'Login_Layer', objectIds: [1] },
{ layerId: 4, name: 'MainMenu_Layer', objectIds: [2] },
],
images: new Map([[7, { id: 7, bmp: 'a.png', frame_all: 1 }]]),
groups: [],
};
assert.equal(imageBuckets(source).get(7), 'common');
});
test('imageBuckets 无对象引用的图归 unassigned', () => {
const source = {
objects: new Map(),
layers: [],
images: new Map([[7, { id: 7, bmp: 'orphan.png', frame_all: 1 }]]),
groups: [],
};
assert.equal(imageBuckets(source).get(7), 'unassigned');
});
test('imageBuckets 在真实数据上给每张图一个归属(回归护栏)', () => {
const source = loadSource(REAL_SOURCE);
const m = imageBuckets(source);
assert.equal(m.size, source.images.size, '每张图都要有归属');
const valid = new Set(['login', 'hall', 'room', 'common', 'unassigned']);
for (const [id, b] of m) assert.ok(valid.has(b), `图 ${id} 的 bucket 非法: ${b}`);
});
@@ -0,0 +1,216 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { convertLayer, convertAll } from '../ui-migration/convert.mjs';
import { loadSource } from '../ui-migration/gameabc-source.mjs';
import { imageBuckets } from '../ui-migration/buckets.mjs';
import { ROOT } from '../lib/paths.mjs';
const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output');
function tinySource() {
const objects = new Map([
[1, { ObjectID: 1, ObjectType: 2, ObjectName: '背景', Left: 0, Top: 0, Width: 1280, Height: 720,
BelongLayerID: 2, IndexOfLayer: 1, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }],
[2, { ObjectID: 2, ObjectType: 2, ObjectName: '按钮', Left: 320, Top: 200, Width: 150, Height: 70,
BelongLayerID: 2, IndexOfLayer: 2, ImageFileID: 2, FrameStyle: 0, FrameIndex: 5,
L9: 12, T9: 8, R9: 12, B9: 8, GroupID: 5, VoiceFileID: 0, TimerInterval: 0,
events: ['mousedown'] }],
[3, { ObjectID: 3, ObjectType: 4, ObjectName: '昵称文字', Left: 100, Top: 50, Width: 76, Height: 25,
BelongLayerID: 2, IndexOfLayer: 3, ImageFileID: 0, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }],
]);
return {
objects,
layers: [{ layerId: 2, name: 'Login_Layer', objectIds: [1, 2, 3] }],
groups: [{ groupId: 5, objectIds: [1, 2, 3] }],
images: new Map([
[1, { id: 1, bmp: '00001.png', w: 1, h: 1, frame_all: 1, w1: 1280, h1: 720, w_all: 1280, h_all: 720 }],
[2, { id: 2, bmp: '00014.png', w: 3, h: 4, frame_all: 12, w1: 150, h1: 70, w_all: 450, h_all: 280 }],
]),
};
}
test('convertLayer 产出层元信息与 bucket', () => {
const s = tinySource();
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.equal(doc.layerId, 2);
assert.equal(doc.name, 'Login_Layer');
assert.equal(doc.bucket, 'login');
assert.equal(doc.nodes.length, 3);
});
test('convertLayer 识别背景:层内首个 + 满屏 → stretch', () => {
const s = tinySource();
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.equal(doc.backgroundNodeId, 1);
const bg = doc.nodes.find((n) => n.id === 1);
assert.equal(bg.flags.stretch, true);
assert.equal(bg.widget.horizontal, 'stretch');
});
test('convertLayer 背景判定只认层内首个,但拉伸适用于所有满屏对象(spec §2.4)', () => {
const s = tinySource();
// 追加一个非首位、同样满屏的对象(如"创建房间遮罩"那类)——
// backgroundNodeId 仍应只认层内首个,但 flags.stretch/widget 对所有满屏对象一视同仁
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '第二张满屏遮罩', Left: 0, Top: 0, Width: 1280, Height: 720,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.equal(doc.backgroundNodeId, 1, '背景仍应是层内首个对象');
const second = doc.nodes.find((n) => n.id === 4);
assert.equal(second.flags.stretch, true, '满屏对象都应拉伸,不论是否层内首个');
assert.equal(second.widget.horizontal, 'stretch');
});
test('convertLayer 非背景对象用三区锚点与转换坐标', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.deepEqual(btn.position, { x: -245, y: 125 });
assert.deepEqual(btn.widget, { horizontal: 'center', horizontalCenter: -245 });
});
test('convertLayer 九宫格非零 → sliced 并带 slice 值', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.equal(btn.sprite.type, 'sliced');
assert.deepEqual(btn.sprite.slice, { left: 12, right: 12, top: 8, bottom: 8 });
});
test('convertLayer 多帧图节点带 frame 与 frameSet', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.equal(btn.sprite.frame, 'atlas-login/00014_05.png');
assert.deepEqual(btn.sprite.frameSet, { prefix: 'atlas-login/00014', count: 12, pad: 2 });
});
test('convertLayer Type4 转 label 且不带 sprite/样式字段', () => {
const s = tinySource();
const txt = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 3);
assert.equal(txt.kind, 'label');
assert.equal(txt.sprite, undefined);
assert.deepEqual(txt.size, { width: 76, height: 25 });
});
test('convertLayer 保留 legacy 承重字段', () => {
const s = tinySource();
const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2);
assert.equal(btn.legacy.objectId, 2);
assert.equal(btn.legacy.imageFileId, 2);
assert.equal(btn.legacy.frameIndex, 5);
assert.equal(btn.legacy.groupId, 5);
assert.equal(btn.legacy.left, 320);
assert.equal(btn.legacy.top, 200);
assert.deepEqual(btn.legacy.events, ['mousedown']);
});
test('convertLayer 对象引用不存在的图时显式抛错(不兜底),消息可定位对象与图', () => {
const s = tinySource();
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '缺图对象', Left: 10, Top: 10, Width: 50, Height: 50,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 999, FrameStyle: 0, FrameIndex: 0,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
assert.throws(
() => convertLayer(s, s.layers[0], imageBuckets(s)),
(err) => {
assert.ok(err.message.includes('对象 4'), `错误信息应包含对象 ID: ${err.message}`);
assert.ok(err.message.includes('999'), `错误信息应包含图 ID: ${err.message}`);
return true;
},
);
});
test('convertLayer 帧号越界:回退帧 1、标记 flags.frameIndexOutOfRange、收集 warning', () => {
const s = tinySource();
// 追加一张 frame_all=2 的图,对象却写着 FrameIndex=3——复现真实数据里 9/40 那类越界
s.images.set(3, { id: 3, bmp: '00099.png', w: 2, h: 1, frame_all: 2, w1: 50, h1: 50, w_all: 100, h_all: 50 });
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '越界帧对象', Left: 500, Top: 300, Width: 50, Height: 50,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 3,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
const n = doc.nodes.find((node) => node.id === 4);
assert.equal(n.flags.frameIndexOutOfRange, true, '越界应打标记');
assert.equal(n.sprite.frame, 'atlas-login/00099_1.png', '越界应回退为帧 1,不得抛错也不得跳过节点');
assert.equal(doc.warnings.length, 1);
const w = doc.warnings[0];
assert.equal(w.objectId, 4);
assert.equal(w.layerId, 2);
assert.equal(w.imageFileId, 3);
assert.equal(w.frameIndex, 3);
assert.equal(w.frameCount, 2);
assert.equal(typeof w.reason, 'string');
assert.ok(w.reason.length > 0, 'reason 必须可读,能定位到具体对象/图');
});
test('convertLayer 单帧图上的残留 FrameIndex 不产生 warning(frameCount<=1 本就与 FrameIndex 无关)', () => {
const s = tinySource();
// 单帧图(frame_all=1)却写着 FrameIndex=8——真实数据里 30/40 这一类,属无害残留
s.images.set(3, { id: 3, bmp: '00088.png', w: 1, h: 1, frame_all: 1, w1: 50, h1: 50, w_all: 50, h_all: 50 });
s.objects.set(4, {
ObjectID: 4, ObjectType: 2, ObjectName: '单帧残留对象', Left: 500, Top: 300, Width: 50, Height: 50,
BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 8,
L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [],
});
s.layers[0].objectIds.push(4);
s.groups[0].objectIds.push(4);
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
const n = doc.nodes.find((node) => node.id === 4);
assert.equal(n.flags.frameIndexOutOfRange, false);
assert.equal(n.sprite.frame, 'atlas-login/00088.png');
assert.equal(doc.warnings.length, 0);
});
test('convertLayer 输出 groups 供父节点分组', () => {
const s = tinySource();
const doc = convertLayer(s, s.layers[0], imageBuckets(s));
assert.deepEqual(doc.groups, [{ groupId: 5, nodeIds: [1, 2, 3] }]);
});
test('convertAll 的 manifest 含 ObjectID 全局索引(C 的承重依赖)', () => {
const s = tinySource();
const { manifest, warnings } = convertAll(s);
assert.equal(manifest.designWidth, 1280);
assert.deepEqual(manifest.objectIndex['2'], { layerId: 2, nodeId: 2 });
assert.equal(manifest.layers.length, 1);
assert.equal(manifest.layers[0].nodeCount, 3);
assert.deepEqual(warnings, [], 'tinySource 本身没有越界帧,不应产生 warning');
});
test('convertAll 在真实数据上覆盖全部对象与界面(回归护栏)', () => {
const source = loadSource(REAL_SOURCE);
const { layers, manifest, warnings } = convertAll(source);
assert.equal(layers.length, 55, '界面数');
const total = layers.reduce((s, l) => s + l.nodes.length, 0);
assert.equal(total, 991, '节点总数必须等于对象总数——不得丢对象');
assert.equal(Object.keys(manifest.objectIndex).length, 991, 'ObjectID 索引必须完整');
assert.equal(warnings.length, 10, '已知的帧号越界对象必须恰为 10 个——数量漂移应被人工关注');
// 内容级校验(不只是条数):objectIndex 每条的 layerId 必须等于该对象真实所属的层
// (source 里的 BelongLayerID)。全量遍历能杀掉"全部条目被写成同一个 layerId"
// 这类变异——55 个不同层里任何一层的对象被错误映射,都会在此处失败。
for (const [idStr, entry] of Object.entries(manifest.objectIndex)) {
const obj = source.objects.get(Number(idStr));
assert.equal(
entry.layerId,
obj.BelongLayerID,
`ObjectID ${idStr} 的 objectIndex.layerId(${entry.layerId}) 应等于其 BelongLayerID(${obj.BelongLayerID})`,
);
}
});
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DESIGN_WIDTH, DESIGN_HEIGHT, CANVAS_WIDTH, CANVAS_HEIGHT,
toCocosPosition, inferWidget, stretchWidget, isFullScreen, isOffscreen,
} from '../ui-migration/coords.mjs';
test('内容区基准保持 1280x720(数据源事实,不随设计分辨率改变)', () => {
assert.equal(DESIGN_WIDTH, 1280);
assert.equal(DESIGN_HEIGHT, 720);
});
test('设计分辨率画布为 1600x720(20:9 主流移动端比例)', () => {
assert.equal(CANVAS_WIDTH, 1600);
assert.equal(CANVAS_HEIGHT, 720);
});
test('内容区基准与设计分辨率画布是两个独立常量', () => {
// 坐标/三区/满屏/越界用 DESIGN_WIDTH(1280);落地 Layer/Group 尺寸用 CANVAS_WIDTH(1600)。
assert.notEqual(DESIGN_WIDTH, CANVAS_WIDTH);
});
test('toCocosPosition 用 spec 的验算样例', () => {
// spec §1.2: Left=320 Top=200 150x70 → x=-245 y=125
assert.deepEqual(toCocosPosition(320, 200, 150, 70), { x: -245, y: 125 });
});
test('toCocosPosition 左上角对象落在画布左上', () => {
// 0,0 的 100x50 → 中心在 (50,25) → x=50-640=-590, y=360-25=335
assert.deepEqual(toCocosPosition(0, 0, 100, 50), { x: -590, y: 335 });
});
test('toCocosPosition 满屏对象落在原点', () => {
assert.deepEqual(toCocosPosition(0, 0, 1280, 720), { x: 0, y: 0 });
});
test('inferWidget 中心点在左区 → 锚左', () => {
// cx = 10+100/2 = 60 < 320
assert.deepEqual(inferWidget(10, 100), { horizontal: 'left', left: 10 });
});
test('inferWidget 中心点在右区 → 锚右', () => {
// cx = 1100+100/2 = 1150 > 960;right = 1280 - 1200 = 80
assert.deepEqual(inferWidget(1100, 100), { horizontal: 'right', right: 80 });
});
test('inferWidget 中心点在中区 → 水平居中', () => {
// cx = 320+150/2 = 395 ∈ [320,960];horizontalCenter = 395-640 = -245
assert.deepEqual(inferWidget(320, 150), { horizontal: 'center', horizontalCenter: -245 });
});
test('inferWidget 分界值 320 与 960 归入中区(非严格小于/大于才算边区)', () => {
// cx 恰为 320:不满足 < 320 → 中区
assert.equal(inferWidget(220, 200).horizontal, 'center');
// cx 恰为 960:不满足 > 960 → 中区
assert.equal(inferWidget(860, 200).horizontal, 'center');
});
test('inferWidget 屏外负坐标自然落入左区', () => {
// cx = -286+100/2 = -236 < 320
assert.equal(inferWidget(-286, 100).horizontal, 'left');
});
test('stretchWidget 四边全 0', () => {
assert.deepEqual(stretchWidget(), {
horizontal: 'stretch', vertical: 'stretch', left: 0, right: 0, top: 0, bottom: 0,
});
});
test('isFullScreen 只认 >= 设计分辨率', () => {
assert.equal(isFullScreen(1280, 720), true);
assert.equal(isFullScreen(1300, 800), true);
assert.equal(isFullScreen(1279, 720), false);
assert.equal(isFullScreen(1280, 719), false);
});
test('isOffscreen 识别越界对象', () => {
assert.equal(isOffscreen(0, 0, 1280, 720), false, '恰好满屏不算越界');
assert.equal(isOffscreen(-286, 100, 50, 50), true, '左越界');
assert.equal(isOffscreen(100, -318, 50, 50), true, '上越界');
assert.equal(isOffscreen(1260, 100, 50, 50), true, '右越界');
assert.equal(isOffscreen(100, 700, 50, 50), true, '下越界');
});
@@ -0,0 +1,73 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PNG } from 'pngjs';
import { splitSheet, reassemble, pixelsEqual, verifySheet } from '../ui-migration/frame-io.mjs';
/** 造一张 w×h 网格的测试图,每格填不同颜色,便于验证切分位置 */
function makeSheet(cols, rows, cellW, cellH) {
const png = new PNG({ width: cols * cellW, height: rows * cellH });
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c; // 行优先编号
for (let y = 0; y < cellH; y++) {
for (let x = 0; x < cellW; x++) {
const o = ((r * cellH + y) * png.width + (c * cellW + x)) << 2;
png.data[o] = (idx * 20) & 0xff; // R 编码帧号,便于断言
png.data[o + 1] = 0x40;
png.data[o + 2] = 0x80;
png.data[o + 3] = 0xff;
}
}
}
}
return PNG.sync.write(png);
}
const IMG = { id: 1, w_all: 60, h_all: 40, w: 3, h: 2, frame_all: 6, bmp: 'test.png', w1: 20, h1: 20 };
test('splitSheet 切出 frame_all 张,尺寸为单帧尺寸', () => {
const frames = splitSheet(makeSheet(3, 2, 20, 20), IMG);
assert.equal(frames.length, 6);
const first = PNG.sync.read(frames[0]);
assert.equal(first.width, 20);
assert.equal(first.height, 20);
});
test('splitSheet 按行优先取帧:下标 0..5 的 R 通道依次为 0,20,40,60,80,100', () => {
const frames = splitSheet(makeSheet(3, 2, 20, 20), IMG);
const reds = frames.map((b) => PNG.sync.read(b).data[0]);
assert.deepEqual(reds, [0, 20, 40, 60, 80, 100],
'若切成列优先,顺序会变为 0,60,20,80,40,100');
});
test('reassemble(splitSheet(x)) 的像素与 x 完全一致(完全验证的核心不变量)', () => {
const sheet = makeSheet(3, 2, 20, 20);
const back = reassemble(splitSheet(sheet, IMG), IMG);
assert.equal(pixelsEqual(sheet, back), true);
});
test('pixelsEqual 对像素不同的图返回 false', () => {
const a = makeSheet(3, 2, 20, 20);
const bPng = PNG.sync.read(a);
bPng.data[0] = (bPng.data[0] + 1) & 0xff; // 改一个字节
assert.equal(pixelsEqual(a, PNG.sync.write(bPng)), false);
});
test('pixelsEqual 忽略编码差异:同像素不同压缩等级仍为 true', () => {
const src = PNG.sync.read(makeSheet(3, 2, 20, 20));
const enc0 = PNG.sync.write(src, { deflateLevel: 0 });
const enc9 = PNG.sync.write(src, { deflateLevel: 9 });
assert.notEqual(Buffer.compare(enc0, enc9), 0, '前提:两种编码的文件字节确实不同');
assert.equal(pixelsEqual(enc0, enc9), true, '但像素数据必须判为相同');
});
test('verifySheet 对正常图返回 ok', () => {
assert.deepEqual(verifySheet(makeSheet(3, 2, 20, 20), IMG), { ok: true });
});
test('verifySheet 对尺寸与网格声明不符的图返回失败原因', () => {
const wrong = makeSheet(2, 2, 20, 20); // 实际 40x40,但 IMG 声明 60x40
const r = verifySheet(wrong, IMG);
assert.equal(r.ok, false);
assert.match(r.reason, /尺寸/);
});
@@ -0,0 +1,95 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frameRect, padWidth, frameFileName, frameNames } from '../ui-migration/frames.mjs';
// 实测样例:00014.png 450x280,网格 3x4,单帧 150x70
const IMG_00014 = { id: 14, w_all: 450, h_all: 280, w: 3, h: 4, frame_all: 12, bmp: '00014.png', w1: 150, h1: 70 };
// 13 列 1 行
const IMG_00019 = { id: 19, w_all: 221, h_all: 28, w: 13, h: 1, frame_all: 13, bmp: '00019.png', w1: 17, h1: 28 };
const IMG_SINGLE = { id: 1, w_all: 1280, h_all: 720, w: 1, h: 1, frame_all: 1, bmp: '00001.png', w1: 1280, h1: 720 };
test('frameRect 帧 1 是左上角(1 基)', () => {
assert.deepEqual(frameRect(IMG_00014, 1), { x: 0, y: 0, width: 150, height: 70 });
});
test('frameRect 行优先:帧 4 换到第二行首列', () => {
// idx=3 → col = 3%3 = 0, row = 1
assert.deepEqual(frameRect(IMG_00014, 4), { x: 0, y: 70, width: 150, height: 70 });
});
test('frameRect 实证样例:「通知」FrameIndex=10 → 第 3 行第 0 列', () => {
// 已由读图验证:00014.png 第 3 行第 0 列即「通知」
assert.deepEqual(frameRect(IMG_00014, 10), { x: 0, y: 210, width: 150, height: 70 });
});
test('frameRect 实证样例:「设置」FrameIndex=11 → 第 3 行第 1 列', () => {
assert.deepEqual(frameRect(IMG_00014, 11), { x: 150, y: 210, width: 150, height: 70 });
});
test('frameRect 实证样例:「战绩」FrameIndex=4 → 第 1 行第 0 列', () => {
assert.deepEqual(frameRect(IMG_00014, 4), { x: 0, y: 70, width: 150, height: 70 });
});
test('frameRect 单行网格逐列推进', () => {
assert.deepEqual(frameRect(IMG_00019, 1), { x: 0, y: 0, width: 17, height: 28 });
assert.deepEqual(frameRect(IMG_00019, 13), { x: 204, y: 0, width: 17, height: 28 });
});
test('frameRect FrameIndex=0 按帧 1 处理', () => {
assert.deepEqual(frameRect(IMG_00014, 0), frameRect(IMG_00014, 1));
});
test('frameRect 越界抛 RangeError 且带图名', () => {
assert.throws(() => frameRect(IMG_00014, 13), RangeError);
assert.throws(() => frameRect(IMG_00014, 13), /00014\.png/);
});
test('frameRect undefined 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameRect(IMG_00014, undefined), RangeError);
assert.throws(() => frameRect(IMG_00014, undefined), /00014\.png/);
});
test('frameRect NaN 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameRect(IMG_00014, NaN), RangeError);
assert.throws(() => frameRect(IMG_00014, NaN), /00014\.png/);
});
test('padWidth 按帧总数决定补零位数', () => {
assert.equal(padWidth(9), 1);
assert.equal(padWidth(12), 2);
assert.equal(padWidth(100), 3);
});
test('frameFileName 1 基且补零,保证字典序等于帧序', () => {
assert.equal(frameFileName('00014.png', 1, 12), '00014_01.png');
assert.equal(frameFileName('00014.png', 10, 12), '00014_10.png');
const names = [frameFileName('00014.png', 2, 12), frameFileName('00014.png', 10, 12)];
assert.deepEqual([...names].sort(), ['00014_02.png', '00014_10.png'], '字典序必须等于帧序');
});
test('frameFileName 单帧图原样返回', () => {
assert.equal(frameFileName('00001.png', 1, 1), '00001.png');
});
test('frameFileName undefined 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameFileName('00014.png', undefined, 12), RangeError);
assert.throws(() => frameFileName('00014.png', undefined, 12), /00014\.png/);
});
test('frameFileName NaN 作为 frameIndex 抛 RangeError', () => {
assert.throws(() => frameFileName('00014.png', NaN, 12), RangeError);
assert.throws(() => frameFileName('00014.png', NaN, 12), /00014\.png/);
});
test('frameFileName 越界抛 RangeError 且带图名', () => {
assert.throws(() => frameFileName('00014.png', 13, 12), RangeError);
assert.throws(() => frameFileName('00014.png', 13, 12), /00014\.png/);
});
test('frameNames 返回全部帧,下标 0 对应帧 1', () => {
const names = frameNames(IMG_00014);
assert.equal(names.length, 12);
assert.equal(names[0], '00014_01.png');
assert.equal(names[11], '00014_12.png');
assert.deepEqual(frameNames(IMG_SINGLE), ['00001.png']);
});
@@ -0,0 +1,122 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { ROOT } from '../lib/paths.mjs';
import { loadSource, SourceError } from '../ui-migration/gameabc-source.mjs';
const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output');
function fakeSource(overrides = {}) {
const dir = mkdtempSync(join(tmpdir(), 'gameabc-src-'));
const files = {
'gameabc_Object.json': {
ObjectList: [
{},
{ Property: { ObjectID: 1, ObjectType: 2, ObjectName: '背景', Left: 0, Top: 0, Width: 1280, Height: 720, BelongLayerID: 1, IndexOfLayer: 1, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0, L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 1, VoiceFileID: 0, TimerInterval: 0 }, Event: { mousedown: 1, mouseup: 1 } },
],
},
'gameabc_Layer.json': { LayerList: [{}, { Property: { LayerID: 1, LayerName: 'Logo_Layer' }, ObjectList: [1] }] },
'gameabc_GroupList.json': { GroupList: [{ GroupID: 1, ObjectList: [1] }] },
'gameabc_Image.json': { ImageFileList: [{}, { id: 1, w_all: 1280, h_all: 720, w: 1, h: 1, frame_all: 1, bmp: '00001.png', w1: 1280, h1: 720 }] },
...overrides,
};
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(dir, name), JSON.stringify(content));
}
return dir;
}
test('loadSource 解析对象、层、组、图,并附上 events', () => {
const dir = fakeSource();
try {
const s = loadSource(dir);
assert.equal(s.objects.size, 1);
assert.equal(s.objects.get(1).ObjectName, '背景');
assert.deepEqual(s.objects.get(1).events, ['mousedown', 'mouseup']);
assert.deepEqual(s.layers, [{ layerId: 1, name: 'Logo_Layer', objectIds: [1] }]);
assert.deepEqual(s.groups, [{ groupId: 1, objectIds: [1] }]);
assert.equal(s.images.get(1).bmp, '00001.png');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 跳过源数据里的空占位槽 {}', () => {
const dir = fakeSource();
try {
const s = loadSource(dir);
assert.equal(s.objects.size, 1, '首个 {} 占位不应变成对象');
assert.equal(s.layers.length, 1);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 对网格与帧数不符的图显式抛错(不兜底)', () => {
const dir = fakeSource({
'gameabc_Image.json': { ImageFileList: [{}, { id: 1, w_all: 100, h_all: 100, w: 3, h: 4, frame_all: 11, bmp: 'bad.png', w1: 33, h1: 25 }] },
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /bad\.png/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 对缺失文件显式抛错', () => {
const dir = mkdtempSync(join(tmpdir(), 'gameabc-empty-'));
try {
assert.throws(() => loadSource(dir), /缺少源数据文件/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 ObjectList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_Object.json': { Property: [] }, // 缺 ObjectList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /ObjectList/);
assert.throws(() => loadSource(dir), /gameabc_Object\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 ImageFileList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_Image.json': { id: 1 }, // 缺 ImageFileList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /ImageFileList/);
assert.throws(() => loadSource(dir), /gameabc_Image\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 LayerList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_Layer.json': { Property: {} }, // 缺 LayerList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /LayerList/);
assert.throws(() => loadSource(dir), /gameabc_Layer\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 缺少顶层数组键 GroupList 时显式抛错', () => {
const dir = fakeSource({
'gameabc_GroupList.json': { data: [] }, // 缺 GroupList
});
try {
assert.throws(() => loadSource(dir), SourceError);
assert.throws(() => loadSource(dir), /GroupList/);
assert.throws(() => loadSource(dir), /gameabc_GroupList\.json/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadSource 在真实源数据上得到已知规模(回归护栏)', () => {
const s = loadSource(REAL_SOURCE);
assert.equal(s.objects.size, 991, '对象总数');
assert.equal(s.layers.length, 55, '界面数');
assert.equal(s.groups.length, 80, '分组数');
assert.equal(s.images.size, 439, '图片数');
const multi = [...s.images.values()].filter((i) => i.frame_all > 1);
assert.equal(multi.length, 139, '多帧图数');
});
@@ -0,0 +1,53 @@
/**
* 层与图的 bucket 归属。见 spec §3.3。
* bucket 决定资源落进 framework/ui/atlas-{common,login,hall,room} 哪个目录。
*/
/** 登录相关:按层名点名,因其 LayerID 散落在 1..29 段内。 */
const LOGIN_LAYERS = new Set(['Logo_Layer', 'Login_Layer', 'Auth_layer', 'Bind_Layer', 'phoneInfo']);
/** 命名笼统、语义不明的层:标 unassigned 交人工归位,不猜。 */
const UNASSIGNED_LAYERS = new Set([
'Layer6', 'Layer13', 'Layer403', 'Layer411', 'Layer601', 'Layer603', 'Layer618',
]);
const ROOM_ID_MIN = 50;
const COMMON_ID_MIN = 600;
export function layerBucket(layerId, layerName) {
if (UNASSIGNED_LAYERS.has(layerName)) return 'unassigned';
if (LOGIN_LAYERS.has(layerName)) return 'login';
if (layerId >= COMMON_ID_MIN) return 'common';
if (layerId >= ROOM_ID_MIN) return 'room';
return 'hall';
}
/**
* 每张图按引用它的对象所在层决定 bucket。
* 跨多个 bucket → common;无对象引用 → unassigned。
*/
export function imageBuckets(source) {
const layerBucketById = new Map(
source.layers.map((l) => [l.layerId, layerBucket(l.layerId, l.name)]),
);
const bucketsPerImage = new Map();
for (const obj of source.objects.values()) {
if (!obj.ImageFileID) continue;
const b = layerBucketById.get(obj.BelongLayerID);
if (!b) continue;
if (!bucketsPerImage.has(obj.ImageFileID)) bucketsPerImage.set(obj.ImageFileID, new Set());
bucketsPerImage.get(obj.ImageFileID).add(b);
}
const out = new Map();
for (const id of source.images.keys()) {
const set = bucketsPerImage.get(id);
if (!set || set.size === 0) { out.set(id, 'unassigned'); continue; }
const real = [...set].filter((b) => b !== 'unassigned');
if (real.length === 0) out.set(id, 'unassigned');
else if (real.length === 1) out.set(id, real[0]);
else out.set(id, 'common');
}
return out;
}
@@ -0,0 +1,165 @@
import {
DESIGN_WIDTH, DESIGN_HEIGHT, CANVAS_WIDTH, CANVAS_HEIGHT,
toCocosPosition, inferWidget, stretchWidget, isFullScreen, isOffscreen,
} from './coords.mjs';
import { frameFileName, padWidth } from './frames.mjs';
import { layerBucket, imageBuckets } from './buckets.mjs';
/** 中间描述组装。见 spec §1。 */
const OBJECT_TYPE_SPRITE = 2;
const OBJECT_TYPE_TEXT = 4;
/**
* 帧号越界(旧数据里美术重导时帧数变少,但对象上的 FrameIndex 没跟着更新)。
* 只拦这一种已知情况——预判而非 try/catch,免得连带吞掉 frameFileName 里真正的实现 bug。
* 单帧图(frame_all<=1)的 FrameIndex 本就与渲染无关,不算越界。
*/
function isFrameIndexOutOfRange(obj, image) {
const requested = obj.FrameIndex || 1;
return image.frame_all > 1 && requested > image.frame_all;
}
function spriteBlock(obj, image, bucket, warnings) {
const dir = `atlas-${bucket}`;
const sliced = obj.L9 || obj.T9 || obj.R9 || obj.B9;
const outOfRange = isFrameIndexOutOfRange(obj, image);
const frameIndex = outOfRange ? 1 : (obj.FrameIndex || 1);
if (outOfRange) {
warnings.push({
objectId: obj.ObjectID,
layerId: obj.BelongLayerID,
imageFileId: obj.ImageFileID,
frameIndex: obj.FrameIndex,
frameCount: image.frame_all,
reason: `对象 ${obj.ObjectID}(${obj.ObjectName})引用 ${image.bmp} 的帧 ${obj.FrameIndex},`
+ `超出该图帧数 ${image.frame_all}(很可能是美术重导后 FrameIndex 未同步更新),已回退为帧 1`,
});
}
const block = {
frame: `${dir}/${frameFileName(image.bmp, frameIndex, image.frame_all)}`,
type: sliced ? 'sliced' : 'simple',
};
if (image.frame_all > 1) {
block.frameSet = {
prefix: `${dir}/${image.bmp.replace(/\.png$/i, '')}`,
count: image.frame_all,
pad: padWidth(image.frame_all),
};
}
if (sliced) {
block.slice = { left: obj.L9, right: obj.R9, top: obj.T9, bottom: obj.B9 };
}
return { block, outOfRange };
}
function toNode(obj, source, imageBucketMap, warnings) {
// 拉伸(四边置0)适用于全部满屏对象(spec §2.4: 实测 52 个),不论是否层内首个。
// backgroundNodeId(层内唯一)是另一件事:仅用于"首个+满屏"的背景判定,与此处解耦。
const stretch = isFullScreen(obj.Width, obj.Height);
const node = {
id: obj.ObjectID,
name: obj.ObjectName,
kind: obj.ObjectType === OBJECT_TYPE_TEXT ? 'label' : 'sprite',
position: toCocosPosition(obj.Left, obj.Top, obj.Width, obj.Height),
size: { width: obj.Width, height: obj.Height },
siblingIndex: obj.IndexOfLayer,
widget: stretch ? stretchWidget() : inferWidget(obj.Left, obj.Width),
flags: {
stretch,
offscreen: isOffscreen(obj.Left, obj.Top, obj.Width, obj.Height),
animation: obj.FrameStyle === 1,
frameIndexOutOfRange: false,
},
legacy: {
objectId: obj.ObjectID,
imageFileId: obj.ImageFileID ?? 0,
frameIndex: obj.FrameIndex ?? 0,
frameStyle: obj.FrameStyle ?? 0,
groupId: obj.GroupID ?? 0,
voiceFileId: obj.VoiceFileID ?? 0,
timerInterval: obj.TimerInterval ?? 0,
left: obj.Left,
top: obj.Top,
events: obj.events ?? [],
},
};
if (node.kind === 'sprite' && obj.ImageFileID) {
const image = source.images.get(obj.ImageFileID);
if (!image) {
throw new Error(
`对象 ${obj.ObjectID}(${obj.ObjectName})引用了不存在的图 ImageFileID=${obj.ImageFileID}`,
);
}
const { block, outOfRange } = spriteBlock(
obj, image, imageBucketMap.get(obj.ImageFileID) ?? 'unassigned', warnings,
);
node.sprite = block;
node.flags.frameIndexOutOfRange = outOfRange;
}
return node;
}
export function convertLayer(source, layer, imageBucketMap) {
const objs = layer.objectIds
.map((id) => source.objects.get(id))
.filter(Boolean);
const first = objs[0];
const backgroundNodeId =
first && isFullScreen(first.Width, first.Height) ? first.ObjectID : null;
const warnings = [];
const nodes = objs
.map((o) => toNode(o, source, imageBucketMap, warnings))
.sort((a, b) => a.siblingIndex - b.siblingIndex);
const nodeIdSet = new Set(nodes.map((n) => n.id));
const groups = source.groups
.map((g) => ({ groupId: g.groupId, nodeIds: g.objectIds.filter((id) => nodeIdSet.has(id)) }))
.filter((g) => g.nodeIds.length > 0);
return {
layerId: layer.layerId,
name: layer.name,
bucket: layerBucket(layer.layerId, layer.name),
backgroundNodeId,
groups,
nodes,
warnings,
};
}
export function convertAll(source) {
const imageBucketMap = imageBuckets(source);
const layers = source.layers.map((l) => convertLayer(source, l, imageBucketMap));
const objectIndex = {};
const warnings = [];
for (const doc of layers) {
for (const n of doc.nodes) objectIndex[String(n.id)] = { layerId: doc.layerId, nodeId: n.id };
warnings.push(...doc.warnings);
}
const manifest = {
generatedBy: 'scripts/convert-ui.mjs',
// 内容区基准(旧数据事实),坐标/三区据此判定,不随设计分辨率改变。
designWidth: DESIGN_WIDTH,
designHeight: DESIGN_HEIGHT,
// 设计分辨率画布(20:9),落地 Layer/Group 根节点尺寸据此设定。
canvasWidth: CANVAS_WIDTH,
canvasHeight: CANVAS_HEIGHT,
layers: layers.map((d) => ({
layerId: d.layerId,
name: d.name,
bucket: d.bucket,
file: `${String(d.layerId).padStart(3, '0')}-${d.name}.json`,
nodeCount: d.nodes.length,
})),
objectIndex,
};
return { layers, manifest, warnings };
}
@@ -0,0 +1,56 @@
/**
* 坐标换算与锚点推断。见 spec §2。
* 旧引擎:原点左上、Y 向下、精灵锚点左上。
* Cocos:原点在父节点中心、Y 向上、节点锚点 (0.5, 0.5)。
*/
// 旧 UI 内容区基准(数据源事实,不可变)。
// 991 个对象的 Left/Top 都是相对旧引擎 1280×720 画布给出的,坐标换算、三区锚点、
// 满屏/越界判断都据此判定。它与「设计分辨率画布宽度」是两回事,见 CANVAS_WIDTH。
export const DESIGN_WIDTH = 1280;
export const DESIGN_HEIGHT = 720;
// Cocos 设计分辨率画布(20:9 主流移动端比例)。落地时 Layer/Group 根节点据此设定
// UITransform 尺寸,运行时 Canvas 按 designResolution + fitHeight 对齐到屏幕。
// 对象坐标与 Widget 值仍以 DESIGN_WIDTH 为基准——靠左贴左、靠右贴右、居中在中心,
// 画布变宽由 Widget 弹性贴边消化,无需重算坐标。
export const CANVAS_WIDTH = 1600;
export const CANVAS_HEIGHT = 720;
const HALF_W = DESIGN_WIDTH / 2;
const HALF_H = DESIGN_HEIGHT / 2;
const LEFT_ZONE = DESIGN_WIDTH / 4; // 320
const RIGHT_ZONE = (DESIGN_WIDTH * 3) / 4; // 960
/** 左上坐标 → Cocos 中心锚点坐标。spec §2.2。 */
export function toCocosPosition(left, top, width, height) {
return {
x: left + width / 2 - HALF_W,
y: HALF_H - top - height / 2,
};
}
/**
* 水平锚点三区推断。spec §2.3。
* fitHeight 下垂直方向永远精确撑满,故不推断垂直锚点。
*/
export function inferWidget(left, width) {
const cx = left + width / 2;
if (cx < LEFT_ZONE) return { horizontal: 'left', left };
if (cx > RIGHT_ZONE) return { horizontal: 'right', right: DESIGN_WIDTH - (left + width) };
return { horizontal: 'center', horizontalCenter: cx - HALF_W };
}
/** 背景与遮罩:四边拉伸。spec §2.4。 */
export function stretchWidget() {
return { horizontal: 'stretch', vertical: 'stretch', left: 0, right: 0, top: 0, bottom: 0 };
}
export function isFullScreen(width, height) {
return width >= DESIGN_WIDTH && height >= DESIGN_HEIGHT;
}
/** 坐标超出画布。spec §2.5:不裁剪,只标记。 */
export function isOffscreen(left, top, width, height) {
return left < 0 || top < 0 || left + width > DESIGN_WIDTH || top + height > DESIGN_HEIGHT;
}
@@ -0,0 +1,60 @@
import { PNG } from 'pngjs';
import { frameRect } from './frames.mjs';
/**
* PNG 切分与重组。见 spec §4.2 ②。
* 切分是可逆操作,故「重组(切分(原图)) 的像素 === 原图像素」是对全部帧的完全验证。
*/
/** 切分整图 → 帧 Buffer 数组,下标 0 对应帧 1。 */
export function splitSheet(sheetBuffer, image) {
const sheet = PNG.sync.read(sheetBuffer);
const out = [];
for (let i = 1; i <= image.frame_all; i++) {
const r = frameRect(image, i);
const cell = new PNG({ width: r.width, height: r.height });
PNG.bitblt(sheet, cell, r.x, r.y, r.width, r.height, 0, 0);
out.push(PNG.sync.write(cell));
}
return out;
}
/** 把帧按原网格拼回整图。 */
export function reassemble(frameBuffers, image) {
const sheet = new PNG({ width: image.w * image.w1, height: image.h * image.h1 });
frameBuffers.forEach((buf, i) => {
const r = frameRect(image, i + 1);
const cell = PNG.sync.read(buf);
PNG.bitblt(cell, sheet, 0, 0, r.width, r.height, r.x, r.y);
});
return PNG.sync.write(sheet);
}
/**
* 比较两张 PNG 的**解码像素数据**。
* 不能比较文件字节——编码参数不同会让同像素的图产生不同字节。
*/
export function pixelsEqual(bufA, bufB) {
const a = PNG.sync.read(bufA);
const b = PNG.sync.read(bufB);
if (a.width !== b.width || a.height !== b.height) return false;
return Buffer.compare(a.data, b.data) === 0;
}
/** 对一张多帧图做重组校验。 */
export function verifySheet(sheetBuffer, image) {
const sheet = PNG.sync.read(sheetBuffer);
const expectW = image.w * image.w1;
const expectH = image.h * image.h1;
if (sheet.width !== expectW || sheet.height !== expectH) {
return {
ok: false,
reason: `尺寸不符: 实际 ${sheet.width}x${sheet.height},按网格应为 ${expectW}x${expectH}`,
};
}
const back = reassemble(splitSheet(sheetBuffer, image), image);
if (!pixelsEqual(sheetBuffer, back)) {
return { ok: false, reason: '重组后像素与原图不一致(切分逻辑有误)' };
}
return { ok: true };
}
@@ -0,0 +1,46 @@
/**
* 帧矩形与命名。见 spec §0.3、§3。
* 帧号 1 基、行优先——已由读取 00014.png 的网格内容与 11 个按钮的 FrameIndex 对照实证。
*/
/** 帧号 → 源矩形。FrameIndex=0 按帧 1 处理(实测仅 2 个对象如此)。 */
export function frameRect(image, frameIndex) {
const normalized = frameIndex === 0 ? 1 : frameIndex;
const idx = normalized - 1;
if (!Number.isInteger(idx) || idx < 0 || idx >= image.frame_all) {
throw new RangeError(
`帧号非法或越界: ${image.bmp} frameIndex=${frameIndex}(有效范围 1..${image.frame_all},0 视同 1)`,
);
}
return {
x: (idx % image.w) * image.w1,
y: Math.floor(idx / image.w) * image.h1,
width: image.w1,
height: image.h1,
};
}
/** 补零位数:保证字典序 = 帧序。 */
export function padWidth(frameCount) {
return String(frameCount).length;
}
/** 00014.png + 帧 5/共 12 → 00014_05.png;单帧图原样返回。 */
export function frameFileName(bmp, frameIndex, frameCount) {
if (frameCount <= 1) return bmp;
const normalized = frameIndex === 0 ? 1 : frameIndex;
if (!Number.isInteger(normalized) || normalized <= 0 || normalized > frameCount) {
throw new RangeError(
`帧号非法或越界: ${bmp} frameIndex=${frameIndex}(有效范围 1..${frameCount},0 视同 1)`,
);
}
const base = bmp.replace(/\.png$/i, '');
const n = String(normalized).padStart(padWidth(frameCount), '0');
return `${base}_${n}.png`;
}
/** 该图全部帧的文件名,下标 0 对应帧 1。 */
export function frameNames(image) {
return Array.from({ length: image.frame_all }, (_, i) =>
frameFileName(image.bmp, i + 1, image.frame_all));
}
@@ -0,0 +1,66 @@
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
/** 源数据非法时抛出。第二准则:缺失/非法一律显式暴露,不兜底。 */
export class SourceError extends Error {
constructor(message) { super(message); this.name = 'SourceError'; }
}
function readJson(dir, name) {
const p = join(dir, name);
if (!existsSync(p)) throw new SourceError(`缺少源数据文件: ${p}`);
try {
return JSON.parse(readFileSync(p, 'utf8'));
} catch (e) {
throw new SourceError(`源数据 JSON 解析失败: ${p} — ${e.message}`);
}
}
function readArray(dir, fileName, key) {
const json = readJson(dir, fileName);
const arr = json[key];
if (!Array.isArray(arr)) {
throw new SourceError(
`源数据 ${fileName} 缺少顶层数组 "${key}"(实际为 ${typeof arr})`,
);
}
return arr;
}
/**
* 读取并校验 gameabc 导出数据。
* 源数组用 `{}` 占位空槽(下标即 ID),须跳过。
*/
export function loadSource(outputDir) {
const objects = new Map();
for (const entry of readArray(outputDir, 'gameabc_Object.json', 'ObjectList')) {
if (!entry || !entry.Property) continue;
const p = entry.Property;
objects.set(p.ObjectID, { ...p, events: Object.keys(entry.Event ?? {}) });
}
const images = new Map();
for (const i of readArray(outputDir, 'gameabc_Image.json', 'ImageFileList')) {
if (!i || i.id === undefined) continue;
if (i.w * i.h !== i.frame_all) {
throw new SourceError(
`图 ${i.bmp} 的网格与帧数不符: ${i.w}x${i.h}=${i.w * i.h} != frame_all=${i.frame_all}`,
);
}
images.set(i.id, i);
}
const layers = readArray(outputDir, 'gameabc_Layer.json', 'LayerList')
.filter((l) => l && l.Property)
.map((l) => ({
layerId: l.Property.LayerID,
name: l.Property.LayerName,
objectIds: l.ObjectList ?? [],
}));
const groups = readArray(outputDir, 'gameabc_GroupList.json', 'GroupList')
.filter((g) => g && g.GroupID !== undefined)
.map((g) => ({ groupId: g.GroupID, objectIds: g.ObjectList ?? [] }));
return { objects, images, layers, groups };
}
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { loadSource } from './ui-migration/gameabc-source.mjs';
import { verifySheet } from './ui-migration/frame-io.mjs';
import { ROOT } from './lib/paths.mjs';
const DEFAULT_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3');
/** 对全部多帧图跑重组校验。返回 { checked, failures: [{bmp, reason}] }。 */
export function verifyAllFrames(projectDir = DEFAULT_SOURCE) {
const { images } = loadSource(join(projectDir, 'output'));
const failures = [];
let checked = 0;
for (const image of images.values()) {
if (image.frame_all <= 1) continue;
const p = join(projectDir, 'assets', 'bmp', image.bmp);
if (!existsSync(p)) {
failures.push({ bmp: image.bmp, reason: `图片文件不存在: ${p}` });
continue;
}
checked++;
const r = verifySheet(readFileSync(p), image);
if (!r.ok) failures.push({ bmp: image.bmp, reason: r.reason });
}
return { checked, failures };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const { checked, failures } = verifyAllFrames(process.argv[2] ?? DEFAULT_SOURCE);
console.log(`重组校验:${checked} 张多帧图`);
if (failures.length === 0) {
console.log('OK: 全部通过(切分对全部帧完全可逆)。');
process.exit(0);
}
console.error(`\n${failures.length} 张失败:`);
for (const f of failures) console.error(` ${f.bmp}\n ${f.reason}`);
process.exit(1);
}