主要改动: - 切到 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>
65 KiB
旧 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 ErrorObj=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 之后):
"pngjs": "^7.0.0",
在 scripts 中,"build-game" 那行之后加两行:
"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:
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:
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
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 = 720toCocosPosition(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) -> booleanisOffscreen(left, top, width, height) -> boolean
-
Step 1: 写失败测试
创建 cocoscreator_projects/scripts/test/ui-coords.test.mjs:
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:
/**
* 坐标换算与锚点推断。见 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
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) -> numberframeFileName(bmp, frameIndex, frameCount) -> string(单帧图原样返回bmp)frameNames(image) -> string[](该图全部帧的文件名,下标 0 对应帧 1)
-
Step 1: 写失败测试
创建 cocoscreator_projects/scripts/test/ui-frames.test.mjs:
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:
/**
* 帧矩形与命名。见 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
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) -> BufferpixelsEqual(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:
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:
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.mjsCLI
创建 cocoscreator_projects/scripts/verify-frames.mjs:
#!/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
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:
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:
/**
* 层与图的 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
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 2toCocosPosition/inferWidget/stretchWidget/isFullScreen/isOffscreen;Task 3frameFileName/padWidth;Task 5layerBucket/imageBuckets -
Produces:
convertLayer(source, layer, imageBucketMap) -> LayerDocconvertAll(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:
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:
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
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 4splitSheet;Task 5imageBuckets;Task 6convertAll -
Produces:
runConvert(opts) -> { layerCount, nodeCount, imageCount, frameCount, unassigned: string[] } -
Step 1: 实现
convert-ui.mjs
创建 cocoscreator_projects/scripts/convert-ui.mjs:
#!/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:
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
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为真的节点用四边拉伸 Widgetsprite.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:
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 行),一并回填。
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>"
验收标准(本计划完成定义)
npm test全绿:既有 64 个 + 本计划新增(source 5 + coords 12 + frames 12 + frame-io 7 + buckets 8 + convert 10 = 54)= 118 个用例。node scripts/verify-frames.mjs对全部 139 张多帧图通过重组校验,退出码 0。node scripts/convert-ui.mjs产出 56 个中间描述文件(55 层 + manifest)与 1306 张散图;manifest.objectIndex含 991 条。- 中间描述里节点总数 等于 991——一个对象都没丢。
- 7 个 bucket 未定的层被告警列出,资源落入
atlas-unassigned,未被静默归入common。 Login_Layer落地为 prefab,结构比对(节点数/分组数/背景/sliced/label/offscreen)逐项相符。- spec §5 待验证项第 3 行有实测结论(MCP 批量落地可行性与耗时)。
- 框架真源里新增的 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 像素值;设计分辨率未变,故这些值仍然有效。