Files
youle_cocos/docs/superpowers/plans/2026-08-28-legacy-ui-migration.md
joywayerandClaude Opus 5 02dc5d51ca chore(spec): 综合清理 + legacy-layer 迁移 spec/plan/data
主要改动:
- 切到 funplay-cocos-mcp v0.5.1 (用户级配置, 项目级 .mcp.json 删除)
- 仓库文档/CLAUDE.md/.gitignore 等清理过时 cocos-mcp-server 引用
- memory 文件同步: cocos-mcp-setup/path/blocker/spriteframe-uuid/prefab-persist 等加 funplay 实测警告
- memory 新建 funplay-cocos-mcp-pending-verification.md (后已被实测覆盖)
- spec/plan/data:
  - docs/superpowers/specs/2026-09-02-legacy-layer-migration-design.md
  - docs/superpowers/plans/2026-09-02-legacy-layer-migration.md
  - docs/superpowers/data/layer-spirit-summary.json
- YouleNexus: profiles.ts / defaults.ts / PlayerInfoView.prefab / scene 改动

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:36:54 +08:00

1596 lines
65 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 旧 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) <noreply@anthropic.com>`;**提交必须用显式路径 `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<number, Obj>, images: Map<number, Img>, 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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<imageId, bucket>`(跨 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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-<groupId>(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:"<tool>.<action>"}` 可查具体 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) <noreply@anthropic.com>"
```
---
## 验收标准(本计划完成定义)
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 像素值;设计分辨率未变,故这些值仍然有效。