feat(ui-migration): 源数据加载与校验

读取 gameabc_{Object,Layer,GroupList,Image}.json, 跳过 {} 占位槽,
校验多帧图的网格与帧数一致(不符即显式抛错, 不兜底)。
真实数据回归护栏: 991 对象 / 55 界面 / 80 组 / 439 图 / 139 多帧图。
引入 pngjs devDep 供后续切图使用(仅迁移工具, 不进框架运行时)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 00:31:36 +08:00
co-authored by Claude Opus 5
parent 8b24acab3f
commit 9d58145bd8
4 changed files with 146 additions and 0 deletions
@@ -0,0 +1,78 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtempSync, mkdirSync, 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 在真实源数据上得到已知规模(回归护栏)', () => {
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,55 @@
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}`);
}
}
/**
* 读取并校验 gameabc 导出数据。
* 源数组用 `{}` 占位空槽(下标即 ID),须跳过。
*/
export function loadSource(outputDir) {
const objects = new Map();
for (const entry of readJson(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 readJson(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 = (readJson(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 = (readJson(outputDir, 'gameabc_GroupList.json').GroupList ?? [])
.filter((g) => g && g.GroupID !== undefined)
.map((g) => ({ groupId: g.GroupID, objectIds: g.ObjectList ?? [] }));
return { objects, images, layers, groups };
}