diff --git a/docs/superpowers/plans/2026-08-28-legacy-ui-migration.md b/docs/superpowers/plans/2026-08-28-legacy-ui-migration.md new file mode 100644 index 0000000..9b73029 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-legacy-ui-migration.md @@ -0,0 +1,1595 @@ +# 旧 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__cocos-creator-mcp__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 刷新资源(工具名用 `ToolSearch` 自行发现,当前扩展为 `cocos-mcp-server` 的 16 个聚合工具;刷新资源大概率是 `cocos_asset` 的某个 action,可用 `cocos_knowledge {topic:"tool_guide", query:"asset.refresh"}` 查用法)。 + +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` 且 cocos-mcp-server 服务可用。所有编辑器操作**必须**走 `mcp__cocos-creator-mcp__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 像素值;设计分辨率未变,故这些值仍然有效。