Files
youle_cocos/docs/superpowers/plans/2026-08-28-legacy-ui-migration.md
T
joywayerandClaude Opus 5 8b24acab3f docs(plan): 旧 gameabc UI 数据迁移实施计划(子系统 A)
8 个任务: 源数据校验 → 坐标/锚点 → 帧矩形与命名 → PNG 切分与全量重组校验
→ bucket 归属 → 中间描述组装 → 转换 CLI 全量产出 → 单界面 MCP 落地验证。

重组校验是核心: 切分可逆, 故「重组(切分(原图))的像素 === 原图像素」是对全部
1006 帧的完全验证且成本近零。帧序用例直接锚定读图实证的样例(通知=10/设置=11/战绩=4)。

引入 pngjs devDep 为经裁决的例外(Node 无内置 PNG codec, 自写解码器风险更大),
仅供迁移工具使用, 不进框架运行时。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:24:21 +08:00

65 KiB
Raw Blame History

旧 gameabc UI 数据迁移(子系统 A)Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把旧 H5 工程的 991 个 UI 对象 / 55 个界面,程序化转换成一份可审阅的「中间描述」+ 1306 张切好的散图,并用一个真实界面验证其可落地为 Cocos prefab。

Architecture: 分两段。Node 侧是纯函数转换器:读 gameabc_*.json → 坐标换算 / 锚点推断 / 分组 / bucket 归属 / 帧切分 → 产出 55 份中间描述 JSON + 散图。编辑器侧读中间描述建节点存 prefab;本计划只用 MCP 跑通一个界面验证链路,不写编辑器扩展。转换器绝不直接生成 .prefab/.meta(红线)。

Tech Stack: Node.js 20.13.1(ESM .mjs)、node:test + node:assert、pngjs(PNG 编解码,见 Global Constraints 的例外说明);编辑器侧走 mcp__cocos-creator-mcp__cocos_*。

Spec: docs/superpowers/specs/2026-08-28-legacy-ui-migration-design.md

Global Constraints

  • 绝不手写 .prefab / .meta / .scene / .pac(CLAUDE.md)。序列化资源只能由 Cocos 编辑器经官方 API 产生。转换器只产出 JSON 与 PNG。
  • 数据源权威唯一、下游不兜底:源数据缺失或非法(如网格与帧数不符)必须显式抛错,不得静默跳过或猜一个默认值。
  • 不得因静态未引用而裁剪帧(spec §0.3):帧号 1 基,且帧在运行时由 set_self 切换;静态数据里的 FrameIndex 只是初始帧。全部 1306 张帧图都要产出。
  • 帧序:1 基 + 行优先。列 = (FrameIndex − 1) % w、行 = ⌊(FrameIndex − 1) / w⌋。FrameIndex = 0 按 1 处理。
  • 设计分辨率 1280×720,坐标 1:1 迁移,不做任何缩放。
  • 节点锚点用 Cocos 惯例 (0.5, 0.5):x = Left + W/2 − 640、y = 360 − Top − H/2。
  • 只推断水平锚点(fitHeight 下垂直永不溢出)。
  • Node.js 20.13.1,ESM .mjs,node:test + node:assert。
  • 新增依赖 pngjs 属经裁决的例外:切分 PNG 需要图像编解码,Node 无内置 codec,自写 PNG 解码器(滤波/隔行/调色板/tRNS)风险远大于引入一个成熟纯 JS 库。工具层已有第三方 devDependency 先例(ws 用于真机联调),故一致。pngjs 仅供迁移工具使用,不得进入框架运行时代码。
  • 提交信息用中文,末行 Co-Authored-By: Claude Opus 5 (1M context) <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 之后):

    "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 = 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:

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) -> number
    • frameFileName(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) -> 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:

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.mjs CLI

创建 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 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:

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 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:

#!/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 刷新资源(工具名用 ToolSearch 自行发现,当前扩展为 cocos-mcp-server 的 16 个聚合工具;刷新资源大概率是 cocos_asset 的某个 action,可用 cocos_knowledge {topic:"tool_guide", query:"asset.refresh"} 查用法)。

Expected: 1306 张图被导入并各自生成 .meta;无 error 级日志。

注意:.meta 由编辑器生成,必须一并入库(承载 UUID)。绝不允许手写或手改。

  • Step 6: Commit
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 且 cocos-mcp-server 服务可用。所有编辑器操作必须走 mcp__cocos-creator-mcp__cocos_*;绝不允许手写 .prefab/.meta。MCP 不可用时提示用户启动服务,不得退而求其次手改文件。

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab(由编辑器生成)
  • Modify: docs/superpowers/specs/2026-08-28-legacy-ui-migration-design.md §5(回填实测结论)

Interfaces:

  • Consumes: Task 7 产出的 ui-migration/intermediate/002-Login_Layer.json 与 atlas-login/ 下的散图

  • Produces: 一个可用的 prefab + spec §5 的实测结论

  • Step 1: 读取目标界面的中间描述

Run: cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node -e "const d=require('./ui-migration/intermediate/002-Login_Layer.json');console.log('层',d.name,'bucket',d.bucket,'节点',d.nodes.length,'分组',d.groups.length,'背景',d.backgroundNodeId);d.nodes.slice(0,3).forEach(n=>console.log(' ',n.id,n.name,n.kind,JSON.stringify(n.position),JSON.stringify(n.widget)))" Expected: 打印 Login_Layer、login、18 个节点及前三个节点的坐标与锚点。

  • Step 2: 用 MCP 建节点树

按中间描述建:

Login_Layer(根,1280×720,Widget 四边拉伸)
└─ group-<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:

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>"

验收标准(本计划完成定义)

  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 像素值;设计分辨率未变,故这些值仍然有效。