# 旧 gameabc UI 数据迁移(子系统 A)Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把旧 H5 工程的 991 个 UI 对象 / 55 个界面,程序化转换成一份可审阅的「中间描述」+ 1306 张切好的散图,并用一个真实界面验证其可落地为 Cocos prefab。 **Architecture:** 分两段。**Node 侧**是纯函数转换器:读 `gameabc_*.json` → 坐标换算 / 锚点推断 / 分组 / bucket 归属 / 帧切分 → 产出 55 份中间描述 JSON + 散图。**编辑器侧**读中间描述建节点存 prefab;本计划只用 MCP 跑通一个界面验证链路,不写编辑器扩展。转换器绝不直接生成 `.prefab`/`.meta`(红线)。 **Tech Stack:** Node.js 20.13.1(ESM `.mjs`)、`node:test` + `node:assert`、`pngjs`(PNG 编解码,见 Global Constraints 的例外说明);编辑器侧走 `mcp__funplay_cocos__*`。 **Spec:** `docs/superpowers/specs/2026-08-28-legacy-ui-migration-design.md` ## Global Constraints - **绝不手写 `.prefab` / `.meta` / `.scene` / `.pac`**(CLAUDE.md)。序列化资源只能由 Cocos 编辑器经官方 API 产生。转换器只产出 JSON 与 PNG。 - **数据源权威唯一、下游不兜底**:源数据缺失或非法(如网格与帧数不符)必须**显式抛错**,不得静默跳过或猜一个默认值。 - **不得因静态未引用而裁剪帧**(spec §0.3):帧号 1 基,且帧在运行时由 `set_self` 切换;静态数据里的 `FrameIndex` 只是初始帧。全部 1306 张帧图都要产出。 - **帧序:1 基 + 行优先**。`列 = (FrameIndex − 1) % w`、`行 = ⌊(FrameIndex − 1) / w⌋`。`FrameIndex = 0` 按 1 处理。 - **设计分辨率 1280×720,坐标 1:1 迁移**,不做任何缩放。 - **节点锚点用 Cocos 惯例 `(0.5, 0.5)`**:`x = Left + W/2 − 640`、`y = 360 − Top − H/2`。 - **只推断水平锚点**(`fitHeight` 下垂直永不溢出)。 - **Node.js 20.13.1,ESM `.mjs`,`node:test` + `node:assert`**。 - **新增依赖 `pngjs` 属经裁决的例外**:切分 PNG 需要图像编解码,Node 无内置 codec,自写 PNG 解码器(滤波/隔行/调色板/tRNS)风险远大于引入一个成熟纯 JS 库。工具层已有第三方 devDependency 先例(`ws` 用于真机联调),故一致。`pngjs` **仅供迁移工具使用**,不得进入框架运行时代码。 - 提交信息用中文,末行 `Co-Authored-By: Claude Opus 5 (1M context) `;**提交必须用显式路径 `git add <文件>`**。 - 所有命令的工作目录为 `G:/Works/YouleGamesCocosCreator/cocoscreator_projects`(下称 monorepo 根),除非另行说明。 --- ## 目录与文件结构(本计划建立) ``` cocoscreator_projects/ ├─ package.json # 加 pngjs devDep + 两个 npm script ├─ scripts/ │ ├─ ui-migration/ │ │ ├─ gameabc-source.mjs # 读取+校验源数据(Task 1) │ │ ├─ coords.mjs # 坐标换算 + 锚点推断(Task 2) │ │ ├─ frames.mjs # 帧矩形 + 命名(纯计算,Task 3) │ │ ├─ frame-io.mjs # PNG 切分/重组(Task 4) │ │ ├─ buckets.mjs # 层/图 → bucket(Task 5) │ │ └─ convert.mjs # 中间描述组装(Task 6) │ ├─ convert-ui.mjs # 转换 CLI(Task 7) │ ├─ verify-frames.mjs # 重组校验 CLI(Task 4) │ └─ test/ │ ├─ ui-source.test.mjs # Task 1 │ ├─ ui-coords.test.mjs # Task 2 │ ├─ ui-frames.test.mjs # Task 3 │ ├─ ui-frame-io.test.mjs # Task 4 │ ├─ ui-buckets.test.mjs # Task 5 │ └─ ui-convert.test.mjs # Task 6 └─ ui-migration/ └─ intermediate/ # 转换产出,入库供人工审阅(Task 7) ``` 散图产出到 `YouleNexus/assets/framework/ui/atlas-{common,login,hall,room}/`(Task 7)。 --- ### Task 1: 源数据加载与校验 **Files:** - Modify: `cocoscreator_projects/package.json` - Create: `cocoscreator_projects/scripts/test/ui-source.test.mjs` - Create: `cocoscreator_projects/scripts/ui-migration/gameabc-source.mjs` **Interfaces:** - Consumes: 无(首个任务) - Produces: `loadSource(outputDir) -> { objects: Map, images: Map, layers: Layer[], groups: Group[] }`;`class SourceError extends Error` - `Obj` = `gameabc_Object.json` 的 `Property` 全部字段 + `events: string[]` - `Img` = `gameabc_Image.json` 的条目(`id`/`w_all`/`h_all`/`w`/`h`/`frame_all`/`bmp`/`w1`/`h1`) - `Layer` = `{ layerId, name, objectIds: number[] }` - `Group` = `{ groupId, objectIds: number[] }` - [ ] **Step 1: 加依赖与 npm script** 在 `cocoscreator_projects/package.json` 的 `devDependencies` 中加入(保持字母序,放在 `@types/ws` 之后): ```json "pngjs": "^7.0.0", ``` 在 `scripts` 中,`"build-game"` 那行之后加两行: ```json "convert-ui": "node scripts/convert-ui.mjs", "verify-frames": "node scripts/verify-frames.mjs", ``` Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && npm install` Expected: 安装完成,`node_modules/pngjs` 存在。 - [ ] **Step 2: 写失败测试** 创建 `cocoscreator_projects/scripts/test/ui-source.test.mjs`: ```js 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 { loadSource, SourceError } from '../ui-migration/gameabc-source.mjs'; const REAL_SOURCE = join( 'G:/Works/YouleGamesCocosCreator', '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, '多帧图数'); }); ``` - [ ] **Step 3: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-source.test.mjs` Expected: FAIL — 报 `Cannot find module '../ui-migration/gameabc-source.mjs'` - [ ] **Step 4: 实现 `gameabc-source.mjs`** 创建 `cocoscreator_projects/scripts/ui-migration/gameabc-source.mjs`: ```js 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 }; } ``` - [ ] **Step 5: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-source.test.mjs` Expected: PASS — 5 tests passed - [ ] **Step 6: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/package.json \ cocoscreator_projects/package-lock.json \ cocoscreator_projects/scripts/ui-migration/gameabc-source.mjs \ cocoscreator_projects/scripts/test/ui-source.test.mjs git commit -m "feat(ui-migration): 源数据加载与校验 读取 gameabc_{Object,Layer,GroupList,Image}.json, 跳过 {} 占位槽, 校验多帧图的网格与帧数一致(不符即显式抛错, 不兜底)。 真实数据回归护栏: 991 对象 / 55 界面 / 80 组 / 439 图 / 139 多帧图。 引入 pngjs devDep 供后续切图使用(仅迁移工具, 不进框架运行时)。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 2: 坐标换算与锚点推断 **Files:** - Create: `cocoscreator_projects/scripts/test/ui-coords.test.mjs` - Create: `cocoscreator_projects/scripts/ui-migration/coords.mjs` **Interfaces:** - Consumes: 无 - Produces: - `DESIGN_WIDTH = 1280`、`DESIGN_HEIGHT = 720` - `toCocosPosition(left, top, width, height) -> { x, y }` - `inferWidget(left, width) -> { horizontal: 'left'|'right'|'center', left?, right?, horizontalCenter? }` - `stretchWidget() -> { horizontal: 'stretch', vertical: 'stretch', left: 0, right: 0, top: 0, bottom: 0 }` - `isFullScreen(width, height) -> boolean` - `isOffscreen(left, top, width, height) -> boolean` - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/ui-coords.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { DESIGN_WIDTH, DESIGN_HEIGHT, toCocosPosition, inferWidget, stretchWidget, isFullScreen, isOffscreen, } from '../ui-migration/coords.mjs'; test('设计分辨率保持 1280x720', () => { assert.equal(DESIGN_WIDTH, 1280); assert.equal(DESIGN_HEIGHT, 720); }); 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, '下越界'); }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-coords.test.mjs` Expected: FAIL — 报 `Cannot find module '../ui-migration/coords.mjs'` - [ ] **Step 3: 实现 `coords.mjs`** 创建 `cocoscreator_projects/scripts/ui-migration/coords.mjs`: ```js /** * 坐标换算与锚点推断。见 spec §2。 * 旧引擎:原点左上、Y 向下、精灵锚点左上。 * Cocos:原点在父节点中心、Y 向上、节点锚点 (0.5, 0.5)。 */ export const DESIGN_WIDTH = 1280; export const DESIGN_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; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-coords.test.mjs` Expected: PASS — 12 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/ui-migration/coords.mjs \ cocoscreator_projects/scripts/test/ui-coords.test.mjs git commit -m "feat(ui-migration): 坐标换算与水平锚点三区推断 左上原点/左上锚点 → Cocos 中心原点/中心锚点(0.5,0.5)。 fitHeight 下垂直永不溢出, 故只推断水平锚点。 背景四边拉伸; 越界对象只标记不裁剪。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 3: 帧矩形与命名(纯计算) **Files:** - Create: `cocoscreator_projects/scripts/test/ui-frames.test.mjs` - Create: `cocoscreator_projects/scripts/ui-migration/frames.mjs` **Interfaces:** - Consumes: Task 1 的 `Img` 结构(`w`/`h`/`frame_all`/`w1`/`h1`/`bmp`) - Produces: - `frameRect(image, frameIndex) -> { x, y, width, height }`(1 基、行优先;越界抛 `RangeError`) - `padWidth(frameCount) -> number` - `frameFileName(bmp, frameIndex, frameCount) -> string`(单帧图原样返回 `bmp`) - `frameNames(image) -> string[]`(该图全部帧的文件名,下标 0 对应帧 1) - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/ui-frames.test.mjs`: ```js 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('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('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']); }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-frames.test.mjs` Expected: FAIL — 报 `Cannot find module '../ui-migration/frames.mjs'` - [ ] **Step 3: 实现 `frames.mjs`** 创建 `cocoscreator_projects/scripts/ui-migration/frames.mjs`: ```js /** * 帧矩形与命名。见 spec §0.3、§3。 * 帧号 1 基、行优先——已由读取 00014.png 的网格内容与 11 个按钮的 FrameIndex 对照实证。 */ /** 帧号 → 源矩形。FrameIndex=0 按帧 1 处理(实测仅 2 个对象如此)。 */ export function frameRect(image, frameIndex) { const idx = (frameIndex || 1) - 1; if (idx < 0 || idx >= image.frame_all) { throw new RangeError( `帧号越界: ${image.bmp} frameIndex=${frameIndex}(有效范围 1..${image.frame_all})`, ); } 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 base = bmp.replace(/\.png$/i, ''); const n = String(frameIndex || 1).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)); } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-frames.test.mjs` Expected: PASS — 12 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/ui-migration/frames.mjs \ cocoscreator_projects/scripts/test/ui-frames.test.mjs git commit -m "feat(ui-migration): 帧矩形与命名(1 基 + 行优先) 帧序经实证: 读 00014.png 网格内容与 11 个按钮的 FrameIndex 对照, 六项精确命中。 用例直接锚定该实证样例(通知=10/设置=11/战绩=4)。 命名保持 1 基并补零, 使字典序等于帧序、且文件名与 FrameIndex 直接对齐。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 4: PNG 切分、重组校验与 CLI **Files:** - Create: `cocoscreator_projects/scripts/test/ui-frame-io.test.mjs` - Create: `cocoscreator_projects/scripts/ui-migration/frame-io.mjs` - Create: `cocoscreator_projects/scripts/verify-frames.mjs` **Interfaces:** - Consumes: Task 3 的 `frameRect(image, frameIndex)`;Task 1 的 `loadSource` - Produces: - `splitSheet(sheetBuffer, image) -> Buffer[]`(长度 = `frame_all`,下标 0 对应帧 1) - `reassemble(frameBuffers, image) -> Buffer` - `pixelsEqual(bufA, bufB) -> boolean`(比较**解码后的像素数据**) - `verifySheet(sheetBuffer, image) -> { ok: boolean, reason?: string }` - CLI `verify-frames.mjs`:对全部多帧图跑重组校验,任一失败退出码 1 > **校验的精确含义**:切分可逆,故 `重组(切分(原图))` 的**解码像素缓冲**必须与 `原图` 的解码像素缓冲逐字节相同。**不能**比较重新编码后的 PNG 文件字节——编码器的压缩参数会不同,那样比必然失败。 - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/ui-frame-io.test.mjs`: ```js 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, /尺寸/); }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-frame-io.test.mjs` Expected: FAIL — 报 `Cannot find module '../ui-migration/frame-io.mjs'` - [ ] **Step 3: 实现 `frame-io.mjs`** 创建 `cocoscreator_projects/scripts/ui-migration/frame-io.mjs`: ```js 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 }; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-frame-io.test.mjs` Expected: PASS — 7 tests passed - [ ] **Step 5: 实现 `verify-frames.mjs` CLI** 创建 `cocoscreator_projects/scripts/verify-frames.mjs`: ```js #!/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'; const DEFAULT_SOURCE = 'G:/Works/YouleGamesCocosCreator/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); } ``` - [ ] **Step 6: 对真实资源跑重组校验** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node scripts/verify-frames.mjs` Expected: 打印 `重组校验:139 张多帧图` 与 `OK: 全部通过(切分对全部帧完全可逆)。`,退出码 0。 > **若有失败**:这是**重要发现**,不是小问题——说明帧序假设(1 基 + 行优先)或网格数据在某些图上不成立。**不要**为了让它通过而放宽校验或跳过失败的图。如实记录失败清单并报告,由控制器裁决。 - [ ] **Step 7: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/ui-migration/frame-io.mjs \ cocoscreator_projects/scripts/verify-frames.mjs \ cocoscreator_projects/scripts/test/ui-frame-io.test.mjs git commit -m "feat(ui-migration): PNG 切分/重组与全量重组校验 切分可逆, 故「重组(切分(原图)) 的像素 === 原图像素」是对全部 1006 帧的完全验证, 成本近零。比较解码后的像素缓冲而非文件字节(编码参数不同会让同像素图字节不同)。 verify-frames CLI 对全部 139 张多帧图跑该校验, 任一失败退出码 1。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 5: bucket 归属 **Files:** - Create: `cocoscreator_projects/scripts/test/ui-buckets.test.mjs` - Create: `cocoscreator_projects/scripts/ui-migration/buckets.mjs` **Interfaces:** - Consumes: Task 1 的 `Layer`、`Obj`、`Img` - Produces: - `layerBucket(layerId, layerName) -> 'login'|'hall'|'room'|'common'|'unassigned'` - `imageBuckets(source) -> Map`(跨 bucket 的图归 `common`;无对象引用的图归 `unassigned`) - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/ui-buckets.test.mjs`: ```js 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'; const REAL_SOURCE = join('G:/Works/YouleGamesCocosCreator', '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}`); }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-buckets.test.mjs` Expected: FAIL — 报 `Cannot find module '../ui-migration/buckets.mjs'` - [ ] **Step 3: 实现 `buckets.mjs`** 创建 `cocoscreator_projects/scripts/ui-migration/buckets.mjs`: ```js /** * 层与图的 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; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-buckets.test.mjs` Expected: PASS — 8 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/ui-migration/buckets.mjs \ cocoscreator_projects/scripts/test/ui-buckets.test.mjs git commit -m "feat(ui-migration): 层与图的 bucket 归属 按 LayerID 段位 + 显式名单判定; 命名笼统的 7 个层标 unassigned 交人工归位, 不猜。 图按引用它的对象所在层归属, 跨 bucket 归 common, 无引用归 unassigned。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 6: 中间描述组装 **Files:** - Create: `cocoscreator_projects/scripts/test/ui-convert.test.mjs` - Create: `cocoscreator_projects/scripts/ui-migration/convert.mjs` **Interfaces:** - Consumes: Task 1 `loadSource`;Task 2 `toCocosPosition`/`inferWidget`/`stretchWidget`/`isFullScreen`/`isOffscreen`;Task 3 `frameFileName`/`padWidth`;Task 5 `layerBucket`/`imageBuckets` - Produces: - `convertLayer(source, layer, imageBucketMap) -> LayerDoc` - `convertAll(source) -> { layers: LayerDoc[], manifest: Manifest }` - `LayerDoc` = `{ layerId, name, bucket, backgroundNodeId, groups, nodes }`(结构见 spec §1.2/§1.4) - `Manifest` = `{ generatedBy, designWidth, designHeight, layers: [{layerId,name,bucket,file,nodeCount}], objectIndex: { [objectId]: { layerId, nodeId } } }` - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/ui-convert.test.mjs`: ```js 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'; const REAL_SOURCE = join('G:/Works/YouleGamesCocosCreator', '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 非背景对象用三区锚点与转换坐标', () => { 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 输出 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 } = 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); }); test('convertAll 在真实数据上覆盖全部对象与界面(回归护栏)', () => { const { layers, manifest } = convertAll(loadSource(REAL_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 索引必须完整'); }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-convert.test.mjs` Expected: FAIL — 报 `Cannot find module '../ui-migration/convert.mjs'` - [ ] **Step 3: 实现 `convert.mjs`** 创建 `cocoscreator_projects/scripts/ui-migration/convert.mjs`: ```js import { DESIGN_WIDTH, DESIGN_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; function spriteBlock(obj, image, bucket) { const dir = `atlas-${bucket}`; const sliced = obj.L9 || obj.T9 || obj.R9 || obj.B9; const block = { frame: `${dir}/${frameFileName(image.bmp, obj.FrameIndex || 1, 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; } function toNode(obj, source, imageBucketMap, isBackground) { 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: isBackground ? stretchWidget() : inferWidget(obj.Left, obj.Width), flags: { stretch: isBackground, offscreen: isOffscreen(obj.Left, obj.Top, obj.Width, obj.Height), animation: obj.FrameStyle === 1, }, 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}`, ); } node.sprite = spriteBlock(obj, image, imageBucketMap.get(obj.ImageFileID) ?? 'unassigned'); } 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 nodes = objs .map((o) => toNode(o, source, imageBucketMap, o.ObjectID === backgroundNodeId)) .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, }; } export function convertAll(source) { const imageBucketMap = imageBuckets(source); const layers = source.layers.map((l) => convertLayer(source, l, imageBucketMap)); const objectIndex = {}; for (const doc of layers) { for (const n of doc.nodes) objectIndex[String(n.id)] = { layerId: doc.layerId, nodeId: n.id }; } const manifest = { generatedBy: 'scripts/convert-ui.mjs', designWidth: DESIGN_WIDTH, designHeight: DESIGN_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 }; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/ui-convert.test.mjs` Expected: PASS — 10 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/ui-migration/convert.mjs \ cocoscreator_projects/scripts/test/ui-convert.test.mjs git commit -m "feat(ui-migration): 中间描述组装 对象 → 节点(坐标/锚点/九宫格/帧引用/flags/legacy), 层 → LayerDoc(bucket/背景/分组)。 manifest 含 ObjectID 全局索引 —— 子系统 C 移植 1773 处 set_self 调用的承重依赖。 真实数据回归护栏: 55 界面 / 991 节点, 一个对象都不得丢。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 7: 转换 CLI 与全量产出 **Files:** - Create: `cocoscreator_projects/scripts/convert-ui.mjs` - Create: `cocoscreator_projects/ui-migration/intermediate/`(产出,入库) - Modify: `cocoscreator_projects/YouleNexus/assets/framework/ui/atlas-*/`(散图产出) **Interfaces:** - Consumes: Task 1 `loadSource`;Task 4 `splitSheet`;Task 5 `imageBuckets`;Task 6 `convertAll` - Produces: `runConvert(opts) -> { layerCount, nodeCount, imageCount, frameCount, unassigned: string[] }` - [ ] **Step 1: 实现 `convert-ui.mjs`** 创建 `cocoscreator_projects/scripts/convert-ui.mjs`: ```js #!/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 } 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'; const DEFAULT_SOURCE = 'G:/Works/YouleGamesCocosCreator/projects/Game_Surface_3'; /** * 全量转换:产出中间描述 + 切好的散图。 * 幂等:每次先清空产出目录(spec §4.4),避免残留帧文件混进图集。 */ export function runConvert(opts = {}) { const projectDir = opts.projectDir ?? DEFAULT_SOURCE; const intermediateDir = opts.intermediateDir ?? join(ROOT, 'ui-migration', 'intermediate'); const uiDir = opts.uiDir ?? join(ROOT, 'YouleNexus', 'assets', 'framework', 'ui'); const source = loadSource(join(projectDir, 'output')); const bucketMap = imageBuckets(source); const { layers, manifest } = convertAll(source); // 1) 中间描述 rmSync(intermediateDir, { recursive: true, force: true }); mkdirSync(intermediateDir, { recursive: true }); writeFileSync(join(intermediateDir, 'manifest.json'), `${JSON.stringify(manifest, 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; for (const image of source.images.values()) { const bucket = bucketMap.get(image.id) ?? 'unassigned'; const outDir = join(uiDir, `atlas-${bucket}`); mkdirSync(outDir, { recursive: true }); 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, }; } 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}`); } } ``` - [ ] **Step 2: 先跑重组校验,确认切分逻辑可信** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node scripts/verify-frames.mjs` Expected: `OK: 全部通过`。**未通过则不要继续 Step 3**——切分有误时产出的 1306 张图全是错的。 - [ ] **Step 3: 全量转换** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node scripts/convert-ui.mjs` Expected: 打印 `界面 55 个 / 节点 991 个` 与 `图片 439 张 → 散图 1306 张`,并列出 7 个 unassigned 层的告警。 - [ ] **Step 4: 核对产出规模** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects echo "中间描述: $(ls ui-migration/intermediate/*.json | wc -l) 个(应为 56 = 55 层 + manifest)" echo "散图总数: $(find YouleNexus/assets/framework/ui/atlas-* -name '*.png' | wc -l) 张(应为 1306)" for b in common login hall room unassigned; do d="YouleNexus/assets/framework/ui/atlas-$b" [ -d "$d" ] && echo " atlas-$b: $(find "$d" -name '*.png' | wc -l) 张" done node -e "const m=require('./ui-migration/intermediate/manifest.json');console.log('ObjectID 索引条目:',Object.keys(m.objectIndex).length,'(应为 991)')" ``` Expected: 中间描述 56 个、散图 1306 张、ObjectID 索引 991 条。 - [ ] **Step 5: 让编辑器导入散图并确认无错** 用 MCP 刷新资源(直接调 `mcp__funplay_cocos__refresh_assets`;工具列表可查 `get_tool_catalog`,按返回值核对)。 Expected: 1306 张图被导入并各自生成 `.meta`;无 error 级日志。 > **注意**:`.meta` 由编辑器生成,**必须一并入库**(承载 UUID)。**绝不允许手写或手改**。 - [ ] **Step 6: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/convert-ui.mjs \ cocoscreator_projects/ui-migration/intermediate \ cocoscreator_projects/YouleNexus/assets/framework/ui git commit -m "feat(ui-migration): 转换 CLI 与全量产出 55 界面 / 991 节点 → 中间描述; 439 图 → 1306 张散图落入 atlas-* 目录。 产出前先跑重组校验; 每次转换清空产出目录保证幂等。 bucket 未定的层告警并落入 atlas-unassigned, 不静默归入 common。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 8: 单界面 MCP 落地验证 > **非 TDD 的验证任务。** 目的是证明「中间描述 → Cocos prefab」这条链路成立,并量出落地一个界面的实际耗时,据此判断是否值得写编辑器扩展批量跑完其余 54 个。 > > **前置条件**:Cocos Creator 打开 `YouleNexus` 且 funplay-cocos-mcp 服务可用(`curl http://127.0.0.1:8765/health`)。所有编辑器操作**必须**走 `mcp__funplay_cocos__*`;**绝不允许手写** `.prefab`/`.meta`。MCP 不可用时提示用户去编辑器启用扩展,不得退而求其次手改文件。 **Files:** - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab`(由编辑器生成) - Modify: `docs/superpowers/specs/2026-08-28-legacy-ui-migration-design.md` §5(回填实测结论) **Interfaces:** - Consumes: Task 7 产出的 `ui-migration/intermediate/002-Login_Layer.json` 与 `atlas-login/` 下的散图 - Produces: 一个可用的 prefab + spec §5 的实测结论 - [ ] **Step 1: 读取目标界面的中间描述** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node -e "const d=require('./ui-migration/intermediate/002-Login_Layer.json');console.log('层',d.name,'bucket',d.bucket,'节点',d.nodes.length,'分组',d.groups.length,'背景',d.backgroundNodeId);d.nodes.slice(0,3).forEach(n=>console.log(' ',n.id,n.name,n.kind,JSON.stringify(n.position),JSON.stringify(n.widget)))"` Expected: 打印 `Login_Layer`、`login`、18 个节点及前三个节点的坐标与锚点。 - [ ] **Step 2: 用 MCP 建节点树** 按中间描述建: ``` Login_Layer(根,1280×720,Widget 四边拉伸) └─ group-(1280×720,Widget 四边拉伸,position 0,0) └─ 各对象节点(position / size / widget / Sprite 或 Label) ``` 要点: - 节点锚点用 `(0.5, 0.5)`;`position` 直接用中间描述里的值,**不要再做任何换算** - `flags.stretch` 为真的节点用四边拉伸 Widget - `sprite.type === 'sliced'` 的节点把 Sprite 组件设为 `SLICED` 并按 `slice` 填九宫格 - `kind === 'label'` 的节点建 Label,字体字号颜色**留空**(走 theme,本任务不设) - **多帧节点只设 `sprite.frame` 这一张静态图**,不建 `FrameSet` 组件(见下) > **`FrameSet` 组件不在本计划范围内。** spec §3.4 设计了一个持有全部帧、供运行时 `setFrame(n)` 切换的组件,但那是**框架运行时代码**,服务于子系统 C(移植 1773 处 `set_self` 调用)。子系统 A 的职责是**产出数据**——中间描述里的 `sprite.frameSet` 块(`prefix`/`count`/`pad`)已经把该组件需要的信息备齐。本任务落地的 prefab 只用静态帧,节点表现为「初始帧的样子」,这对验证「中间描述 → prefab」链路是充分的。 > > 连带影响:由于没有组件引用全部帧,**未被静态引用的帧暂时不会被依赖树纳入**。这**不代表**可以删除它们——spec §0.3 明确写了帧在运行时切换、任何帧都不得因静态未引用而裁剪。它们已作为文件存在于 `atlas-*` 目录,等 C 建好 `FrameSet` 组件后自然被引用。 MCP 工具名用 `ToolSearch` 自行发现;`cocos_knowledge {topic:"tool_guide", query:"."}` 可查具体 action 用法。 **记录每一步的工具调用与耗时**——Step 5 要据此外推 55 个界面的总成本。 - [ ] **Step 3: 存为 prefab** 用 MCP 把 `Login_Layer` 节点树存为 `db://assets/framework/ui/prefabs/Login_Layer.prefab`。 Expected: prefab 与其 `.meta` 生成成功,编辑器无 error 级日志。 - [ ] **Step 4: 结构比对** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects node -e " const d=require('./ui-migration/intermediate/002-Login_Layer.json'); console.log('期望节点数:',d.nodes.length); console.log('期望分组数:',d.groups.length); console.log('期望背景节点:',d.backgroundNodeId); console.log('sliced 节点:',d.nodes.filter(n=>n.sprite&&n.sprite.type==='sliced').length); console.log('label 节点:',d.nodes.filter(n=>n.kind==='label').length); console.log('offscreen 节点:',d.nodes.filter(n=>n.flags.offscreen).length); " ``` 再用 MCP 读回 prefab 的节点树,与上面的期望值逐项核对。**任一项不符即如实报告**,不要调整期望值去迁就实现。 - [ ] **Step 5: 视觉验证与耗时记录** 用 MCP 截图该界面,与原项目登录页对比(原项目可参考 `projects/Game_Surface_3` 的实际运行效果,或直接看 `assets/bmp` 下登录相关图)。 记录: - 落地 18 个节点实际用了多少次工具调用、多长时间 - 据此外推 991 个节点的成本 - 结论:**MCP 逐节点批量落地是否可行**,还是必须写编辑器扩展 - [ ] **Step 6: 回填 spec §5 并 Commit** 把 Step 5 的结论写进 spec `§5 待验证项` 表格第 3 行(「MCP 批量建节点的可行性与耗时」),注明日期与实测数字。若发现 Auto Atlas 尺寸上限问题(第 1 行)或包体问题(第 2 行),一并回填。 ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/YouleNexus/assets/framework/ui/prefabs \ docs/superpowers/specs/2026-08-28-legacy-ui-migration-design.md git commit -m "test(ui-migration): 单界面 MCP 落地验证并回填 spec Login_Layer 18 个节点经中间描述落地为 prefab, 结构比对通过。 实测耗时回填 spec §5, 作为「是否需要写编辑器扩展」的判断依据。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ## 验收标准(本计划完成定义) 1. `npm test` 全绿:既有 64 个 + 本计划新增(source 5 + coords 12 + frames 12 + frame-io 7 + buckets 8 + convert 10 = 54)= **118 个用例**。 2. `node scripts/verify-frames.mjs` 对全部 **139 张多帧图**通过重组校验,退出码 0。 3. `node scripts/convert-ui.mjs` 产出 **56 个中间描述文件**(55 层 + manifest)与 **1306 张散图**;`manifest.objectIndex` 含 **991 条**。 4. 中间描述里节点总数 **等于 991**——一个对象都没丢。 5. 7 个 bucket 未定的层被**告警列出**,资源落入 `atlas-unassigned`,未被静默归入 `common`。 6. `Login_Layer` 落地为 prefab,结构比对(节点数/分组数/背景/sliced/label/offscreen)逐项相符。 7. spec §5 待验证项第 3 行有实测结论(MCP 批量落地可行性与耗时)。 8. 框架真源里新增的 **1306 张图的 `.meta` 全部入库**,且**无一是手写的**。 ## 后续计划衔接 - **其余 54 个界面的落地**:依 Task 8 的耗时结论,决定继续用 MCP 还是写编辑器扩展,另立计划。 - **子系统 B(引擎机制映射)**:本计划产出的 `legacy.objectId` / `groupId` / `events` 是它的输入;它的属性号对照表是 C 的前置。可与本计划并行。 - **上一个 spec 的三条 ⏸ 承重假设**(Auto Atlas 构建期打包 / meta 尺寸行为 / Spine):本计划产出 1306 张真实散图后**首次具备验证条件**,应尽快复测。 - **theme 的 `layout` 字段**:目前 3 个字段取自旧 `Game_Config.Info` 的 1280×720 像素值;设计分辨率未变,故这些值仍然有效。