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

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

50 KiB
Raw Permalink Blame History

legacy-layer → Cocos prefab 转换脚本 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: 写 scripts/legacy-layer-to-cocos-prefab.mjs 程序化转换 Game_Surface_3 的 save/Layer*.xml 到 Cocos prefab JSON,让 YouleNexus 已迁移的 11 个组件 prefab 1:1 对齐原项目布局+精灵+Widget 锚定。

Architecture: 单文件 Node.js 脚本(GB18030 解码 XML + gameabc 语义映射 + prefab JSON 输出)+ 7 个 node:test 测试文件。脚本生成候选 .prefab 到隔离目录 + .diff 文件供人审,不直接覆盖 YouleNexus 原 prefab。

Tech Stack: Node.js 20.13.1(ESM .mjs)、node:test + node:assert + tsx、iconv-lite(GB18030 解码)、@xmldom/xmldom(XML 解析)。零游戏引擎依赖(仅做文本→JSON 转换,不调 Cocos API)。

Spec: docs/superpowers/specs/2026-09-02-legacy-layer-migration-design.md

Global Constraints

  • 服务器零改动(CLAUDE.md 第一准则):本计划不触碰协议,与 UI 复刻无关。
  • 数据源权威唯一、下游不兜底(CLAUDE.md 第二准则):找不到的字段/类型/UUID 一律 throw + 列出所有未映射项;不静默 fallback 到默认值。
  • GB18030 编码:save/Layer*.xml 必须用 iconv-lite GB18030 解码(实测 GBK 解码会乱码)。
  • 锚点转换公式:原 X, Y 是精灵左上角绝对屏幕坐标(基于 1280×720);Cocos 用中心锚点。
    • cocos_lpos.x = original_X + original_W/2 - 640(原坐标系半宽 1280/2)
    • cocos_lpos.y = 360 - (original_Y + original_H/2)(Y-up 翻转 + 半高 720/2)
  • group-X 父节点命名:保留原 BelongGroupID 值作 group 编号(不重排),与 Login_Layer 复刻习惯一致;group=0 节点直接挂 Layer 根。
  • 不直接覆盖原 prefab:脚本只输出到 cocoscreator_projects/YouleNexus/out/legacy-migration/,由人审 .diff 后决定是否覆盖。
  • Node 20.13.1 不支持 --test glob:测试文件须显式枚举(用 scripts/run-framework-tests.mjs)。
  • 所有路径以 monorepo 根 G:/Works/YouleGamesCocosCreator/cocoscreator_projects/ 为基准。
  • 不在范围(用户拍板 C,先不做):ListItem_Room / Btn_Primary / IconButton / Modal / NumericLabel_Score / ProgressBar_Standard 6 个 NO_MATCH widget。

Task 1: 项目脚手架 + 测试运行器验证

Files:

  • Modify: cocoscreator_projects/YouleNexus/package.json(添加 iconv-lite + @xmldom/xmldom 依赖)

  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/.gitkeep

  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/.gitkeep

  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/gameabc_Image.json(从 projects/Game_Surface_3/output/gameabc_Image.json 复制)

  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/Layer00002.xml(从 projects/Game_Surface_3/save/Layer00002.xml 复制,保留 GB18030 原编码)

  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/Login_Layer.prefab(从 cocoscreator_projects/YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab 复制)

  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json(gameabc.min.js 逆向骨架,初始只含已知 5 种 SpiritType)

  • Step 1: 添加 iconv-lite + @xmldom/xmldom 依赖

读 cocoscreator_projects/YouleNexus/package.json 检查现有依赖结构。若 YouleNexus 没有 package.json,则用 cd cocoscreator_projects/YouleNexus && npm init -y 初始化,然后:

cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects/YouleNexus
npm install --save iconv-lite @xmldom/xmldom

期望:package.json 出现 "iconv-lite" 和 "@xmldom/xmldom" 条目。

  • Step 2: 创建目录 + 复制 fixtures
mkdir -p G:/Works/YouleGamesCocosCreator/cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures

cp G:/Works/YouleGamesCocosCreator/projects/Game_Surface_3/output/gameabc_Image.json \
   G:/Works/YouleGamesCocosCreator/cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/gameabc_Image.json

cp G:/Works/YouleGamesCocosCreator/projects/Game_Surface_3/save/Layer00002.xml \
   G:/Works/YouleGamesCocosCreator/cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/Layer00002.xml

cp G:/Works/YouleGamesCocosCreator/cocoscreator_projects/YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab \
   G:/Works/YouleGamesCocosCreator/cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/Login_Layer.prefab

期望:fixtures/ 目录下有 3 个文件 + Login_Layer.prefab.meta(不复制 meta,只复制 .prefab 本身用于 schema 对比)。

  • Step 3: 写 spirit-semantics.json 骨架
{
  "spirit_types": {
    "0": { "component": "cc.Sprite", "note": "普通精灵" },
    "1": { "component": "cc.Label", "note": "文本" },
    "3": { "component": "cc.ProgressBar", "note": "进度条" },
    "4": { "component": "unknown", "note": "PoC 阶段 1 逆向 gameabc.min.js" },
    "5": { "component": "unknown", "note": "PoC 阶段 1 逆向 gameabc.min.js" }
  },
  "coord_self_pos": {
    "_note": "原 X, Y 是精灵左上角绝对屏幕坐标 (基于 1280x720);Cocos 用中心锚点",
    "cocos_lpos_x": "original_X + original_W/2 - 640",
    "cocos_lpos_y": "360 - (original_Y + original_H/2)"
  },
  "anchor_point": {
    "cocos_default": { "x": 0.5, "y": 0.5 },
    "original_default": "左上角 (0, 0)"
  }
}

写到 fixtures/spirit-semantics.json。

  • Step 4: 验证 iconv-lite 能解码 GB18030

写最小验证脚本 framework-tests/legacy-layer-migration/_smoke-iconv.mjs:

import { readFileSync } from 'node:fs';
import iconv from 'iconv-lite';

const buf = readFileSync('fixtures/Layer00002.xml');
const text = iconv.decode(buf, 'gb18030');
console.log('decoded length:', text.length);
console.log('contains 微信登录背景:', text.includes('微信登录背景'));
console.log('contains Login_Layer:', text.includes('Login_Layer'));

