Files
youle_cocos/docs/superpowers/plans/2026-09-02-legacy-layer-migration.md
T
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

1338 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 初始化,然后:
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects/YouleNexus
npm install --save iconv-lite @xmldom/xmldom
```
期望:`package.json` 出现 `"iconv-lite"` 和 `"@xmldom/xmldom"` 条目。
- [ ] **Step 2: 创建目录 + 复制 fixtures**
```bash
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 骨架**
```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`:
```javascript
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**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node framework-tests/legacy-layer-migration/_smoke-iconv.mjs
```
期望:3 行输出,decoded length 在 5000-10000 之间。
- [ ] **Step 6: Commit**
```bash
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`:
```javascript
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: 跑测试看失败**
```bash
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` 头部:
```javascript
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: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/xml-parser.test.mjs
```
期望:3 个 test 全 PASS。
- [ ] **Step 5: Commit**
```bash
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: 写失败测试**
```javascript
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: 跑测试看失败**
```bash
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`:
```javascript
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: 跑测试看通过**
```bash
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**
```bash
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: 写失败测试**
```javascript
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: 跑测试看失败**
```bash
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**
追加:
```javascript
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: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/group-builder.test.mjs
```
期望:4 个 test PASS。
- [ ] **Step 5: Commit**
```bash
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 相关字符串**
```bash
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 语义**
```bash
# 找一个含 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`:
```javascript
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: 跑测试看失败**
```bash
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`:
```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: 跑测试看通过**
```bash
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**
```bash
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: 写失败测试**
```javascript
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: 跑测试看失败**
```bash
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**
追加:
```javascript
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: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/spirit-to-node.test.mjs
```
期望:6 个 test PASS。
- [ ] **Step 5: Commit**
```bash
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 结构**
```bash
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)**
```javascript
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: 跑测试看失败**
```bash
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 实现,伪代码:
```javascript
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: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/sprite-uuid-resolver.test.mjs
```
期望:2 个 test PASS。
- [ ] **Step 6: Commit**
```bash
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)**
```javascript
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: 跑测试看失败**
```bash
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(最小骨架)**
```javascript
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: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/prefab-emitter.test.mjs
```
期望:3 个 test PASS。
- [ ] **Step 5: Commit**
```bash
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`:
```javascript
#!/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 复杂度类似)。
```bash
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`:
```javascript
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: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/poc-iconbutton.test.mjs
```
期望:1 个 test PASS。
- [ ] **Step 5: 人工 diff 评审**
```bash
cat YouleNexus/out/legacy-migration/BackHall_Layer.prefab.diff
```
检查 diff 合理性:精灵节点位置/尺寸/SpriteFrame UUID 是否与 Layer00006.xml 一致。
- [ ] **Step 6: Commit**
```bash
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: 写失败测试**
```javascript
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: 跑测试看失败**
```bash
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` 追加:
```javascript
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: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --test framework-tests/legacy-layer-migration/poc-protocol.test.mjs
```
期望:1 个 test PASS。
- [ ] **Step 5: 人工 diff 评审**
```bash
cat YouleNexus/out/legacy-migration/Protol_Layer.prefab.diff
```
确认 Widget _alignFlags / _horizontalCenter / _verticalCenter 与 Layer00010.xml 几何一致。
- [ ] **Step 6: Commit**
```bash
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**
```bash
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`:
```javascript
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: 跑测试看通过**
```bash
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 完整评审**
```bash
cat YouleNexus/out/legacy-migration/Login_Layer.prefab.diff
```
按 diff 修复脚本(如某个 Spirit 类型错、Widget 计算不对、SpriteFrame UUID 缺失等)。重复 Step 1-3 直到 diff 接近零差异(除 fileId 等随机字段)。
- [ ] **Step 5: Commit**
```bash
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`:
```javascript
#!/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: 跑批量**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node scripts/run-legacy-migration-batch.mjs
```
期望:10 个 prefab 全部生成(含 Login_Layer 和 Layer619)+ 各自 .diff。
- [ ] **Step 3: 人工评审所有 .diff**
```bash
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 测试文件追加:
```javascript
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: 跑全套测试**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node scripts/run-framework-tests.mjs
```
期望:所有 test PASS。
- [ ] **Step 6: 最终 commit**
```bash
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?