主要改动: - 切到 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>
1338 lines
50 KiB
Markdown
1338 lines
50 KiB
Markdown
# 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?
|