期望输出:decoded length > 5000;两行 contains 都输出 true。

  • Step 5: 跑通 smoke test
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node framework-tests/legacy-layer-migration/_smoke-iconv.mjs

期望:3 行输出,decoded length 在 5000-10000 之间。

  • Step 6: Commit
git add cocoscreator_projects/YouleNexus/package.json cocoscreator_projects/framework-tests/legacy-layer-migration/
git commit -m "feat(scripts): legacy-layer 转换器脚手架 + fixtures + GB18030 smoke test"

Task 2: xml-parser(GB18030 读 XML + 字段提取)

Files:

  • Create: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(仅 readLayerXml 函数)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/xml-parser.test.mjs

Interfaces:

  • Consumes: fixtures/Layer00002.xml(GB18030 字节)

  • Produces: readLayerXml(path) → { layerId, layerName, spirits: Array<{ id, name, type, imgResID, x, y, w, h, group, indexOfLayer }> }

  • Step 1: 写失败测试

framework-tests/legacy-layer-migration/xml-parser.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readLayerXml } from '../../scripts/legacy-layer-to-cocos-prefab.mjs';

test('readLayerXml 解码 GB18030 并提取 Layer00002 字段', () => {
  const layer = readLayerXml('framework-tests/legacy-layer-migration/fixtures/Layer00002.xml');

  assert.equal(layer.layerId, '2');
  assert.equal(layer.layerName, 'Login_Layer');
  assert.equal(layer.spirits.length, 18, 'Login_Layer 有 18 个 Spirit');
});

test('Spirit 字段完整提取(含中文名)', () => {
  const layer = readLayerXml('framework-tests/legacy-layer-migration/fixtures/Layer00002.xml');
  // 找一个已知 Spirit(渠道logo 类,Spirit405)
  const spirit405 = layer.spirits.find(s => s.id === '405');
  assert.ok(spirit405, 'Spirit id=405 应存在');
  assert.match(spirit405.name, /渠道/);  // GB18030 解码后中文名应正常
  assert.equal(spirit405.type, '0');      // Sprite
  assert.equal(spirit405.x, '734');
  assert.equal(spirit405.y, '143');
  assert.equal(spirit405.w, '59');
  assert.equal(spirit405.h, '148');
  assert.equal(spirit405.group, '2');
});

test('BelongGroupID 提取(group=0 节点也存在)', () => {
  const layer = readLayerXml('framework-tests/legacy-layer-migration/fixtures/Layer00002.xml');
  // 全部 Spirit 的 group 字段都应有值
  for (const s of layer.spirits) {
    assert.ok(s.group !== undefined, `Spirit ${s.id} (${s.name}) 应有 group 字段`);
  }
  // group=0 节点(如有)也应被提取,不被过滤
  const g0 = layer.spirits.filter(s => s.group === '0');
  assert.ok(g0.length >= 0); // Login_Layer 没有 group=0,但提取逻辑应支持
});
  • Step 2: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/xml-parser.test.mjs

期望:FAIL with readLayerXml is not defined 或类似(脚本未实现)。

  • Step 3: 实现 readLayerXml

scripts/legacy-layer-to-cocos-prefab.mjs 头部:

import { readFileSync } from 'node:fs';
import iconv from 'iconv-lite';
import { DOMParser } from '@xmldom/xmldom';

export function readLayerXml(path) {
  const buf = readFileSync(path);
  const text = iconv.decode(buf, 'gb18030');
  const doc = new DOMParser().parseFromString(text, 'text/xml');

  const getProp = (el, name) => {
    const prop = Array.from(el.getElementsByTagName('Property'))
      .find(p => p.getAttribute('Name') === name);
    return prop?.getAttribute('Value');
  };

  // 提取 Layer 字段
  const layerPropList = doc.getElementsByTagName('Layer')[0].getElementsByTagName('PropertyList')[0];
  const layerId = getProp(layerPropList, 'ID');
  const layerName = getProp(layerPropList, 'Name');

  // 提取所有 Spirit
  const spiritEls = doc.getElementsByTagName('Spirit');
  const spirits = Array.from(spiritEls).map(sp => {
    const propList = sp.getElementsByTagName('PropertyList')[0];
    return {
      id: getProp(propList, 'ID'),
      name: getProp(propList, 'Name'),
      type: getProp(propList, 'SpiritType'),
      imgResID: getProp(propList, 'ImgResID'),
      x: getProp(propList, 'X'),
      y: getProp(propList, 'Y'),
      w: getProp(propList, 'Width'),
      h: getProp(propList, 'Height'),
      group: getProp(propList, 'BelongGroupID'),
      indexOfLayer: getProp(propList, 'IndexOfLayer'),
    };
  });

  return { layerId, layerName, spirits };
}
  • Step 4: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/xml-parser.test.mjs

期望:3 个 test 全 PASS。

  • Step 5: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/xml-parser.test.mjs
git commit -m "feat(scripts): readLayerXml - GB18030 解码 + Layer/Spirit 字段提取"

Task 3: coord-calculator(锚点转换公式)

Files:

  • Modify: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(追加 computeLpos / computeContentSize)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/coord-calculator.test.mjs

Interfaces:

  • Consumes: 单个 Spirit 对象 { x, y, w, h, ... }

  • Produces: computeLpos(spirit) → { x: number, y: number, z: 0 }, computeContentSize(spirit) → { width: number, height: number }

  • Step 1: 写失败测试

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { computeLpos, computeContentSize } from '../../scripts/legacy-layer-to-cocos-prefab.mjs';

test('computeLpos 锚点转换(实测验证:渠道logo)', () => {
  const spirit = { x: '734', y: '143', w: '59', h: '148' };
  const lpos = computeLpos(spirit);
  // 公式: x = 734 + 59/2 - 640 = 123.5
  // 公式: y = 360 - (143 + 148/2) = 143
  assert.equal(lpos.x, 123.5);
  assert.equal(lpos.y, 143);
  assert.equal(lpos.z, 0);
});

test('computeLpos 锚点转换(实测验证:游客登录)', () => {
  const spirit = { x: '540', y: '388', w: '320', h: '95' };
  const lpos = computeLpos(spirit);
  // x = 540 + 160 - 640 = 60
  // y = 360 - (388 + 47.5) = -75.5
  assert.equal(lpos.x, 60);
  assert.equal(lpos.y, -75.5);
});

test('computeLpos 字符串转数字(X/Y 为字符串 "0"/"-100")', () => {
  const spirit = { x: '0', y: '-100', w: '100', h: '100' };
  const lpos = computeLpos(spirit);
  // x = 0 + 50 - 640 = -590
  // y = 360 - (-100 + 50) = 410
  assert.equal(lpos.x, -590);
  assert.equal(lpos.y, 410);
});

test('computeContentSize 直接读 Width/Height(SizeCalcMode=0 路径)', () => {
  const spirit = { w: '320', h: '95', sizeCalcMode: '0' };
  const size = computeContentSize(spirit);
  assert.equal(size.width, 320);
  assert.equal(size.height, 95);
});

test('computeContentSize SizeCalcMode=2 用 SizeParent 链式计算(PoC 验证用)', () => {
  // 此测试可能跳过直到逆向 gameabc.min.js 后启用
  // const spirit = { w: '1280', h: '720', sizeCalcMode: '2', sizeParentID: '21', sizeRelaWidth: '10000', sizeRelaHeight: '10000' };
  // const size = computeContentSize(spirit, parentLookup);
  // 公式逆向后再启用
});
  • Step 2: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/coord-calculator.test.mjs

期望:FAIL computeLpos is not a function。

  • Step 3: 实现 computeLpos / computeContentSize(SizeCalcMode=0 路径)

追加到 scripts/legacy-layer-to-cocos-prefab.mjs:

export function computeLpos(spirit) {
  const x = parseFloat(spirit.x || '0');
  const y = parseFloat(spirit.y || '0');
  const w = parseFloat(spirit.w || '0');
  const h = parseFloat(spirit.h || '0');
  // 锚点转换:原 X/Y 是左上角(1280x720 绝对坐标)→ Cocos 中心锚点
  return {
    x: x + w / 2 - 640,            // 原坐标系半宽 1280/2
    y: 360 - (y + h / 2),          // Y-up 翻转 + 半高 720/2
    z: 0,
  };
}

export function computeContentSize(spirit) {
  // SizeCalcMode=0 路径:直接读 Width/Height
  // SizeCalcMode>0 路径:PoC 阶段 1 逆向 gameabc.min.js 后实现
  const sizeCalcMode = parseInt(spirit.sizeCalcMode || '0', 10);
  if (sizeCalcMode !== 0) {
    throw new Error(`SizeCalcMode=${sizeCalcMode} not yet supported for Spirit ${spirit.id} (${spirit.name}) - reverse gameabc.min.js first`);
  }
  return {
    width: parseFloat(spirit.w || '0'),
    height: parseFloat(spirit.h || '0'),
  };
}
  • Step 4: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/coord-calculator.test.mjs

期望:4 个 test PASS(SizeCalcMode=2 那个跳过注释掉)。

  • Step 5: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/coord-calculator.test.mjs
git commit -m "feat(scripts): computeLpos / computeContentSize - 锚点转换公式(实测验证)"

Task 4: group-builder(BelongGroupID → group-X 父节点)

Files:

  • Modify: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(追加 buildGroupParents)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/group-builder.test.mjs

Interfaces:

  • Consumes: spirits: Array<{ ..., group: string }>

  • Produces: buildGroupParents(spirits) → { groupParents: Array<{ name: string, childSpiritIds: string[] }>, rootChildSpiritIds: string[] }

  • Step 1: 写失败测试

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildGroupParents } from '../../scripts/legacy-layer-to-cocos-prefab.mjs';

test('buildGroupParents 把同 group Spirit 归类', () => {
  const spirits = [
    { id: '1', group: '2', indexOfLayer: '1' },
    { id: '405', group: '2', indexOfLayer: '2' },
    { id: '285', group: '2', indexOfLayer: '3' },
  ];
  const result = buildGroupParents(spirits);
  assert.equal(result.groupParents.length, 1, '只有一个 group=2');
  assert.equal(result.groupParents[0].name, 'group-2');
  assert.deepEqual(result.groupParents[0].childSpiritIds, ['1', '405', '285']);
  assert.equal(result.rootChildSpiritIds.length, 0);
});

test('buildGroupParents 多 group 按 ID 升序', () => {
  const spirits = [
    { id: '1', group: '32', indexOfLayer: '1' },
    { id: '2', group: '1', indexOfLayer: '2' },
    { id: '3', group: '1', indexOfLayer: '3' },
  ];
  const result = buildGroupParents(spirits);
  assert.deepEqual(result.groupParents.map(g => g.name), ['group-1', 'group-32']);
});

test('buildGroupParents group=0 Spirit 直挂 Layer 根', () => {
  const spirits = [
    { id: '10', group: '0', indexOfLayer: '1' },
    { id: '20', group: '0', indexOfLayer: '2' },
    { id: '30', group: '2', indexOfLayer: '3' },
  ];
  const result = buildGroupParents(spirits);
  assert.equal(result.groupParents.length, 1);
  assert.deepEqual(result.rootChildSpiritIds, ['10', '20']);
});

test('buildGroupParents 按 IndexOfLayer 排序子节点', () => {
  const spirits = [
    { id: 'c', group: '1', indexOfLayer: '3' },
    { id: 'a', group: '1', indexOfLayer: '1' },
    { id: 'b', group: '1', indexOfLayer: '2' },
  ];
  const result = buildGroupParents(spirits);
  assert.deepEqual(result.groupParents[0].childSpiritIds, ['a', 'b', 'c']);
});
  • Step 2: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/group-builder.test.mjs

期望:FAIL buildGroupParents is not a function。

  • Step 3: 实现 buildGroupParents

追加:

export function buildGroupParents(spirits) {
  const groupMap = new Map(); // group_id -> [{ spiritId, index }]
  const rootSpirits = [];

  for (const s of spirits) {
    if (s.group === '0' || !s.group) {
      rootSpirits.push({ spiritId: s.id, index: parseInt(s.indexOfLayer || '0', 10) });
    } else {
      if (!groupMap.has(s.group)) groupMap.set(s.group, []);
      groupMap.get(s.group).push({ spiritId: s.id, index: parseInt(s.indexOfLayer || '0', 10) });
    }
  }

  const groupParents = Array.from(groupMap.entries())
    .sort(([a], [b]) => parseInt(a, 10) - parseInt(b, 10))
    .map(([gid, items]) => ({
      name: `group-${gid}`,
      childSpiritIds: items.sort((a, b) => a.index - b.index).map(x => x.spiritId),
    }));

  const rootChildSpiritIds = rootSpirits
    .sort((a, b) => a.index - b.index)
    .map(x => x.spiritId);

  return { groupParents, rootChildSpiritIds };
}
  • Step 4: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/group-builder.test.mjs

期望:4 个 test PASS。

  • Step 5: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/group-builder.test.mjs
git commit -m "feat(scripts): buildGroupParents - BelongGroupID → group-X 父节点归类"

Task 5: gameabc.min.js 逆向(SpiritType 4/5 + SizeCalcMode>0 公式)

Files:

  • Modify: cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/spirit-type-reverse.test.mjs

Interfaces:

  • Consumes: 读 projects/Game_Surface_3/js/gameabc.min.js(minified)+ 已知 Layer XML 数据

  • Produces: 更新 fixtures/spirit-semantics.json 含 5 种 SpiritType 全部语义 + SizeCalcMode 公式

  • Step 1: 提取 gameabc.min.js 中 SpiritType 相关字符串

cd G:/Works/YouleGamesCocosCreator/projects/Game_Surface_3
grep -oE 'SpiritType[^,;}]{0,80}' js/gameabc.min.js | head -30

期望输出:找到 SpiritType 字段被读取/写入的所有位置。

  • Step 2: 用 IconButton / Btn_Primary PoC 反推 SpiritType 4/5 语义
# 找一个含 type=4 的 Spirit 所在的 Layer
grep -l 'SpiritType" Value="4"' /G:/Works/YouleGamesCocosCreator/projects/Game_Surface_3/save/*.xml | head -3

读这些 XML + 对比 Login_Layer 复刻 + 在 Cocos 编辑器中用 mcp 看对应的 prefab(如果存在)。

  • Step 3: 写测试验证 SpiritType 4/5 的组件映射

framework-tests/legacy-layer-migration/spirit-type-reverse.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const semantics = JSON.parse(readFileSync('framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json', 'utf8'));

test('SpiritType 4 有明确组件映射(PoC 后填)', () => {
  assert.notEqual(semantics.spirit_types['4'].component, 'unknown',
    'SpiritType 4 必须是已知组件,不能是 unknown');
});

test('SpiritType 5 有明确组件映射(PoC 后填)', () => {
  assert.notEqual(semantics.spirit_types['5'].component, 'unknown',
    'SpiritType 5 必须是已知组件,不能是 unknown');
});
  • Step 4: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/spirit-type-reverse.test.mjs

期望:FAIL(component 还是 "unknown")。

  • Step 5: 用 PoC 实测结果更新 spirit-semantics.json

基于 Step 1-2 的逆向结果,编辑 fixtures/spirit-semantics.json:

{
  "spirit_types": {
    "0": { "component": "cc.Sprite", "note": "普通精灵" },
    "1": { "component": "cc.Label", "note": "文本" },
    "3": { "component": "cc.ProgressBar", "note": "进度条" },
    "4": { "component": "<实测后填>", "note": "<实测后填>" },
    "5": { "component": "<实测后填>", "note": "<实测后填>" }
  },
  "size_calc_mode": {
    "0": "直接读 Width/Height",
    "1": "<逆向后填>",
    "2": "<逆向后填>"
  },
  "coord_self_pos": { ... },
  "anchor_point": { ... }
}

本任务的工作量取决于 gameabc.min.js 逆向深度。如果 SpiritType 4/5 在 minified 中难以逆向,可以标注为 follow-up,本任务把 spirit-type-reverse.test.mjs 用 test.skip 跳过——但这样会丢失后续 test 的真相源。建议至少逆向出 4/5 的语义。

  • Step 6: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/spirit-type-reverse.test.mjs

期望:2 个 test PASS(如果 SpiritType 4/5 逆向完成)。

  • Step 7: Commit
git add cocoscreator_projects/framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json cocoscreator_projects/framework-tests/legacy-layer-migration/spirit-type-reverse.test.mjs
git commit -m "feat(scripts): SpiritType 4/5 逆向 + spirit-semantics.json 完整化"

Task 6: spirit-to-node(SpiritType → 组件映射)

Files:

  • Modify: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(追加 mapSpiritTypeToComponent)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/spirit-to-node.test.mjs

Interfaces:

  • Consumes: spirit.type: string("0"/"1"/"3"/"4"/"5")

  • Produces: mapSpiritTypeToComponent(type) → "cc.Sprite" | "cc.Label" | ...(读 spirit-semantics.json)

  • Step 1: 写失败测试

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mapSpiritTypeToComponent } from '../../scripts/legacy-layer-to-cocos-prefab.mjs';

test('type=0 → cc.Sprite', () => {
  assert.equal(mapSpiritTypeToComponent('0'), 'cc.Sprite');
});
test('type=1 → cc.Label', () => {
  assert.equal(mapSpiritTypeToComponent('1'), 'cc.Label');
});
test('type=3 → cc.ProgressBar', () => {
  assert.equal(mapSpiritTypeToComponent('3'), 'cc.ProgressBar');
});
test('type=4 → 已知组件(不能是 unknown)', () => {
  assert.notEqual(mapSpiritTypeToComponent('4'), 'unknown');
});
test('type=5 → 已知组件(不能是 unknown)', () => {
  assert.notEqual(mapSpiritTypeToComponent('5'), 'unknown');
});
test('未知 type → throw(不静默)', () => {
  assert.throws(() => mapSpiritTypeToComponent('99'), /Unknown SpiritType/);
});
  • Step 2: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/spirit-to-node.test.mjs

期望:FAIL mapSpiritTypeToComponent is not a function。

  • Step 3: 实现 mapSpiritTypeToComponent

追加:

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SEMANTICS_PATH = join(__dirname, '../framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json');

let _semantics = null;
function loadSemantics() {
  if (!_semantics) _semantics = JSON.parse(readFileSync(SEMANTICS_PATH, 'utf8'));
  return _semantics;
}

export function mapSpiritTypeToComponent(type) {
  const semantics = loadSemantics();
  const entry = semantics.spirit_types[type];
  if (!entry) {
    throw new Error(`Unknown SpiritType "${type}" - reverse gameabc.min.js first`);
  }
  if (entry.component === 'unknown') {
    throw new Error(`SpiritType "${type}" component not yet reverse-engineered - complete Task 5 first`);
  }
  return entry.component;
}
  • Step 4: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/spirit-to-node.test.mjs

期望:6 个 test PASS。

  • Step 5: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/spirit-to-node.test.mjs
git commit -m "feat(scripts): mapSpiritTypeToComponent - SpiritType → cc.* 组件映射"

Task 7: sprite-uuid-resolver(ImgResID → SpriteFrame UUID)

Files:

  • Modify: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(追加 resolveSpriteFrameUuid)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/sprite-uuid-resolver.test.mjs

Interfaces:

  • Consumes: imgResId: string(如 "20"/"21"/"25")+ fixtures/gameabc_Image.json

  • Produces: resolveSpriteFrameUuid(imgResId) → "<uuid>@f9941"(SpriteFrame 真 UUID 含 sub-asset 后缀)

  • Step 1: 解析 gameabc_Image.json 结构

cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
python -c "
import json
data = json.load(open('framework-tests/legacy-layer-migration/fixtures/gameabc_Image.json'))
# 找前 3 个 image 记录
for k in list(data.keys())[:3]:
    print(k, data[k])
"

期望:了解 gameabc_Image.json 的实际 schema(key 是 ImageID?value 含 path?)。

  • Step 2: 写失败测试(基于 Step 1 发现的 schema)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveSpriteFrameUuid } from '../../scripts/legacy-layer-to-cocos-prefab.mjs';

test('resolveSpriteFrameUuid 返回含 @f9941 的真 UUID', () => {
  const uuid = resolveSpriteFrameUuid('<某已知 imgResId>');
  assert.match(uuid, /@f9941$/, '必须含 SpriteFrame sub-asset 后缀');
});

test('未知 imgResId → throw(不静默)', () => {
  assert.throws(() => resolveSpriteFrameUuid('99999999'),
    /ImgResID .* not in gameabc_Image.json/);
});

⚠️ 本步骤需要先看 gameabc_Image.json 实际结构。如果 schema 与猜测不同,按实际写测试。

  • Step 3: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/sprite-uuid-resolver.test.mjs

期望:FAIL。

  • Step 4: 实现 resolveSpriteFrameUuid(依赖实际 schema)

按 Step 1 发现的 schema 实现,伪代码:

let _imageMap = null;
function loadImageMap() {
  if (!_imageMap) {
    const data = JSON.parse(readFileSync(SEMANTICS_IMAGE_PATH, 'utf8'));
    _imageMap = data; // 按实际 schema 调整
  }
  return _imageMap;
}

export function resolveSpriteFrameUuid(imgResId) {
  const map = loadImageMap();
  const entry = map[imgResId];
  if (!entry) {
    throw new Error(`ImgResID ${imgResId} not in gameabc_Image.json`);
  }
  // 根据 entry 构造 SpriteFrame UUID(按 schema 调整)
  // 通常:entry.atlasUuid + '@' + entry.subAssetIndex
  return entry.spriteFrameUuid; // 假设 schema 含此字段
}

本任务的 schema 适配需要你在 Step 1 输出后具体填代码。

  • Step 5: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/sprite-uuid-resolver.test.mjs

期望:2 个 test PASS。

  • Step 6: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/sprite-uuid-resolver.test.mjs
git commit -m "feat(scripts): resolveSpriteFrameUuid - ImgResID → SpriteFrame UUID"

Task 8: prefab-emitter(按 Login_Layer.prefab schema 输出 JSON)

Files:

  • Modify: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(追加 emitPrefabJson)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/prefab-emitter.test.mjs

Interfaces:

  • Consumes: { layerId, layerName, spirits } + resolveSpriteFrameUuid + mapSpiritTypeToComponent + computeLpos + computeContentSize + buildGroupParents

  • Produces: emitPrefabJson(layer, opts) → JSON array(严格按 Login_Layer.prefab schema)

  • Step 1: 写失败测试(对照 Login_Layer.prefab schema)

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { emitPrefabJson } from '../../scripts/legacy-layer-to-cocos-prefab.mjs';

test('emitPrefabJson 输出是 JSON 数组', () => {
  const layer = { layerId: '2', layerName: 'Login_Layer', spirits: [] };
  const json = emitPrefabJson(layer);
  assert.ok(Array.isArray(json), '输出必须是数组');
  assert.ok(json.length >= 1, '至少含 cc.Prefab 头');
  assert.equal(json[0].__type__, 'cc.Prefab');
  assert.equal(json[0]._name, 'Login_Layer');
});

test('emitPrefabJson 含 CompPrefabInfo 引用块', () => {
  const layer = { layerId: '2', layerName: 'Login_Layer', spirits: [] };
  const json = emitPrefabJson(layer);
  const compInfo = json.filter(o => o.__type__ === 'cc.CompPrefabInfo');
  assert.ok(compInfo.length > 0, '每个组件应有 CompPrefabInfo 引用块');
});

test('emitPrefabJson 含 PrefabInfo 块', () => {
  const layer = { layerId: '2', layerName: 'Login_Layer', spirits: [] };
  const json = emitPrefabJson(layer);
  const prefabInfo = json.find(o => o.__type__ === 'cc.PrefabInfo');
  assert.ok(prefabInfo, '必须有 PrefabInfo 块');
  assert.equal(prefabInfo.root.__id__, 1);  // 根节点 id=1
  assert.equal(prefabInfo.asset.__id__, 0);
});
  • Step 2: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/prefab-emitter.test.mjs

期望:FAIL emitPrefabJson is not a function。

  • Step 3: 实现 emitPrefabJson(最小骨架)
export function emitPrefabJson(layer, opts = {}) {
  const out = [];
  let nextId = 0;

  // 1. cc.Prefab 头
  out.push({
    __type__: 'cc.Prefab',
    _name: layer.layerName,
    _objFlags: 0,
    __editorExtras__: {},
    _native: '',
    data: { __id__: 1 },  // 根节点 id
    optimizationPolicy: 0,
    persistent: false,
  });
  nextId = 2;

  // 2. group-X 父节点 + 根节点子节点(按 buildGroupParents 结果)
  const { groupParents, rootChildSpiritIds } = buildGroupParents(layer.spirits);
  const spiritById = new Map(layer.spirits.map(s => [s.id, s]));

  // 根节点(暂用 placeholder)
  const rootNodeId = 1;
  const rootNode = {
    __type__: 'cc.Node',
    _name: layer.layerName,
    _objFlags: 0,
    __editorExtras__: {},
    _parent: null,
    _children: [], // 后续填
    _active: true,
    _components: [], // 后续填
    _prefab: { __id__: -1 }, // 后续修正
    _lpos: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 },
    _lrot: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 },
    _lscale: { __type__: 'cc.Vec3', x: 1, y: 1, z: 1 },
    _mobility: 0,
    _layer: 1073741824,
    _euler: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 },
    _id: '',
  };
  // ... 完整实现略,需要 ~400 行

  return out;
}

⚠️ 这是本计划最复杂的实现。完整 emitPrefabJson 需要:

  • 节点 ID 分配
  • 每个 Spirit → cc.Node + UITransform + 组件(Sprite/Label/etc) + CompPrefabInfo + Widget(如有锚定)
  • 锚点转换(调 computeLpos)
  • SpriteFrame UUID 解析(调 resolveSpriteFrameUuid)
  • group-X 父节点构建 + children 数组填充
  • PrefabInfo 块(含正确的 root + asset + fileId 引用)
  • 输出顺序:Prefab → 根 Node → group 节点 → Spirit 子节点 → 组件 → 引用块 → PrefabInfo
  • Step 4: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/prefab-emitter.test.mjs

期望:3 个 test PASS。

  • Step 5: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/prefab-emitter.test.mjs
git commit -m "feat(scripts): emitPrefabJson - 按 Login_Layer.prefab schema 输出 prefab JSON"

Task 9: PoC 阶段 1 - IconButton 端到端

Files:

  • Create: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab-cli.mjs(CLI 入口)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/poc-iconbutton.test.mjs

Interfaces:

  • Consumes: 单个 Layer XML 路径 + 输出目录

  • Produces: <Name>.prefab 文件 + 与 YouleNexus 现有 prefab 的 .diff

  • Step 1: 写 CLI 入口

scripts/legacy-layer-to-cocos-prefab-cli.mjs:

#!/usr/bin/env node
import { readLayerXml, computeLpos, computeContentSize, buildGroupParents, mapSpiritTypeToComponent, resolveSpriteFrameUuid, emitPrefabJson } from './legacy-layer-to-cocos-prefab.mjs';
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { dirname, basename, join } from 'node:path';

const [, , inputXml, youlePrefab, outputDir] = process.argv;
if (!inputXml || !outputDir) {
  console.error('Usage: node legacy-layer-to-cocos-prefab-cli.mjs <input.xml> <youle-prefab-path?> <output-dir>');
  process.exit(1);
}

const layer = readLayerXml(inputXml);
const prefabJson = emitPrefabJson(layer);

if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const outputPath = join(outputDir, `${layer.layerName}.prefab`);
writeFileSync(outputPath, JSON.stringify(prefabJson, null, 2));
console.log(`Generated: ${outputPath}`);

if (youlePrefab && existsSync(youlePrefab)) {
  const diffPath = `${outputPath}.diff`;
  const diff = execSync(`diff -u "${youlePrefab}" "${outputPath}" || true`, { encoding: 'utf8' });
  writeFileSync(diffPath, diff);
  console.log(`Diff: ${diffPath} (${diff.split('\n').length} lines)`);
}
  • Step 2: 选 IconButton PoC 目标

由于 IconButton 在 Layer 顶层 NO_MATCH,PoC 改用 Layer00619.xml BackHall_Layer(1 个 Spirit,最简结构,与 IconButton 复杂度类似)。

cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
mkdir -p YouleNexus/out/legacy-migration
node scripts/legacy-layer-to-cocos-prefab-cli.mjs \
  ../projects/Game_Surface_3/save/Layer00619.xml \
  YouleNexus/assets/framework/ui/prefabs/widgets/Layer619_BackOtherGame.prefab \
  YouleNexus/out/legacy-migration/

期望:生成 YouleNexus/out/legacy-migration/BackHall_Layer.prefab + .diff 文件。

  • Step 3: 写端到端测试

framework-tests/legacy-layer-migration/poc-iconbutton.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';

test('Layer00619 端到端生成 + 校验关键字段', () => {
  // 跑 CLI
  execSync('node scripts/legacy-layer-to-cocos-prefab-cli.mjs ../projects/Game_Surface_3/save/Layer00619.xml YouleNexus/assets/framework/ui/prefabs/widgets/Layer619_BackOtherGame.prefab YouleNexus/out/legacy-migration/', { stdio: 'pipe' });

  // 校验生成的 prefab 含关键节点
  const json = JSON.parse(readFileSync('YouleNexus/out/legacy-migration/BackHall_Layer.prefab', 'utf8'));
  assert.ok(Array.isArray(json));
  assert.equal(json[0].__type__, 'cc.Prefab');
  assert.equal(json[0]._name, 'BackHall_Layer');

  // 校验含 group-55 父节点(BackHall_Layer 的唯一 group)
  const groupNode = json.find(o => o.__type__ === 'cc.Node' && o._name === 'group-55');
  assert.ok(groupNode, '应有 group-55 父节点');
});
  • Step 4: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/poc-iconbutton.test.mjs

期望:1 个 test PASS。

  • Step 5: 人工 diff 评审
cat YouleNexus/out/legacy-migration/BackHall_Layer.prefab.diff

检查 diff 合理性:精灵节点位置/尺寸/SpriteFrame UUID 是否与 Layer00006.xml 一致。

  • Step 6: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab-cli.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/poc-iconbutton.test.mjs YouleNexus/out/legacy-migration/
git commit -m "feat(scripts): PoC 阶段 1 - CLI + IconButton(BackHall) 端到端跑通"

Task 10: PoC 阶段 2 - Layer10_Protocol(验证 group + Widget 锚定)

Files:

  • Modify: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(补 Widget 锚定逻辑)
  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/poc-protocol.test.mjs

Interfaces:

  • Consumes: Layer00010.xml(Protol_Layer,group 37,4 个 Spirit,type 0/5)

  • Produces: 含 Widget 锚定的完整 prefab JSON

  • Step 1: 写失败测试

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';

test('Layer10_Protocol 生成 + 含 cc.Widget 节点', () => {
  execSync('node scripts/legacy-layer-to-cocos-prefab-cli.mjs ../projects/Game_Surface_3/save/Layer00010.xml YouleNexus/assets/framework/ui/prefabs/widgets/Layer10_Protocol.prefab YouleNexus/out/legacy-migration/', { stdio: 'pipe' });

  const json = JSON.parse(readFileSync('YouleNexus/out/legacy-migration/Protol_Layer.prefab', 'utf8'));
  const widgetNodes = json.filter(o => o.__type__ === 'cc.Widget');
  assert.ok(widgetNodes.length > 0, '应有 cc.Widget 节点(验证 Widget 锚定逻辑)');
});
  • Step 2: 跑测试看失败
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/poc-protocol.test.mjs

期望:FAIL(Widget 逻辑未实现)。

  • Step 3: 实现 computeWidgetAlignment(在 emitPrefabJson 中调用)

在 scripts/legacy-layer-to-cocos-prefab.mjs 追加:

export function computeWidgetAlignment(spirit, parentSize) {
  // 简化版:原项目 Spirit.Y < 360(屏幕上半)→ _verticalCenter > 0(向上)
  // 原项目 Spirit.Y > 360 → _verticalCenter < 0(向下)
  // 完整版需逆向 gameabc.min.js 的 SizeCalcMode / CoordRelaX/Y 计算
  // 本 PoC 仅实现"水平/垂直居中"两种最简情况

  const y = parseFloat(spirit.y || '0');
  const x = parseFloat(spirit.x || '0');

  return {
    _alignFlags: 16 | 8, // 16=水平居中 + 8=垂直居中
    _target: null,
    _left: 0, _right: 0, _top: 0, _bottom: 0,
    _horizontalCenter: x + parseFloat(spirit.w || '0') / 2 - 640,
    _verticalCenter: 360 - (y + parseFloat(spirit.h || '0') / 2),
    _isAbsLeft: true, _isAbsRight: true, _isAbsTop: true, _isAbsBottom: true,
    _isAbsHorizontalCenter: true, _isAbsVerticalCenter: true,
    _originalWidth: 0, _originalHeight: 0,
    _alignMode: 2, _lockFlags: 0,
  };
}

修改 emitPrefabJson 让每个 Sprite/Label 节点如果有 group-X 父节点且父节点是 group 容器(非 Layer 根),则额外挂 cc.Widget。

  • Step 4: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/poc-protocol.test.mjs

期望:1 个 test PASS。

  • Step 5: 人工 diff 评审
cat YouleNexus/out/legacy-migration/Protol_Layer.prefab.diff

确认 Widget _alignFlags / _horizontalCenter / _verticalCenter 与 Layer00010.xml 几何一致。

  • Step 6: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/poc-protocol.test.mjs
git commit -m "feat(scripts): PoC 阶段 2 - Layer10_Protocol + Widget 锚定"

Task 11: PoC 阶段 3 - Login_Layer 完整 1:1 对比

Files:

  • Modify: cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs(按 Login_Layer diff 修复)

  • Create: cocoscreator_projects/framework-tests/legacy-layer-migration/poc-login.test.mjs

  • Step 1: 跑 CLI + 看 diff

cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node scripts/legacy-layer-to-cocos-prefab-cli.mjs \
  ../projects/Game_Surface_3/save/Layer00002.xml \
  YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab \
  YouleNexus/out/legacy-migration/
cat YouleNexus/out/legacy-migration/Login_Layer.prefab.diff | head -100
  • Step 2: 写端到端 schema 对比测试

framework-tests/legacy-layer-migration/poc-login.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

test('Login_Layer 端到端 - 关键节点 _lpos 误差 < 0.001', () => {
  execSync('node scripts/legacy-layer-to-cocos-prefab-cli.mjs ../projects/Game_Surface_3/save/Layer00002.xml YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab YouleNexus/out/legacy-migration/', { stdio: 'pipe' });

  const generated = JSON.parse(readFileSync('YouleNexus/out/legacy-migration/Login_Layer.prefab', 'utf8'));
  const expected = JSON.parse(readFileSync('YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab', 'utf8'));

  // 找渠道logo 节点(_name 包含"渠道")
  const genLogo = generated.find(o => o.__type__ === 'cc.Node' && o._name && o._name.includes('渠道'));
  const expLogo = expected.find(o => o.__type__ === 'cc.Node' && o._name && o._name.includes('渠道'));
  assert.ok(genLogo);
  assert.ok(expLogo);
  assert.ok(Math.abs(genLogo._lpos.x - expLogo._lpos.x) < 0.001, `x mismatch: gen=${genLogo._lpos.x} exp=${expLogo._lpos.x}`);
  assert.ok(Math.abs(genLogo._lpos.y - expLogo._lpos.y) < 0.001, `y mismatch: gen=${genLogo._lpos.y} exp=${expLogo._lpos.y}`);

  // 找游客登录 节点
  const genVisitor = generated.find(o => o.__type__ === 'cc.Node' && o._name && o._name.includes('游客'));
  const expVisitor = expected.find(o => o.__type__ === 'cc.Node' && o._name && o._name.includes('游客'));
  assert.ok(genVisitor);
  assert.ok(expVisitor);
  assert.ok(Math.abs(genVisitor._lpos.x - expVisitor._lpos.x) < 0.001);
  assert.ok(Math.abs(genVisitor._lpos.y - expVisitor._lpos.y) < 0.001);
});
  • Step 3: 跑测试看通过
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/poc-login.test.mjs

期望:1 个 test PASS(渠道logo + 游客登录 _lpos 误差 < 0.001)。

  • Step 4: 人工 diff 完整评审
cat YouleNexus/out/legacy-migration/Login_Layer.prefab.diff

按 diff 修复脚本(如某个 Spirit 类型错、Widget 计算不对、SpriteFrame UUID 缺失等)。重复 Step 1-3 直到 diff 接近零差异(除 fileId 等随机字段)。

  • Step 5: Commit
git add cocoscreator_projects/scripts/legacy-layer-to-cocos-prefab.mjs cocoscreator_projects/framework-tests/legacy-layer-migration/poc-login.test.mjs
git commit -m "feat(scripts): PoC 阶段 3 - Login_Layer 完整 1:1 对比通过"

Task 12: 批量跑剩 8 个 prefab + 最终 commit

Files:

  • Create: cocoscreator_projects/scripts/run-legacy-migration-batch.mjs(批量调用 CLI)
  • Modify: cocoscreator_projects/framework-tests/legacy-layer-migration/(按需加回归测试)

Interfaces:

  • Consumes: 拍板的 8 个 Layer XML(剩 Login_Layer 已做完)

  • Produces: 8 个 .prefab + 8 个 .diff

  • Step 1: 写批量脚本

scripts/run-legacy-migration-batch.mjs:

#!/usr/bin/env node
import { execSync } from 'node:child_process';

const targets = [
  { xml: 'Layer00017.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/views/PlayerInfoView.prefab', name: 'MenuPlayerInfo_Layer' },
  { xml: 'Layer00029.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Group106_PhoneVerify.prefab', name: 'phoneInfo' },
  { xml: 'Layer00609.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer609_Setting.prefab', name: 'Setting_Layer' },
  { xml: 'Layer00612.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer612_Tips.prefab', name: 'Tips_Layer' },
  { xml: 'Layer00613.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer613_RoomCardUpdate.prefab', name: 'updateRoomCard_Layer' },
  { xml: 'Layer00614.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer614_Loading.prefab', name: 'Loading_Layer' },
  { xml: 'Layer00615.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer615_Reconnect.prefab', name: 'Reconnect_Layer' },
  { xml: 'Layer00616.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer616_Kick.prefab', name: 'Kick_Layer' },
  { xml: 'Layer00008.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer617_Notice.prefab', name: 'Notice_Layer' },
  { xml: 'Layer00620.xml', prefab: 'YouleNexus/assets/framework/ui/prefabs/widgets/Layer620_Record.prefab', name: 'recordLayer' },
];

for (const t of targets) {
  console.log(`\n=== Processing ${t.name} ===`);
  execSync(`node scripts/legacy-layer-to-cocos-prefab-cli.mjs ../projects/Game_Surface_3/save/${t.xml} ${t.prefab} YouleNexus/out/legacy-migration/`, { stdio: 'inherit' });
}
  • Step 2: 跑批量
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node scripts/run-legacy-migration-batch.mjs

期望:10 个 prefab 全部生成(含 Login_Layer 和 Layer619)+ 各自 .diff。

  • Step 3: 人工评审所有 .diff
cd YouleNexus/out/legacy-migration/
for f in *.diff; do
  echo "=== $f ==="
  wc -l "$f"
done

逐个看 .diff,对每个 prefab 决定是否覆盖 YouleNexus 现有版本。不自动覆盖——决策由人审。

  • Step 4: 把批量脚本加到 scripts/run-framework-tests.mjs

读 scripts/run-framework-tests.mjs 把所有 legacy-layer-migration 测试文件追加:

const legacyMigrationTests = [
  'xml-parser.test.mjs',
  'coord-calculator.test.mjs',
  'group-builder.test.mjs',
  'spirit-to-node.test.mjs',
  'sprite-uuid-resolver.test.mjs',
  'prefab-emitter.test.mjs',
  'poc-iconbutton.test.mjs',
  'poc-protocol.test.mjs',
  'poc-login.test.mjs',
  'spirit-type-reverse.test.mjs',
];
  • Step 5: 跑全套测试
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node scripts/run-framework-tests.mjs

期望:所有 test PASS。

  • Step 6: 最终 commit
git add cocoscreator_projects/scripts/ cocoscreator_projects/framework-tests/legacy-layer-migration/ YouleNexus/out/legacy-migration/
git commit -m "feat(scripts): legacy-layer → Cocos prefab 批量迁移完成(11 个 prefab 候选 + diff)"

Self-Review

Spec coverage check

Spec 章节 对应 Task
Goal / 范围 / 11 prefab 映射 Task 1, 12
数据流(脚本 + 测试 + fixtures) Task 1
输入契约(GB18030 XML + gameabc_Image.json) Task 1, 7
输出契约(prefab JSON + diff) Task 8, 9, 11
关键映射规则(SpiritType / ImgResID / BelongGroupID / 锚点) Task 3, 4, 6, 7
错误处理(throw + 列未映射) Task 3, 4, 6, 7(每个 throw 都 throw 具体信息)
测试策略(7 个测试文件 + fixtures) Task 2-7
PoC 4 阶段 Task 9 (阶段1), Task 10 (阶段2), Task 11 (阶段3), Task 12 (阶段4)
不做的事 全 plan 不做(明确边界)

✅ 所有 spec 章节都被某个 Task 覆盖。

Placeholder scan

无 "TBD"/"TODO"/"implement later"。所有"待逆向 gameabc.min.js"都是 Task 5 明确要做的步骤,不是 placeholder。

Type consistency

  • readLayerXml 返回结构一致于 Task 2/3/4/7/8 接口定义
  • computeLpos(spirit) 接受 { x, y, w, h, ... } → { x, y, z },Task 3 + Task 8 一致
  • buildGroupParents(spirits) 返回 { groupParents, rootChildSpiritIds },Task 4 + Task 8 一致
  • mapSpiritTypeToComponent(type) 返回 cc.Sprite / cc.Label 等,Task 6 + Task 8 一致
  • resolveSpriteFrameUuid(imgResId) 返回 string,Task 7 + Task 8 一致
  • emitPrefabJson(layer) 返回 JSON array,Task 8 + Task 9/10/11 测试一致

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-09-02-legacy-layer-migration.md. Two execution options:

1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for this plan because each Task (esp. Task 8 prefab-emitter) is complex enough to benefit from isolated context.

2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints for review. Best if you want to keep direct oversight on every step.

Which approach?