# legacy-layer → Cocos prefab 转换脚本设计 > **Status**: Draft v1 (brainstorming approved, awaiting user spec review) > **Date**: 2026-09-02 > **Spec for**: `scripts/legacy-layer-to-cocos-prefab.mjs` + 测试套件 ## Goal 把 `projects/Game_Surface_3/save/Layer*.xml`(GB18030 编码 XML)+ `output/gameabc_*.json` 的结构化 UI 数据,**程序化转换为 Cocos prefab JSON**,让 YouleNexus 已迁移的 ~17 个组件 prefab(widgets/ + views/ + Login_Layer)能 1:1 对齐原项目 Game_Surface_3 的布局+精灵+Widget 锚定。 **成功标准**:脚本生成的 prefab 加载到 Cocos 编辑器 0 报错,节点树/SpriteFrame/Widget 锚定与原 Layer XML 字段语义一致。 ## 范围(本设计覆盖) ### 已核实映射表(2026-09-02 全量 XML 解析 + 用户拍板) | 目标 prefab(YouleNexus) | 对应 Layer XML | Layer 名称 | Group | Spirit 数 | 备注 | |---|---|---|---|---|---| | `prefabs/Login_Layer.prefab` | Layer00002.xml | Login_Layer | 2 | 18 | 已有完整复刻,端到端 PoC 验证 | | `prefabs/views/PlayerInfoView.prefab` | **Layer00017.xml** | **MenuPlayerInfo_Layer** | **18** | 15 | **用户拍板 A** | | `prefabs/widgets/Group106_PhoneVerify.prefab` | Layer00029.xml | phoneInfo | 106, 108 | 38 | | | `prefabs/widgets/Layer10_Protocol.prefab` | Layer00010.xml | Protol_Layer | 37 | 4 | **PoC 阶段 2**(含 Widget 锚定) | | `prefabs/widgets/Layer609_Setting.prefab` | Layer00609.xml | Setting_Layer | 6 | 15 | | | `prefabs/widgets/Layer612_Tips.prefab` | Layer00612.xml | Tips_Layer | 32, 89 | 5 | | | `prefabs/widgets/Layer613_RoomCardUpdate.prefab` | Layer00613.xml | updateRoomCard_Layer | 34 | 3 | | | `prefabs/widgets/Layer614_Loading.prefab` | Layer00614.xml | Loading_Layer | 40 | 2 | | | `prefabs/widgets/Layer615_Reconnect.prefab` | Layer00615.xml | Reconnect_Layer | 35 | 4 | | | `prefabs/widgets/Layer616_Kick.prefab` | Layer00616.xml | Kick_Layer | 33 | 3 | | | `prefabs/widgets/Layer617_Notice.prefab` | **Layer00008.xml** | **Notice_Layer** | **3, 95** | **9** | **用户拍板 B** | | `prefabs/widgets/Layer619_BackOtherGame.prefab` | Layer00619.xml | BackHall_Layer | 55 | 1 | Layer 名 BackHall ≠ BackOtherGame,但 group 唯一匹配 | | `prefabs/widgets/Layer620_Record.prefab` | Layer00620.xml | recordLayer | 101 | 3 | | **总计:11 个 prefab 全部映射到明确 Layer XML**(不含 6 个 NO_MATCH widget) ### 范围外(**用户拍板 C:先不做**) | 目标 prefab | 原因 | |---|---| | `prefabs/templates/ListItem_Room.prefab` | 跨 Layer 复用的 widget 子模板 / YouleNexus 新加,未在 save/Layer*.xml 中找到 | | `prefabs/widgets/Btn_Primary.prefab` | 通用按钮 widget,YouleNexus 新加 | | `prefabs/widgets/IconButton.prefab` | 通用图标按钮 widget,YouleNexus 新加 | | `prefabs/widgets/Modal.prefab` | 通用弹窗 widget,YouleNexus 新加 | | `prefabs/widgets/NumericLabel_Score.prefab` | 数字滚动 Label widget,YouleNexus 新加 | | `prefabs/widgets/ProgressBar_Standard.prefab` | 通用进度条 widget,YouleNexus 新加 | ### 事实源 完整 Layer 解析数据见 `docs/superpowers/data/layer-spirit-summary.json`(68 个 Layer 全量、81 个 group、5 种 SpiritType 分布)。 ### 未来扩展(如需) 如果要补做 6 个 NO_MATCH widget,需先在 `scripts/legacy-layer-to-cocos-prefab.mjs` 加"跨 Layer Spirit 提取"能力(按 Sprite name / Layer 内 widget 模式匹配),不在本设计范围。 **不在范围**: - 未迁移的 68 个 Layer 中的其余 ~50 个 - 动画(Spirit 的 `ani_*` 属性 / Spine / DragonBones) - 多语言文本 - 协议层字段 ## Architecture ### 数据流 ``` projects/Game_Surface_3/save/Layer*.xml ─┐ projects/Game_Surface_3/output/ ─┼→ [legacy-layer-to-cocos-prefab.mjs] → out/legacy-migration/ gameabc_Image.json (sprite 映射) │ ├─ .prefab (UTF-8 JSON 数组) gameabc_Project.json (适配元数据) │ └─ .diff (vs YouleNexus 现有) projects/Game_Surface_3/js/ ─┘ gameabc.min.js (语义来源,需逆向) ``` ### 模块划分(单文件脚本 + 7 个测试) | 模块 | 职责 | |---|---| | `readLayerXml(path)` | GB18030 编码读 XML → JS 对象 | | `resolveSpriteFrameUuid(imgResId)` | 查 gameabc_Image.json → atlas SpriteFrame 真 UUID(含 @f9941) | | `buildGroupParents(spirits)` | 按 BelongGroupID 分组 → group-X 父节点骨架 | | `mapSpiritTypeToComponent(type)` | SpiritType → 组件类型枚举(逆向 gameabc.min.js) | | `computeLpos(spirit, parentGroup)` | CoordSelfPos + CoordRelaX/Y → _lpos {x,y,z} | | `computeContentSize(spirit)` | SizeCalcMode + SizeRelaWidth/Height + Width/HeightCalcMode → _contentSize {width,height} | | `computeWidgetAlignment(spirit)` | SizeCalcMode + 父 group 关系 → cc.Widget._alignFlags + _horizontalCenter 等 | | `emitPrefabJson(name, group, root)` | 按 Login_Layer.prefab schema 输出 JSON 数组(含 CompPrefabInfo / PrefabInfo) | | `diffAgainstExisting(generated, existing)` | 输出 .diff 文件 | ## 输入契约 ### `save/Layer.xml` **GB18030 编码**(不是 UTF-8 也不是 GBK!实测 Python xml.etree 用 GBK 解码会乱码,GB18030 才能完整解析中文 Spirit 名)。结构: ```xml ... ``` > **GB18030 编码陷阱**:脚本必须用 GB18030 读(GBK 是 GB18030 子集,但部分生僻字 / 私有区字符需要 GB18030 才能解码)。UTF-8 会乱码。Spirit 名是中文,输出 .prefab JSON 时按 UTF-8 写。Node.js 用 `iconv-lite` 包支持 GB18030 解码。 ### `output/gameabc_Image.json` `ImgResID → atlas spriteFrame path` 映射(具体 schema 在 PoC 阶段 1 解析)。 ### `output/gameabc_Project.json` ```json { "Property": { "ProjectName": "Game_Surface_3", "ScreenWidth": 1280, "ScreenHeight": 720, "GameSceneWidth": 1699, "GameSceneHeight": 1800, "ScreenFitMode": 1 } } ``` > **适配策略**:原项目 ScreenWidth=1280, ScreenHeight=720,但 YouleNexus 当前 Login_Layer.prefab 根 _contentSize = 1600×720。脚本默认生成 1600×720 的根 UITransform(与 Login_Layer.prefab 一致),但 Spirit 内部坐标按原 XML 不变——**Widget 锚定负责多分辨率适配**。 ### `js/gameabc.min.js` 逆向目标: - `SpiritType` 枚举(0=Sprite?1=Text?2=Button?3=ProgressBar?...) - `CoordSelfPos` 计算公式(X/Y 相对父节点的偏移量) - `SizeCalcMode` 公式(SizeParentID / SizeRelaWidth / SizeRelaHeight 怎么合成 _contentSize) - `WidthCalcMode` / `HeightCalcMode` 公式 - BelongGroupID 的语义 > **逆向策略**:在 PoC 阶段 1 之前完成。逆向结果固化到 `framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json` 作为已知事实源。 ## 输出契约 ### `out/legacy-migration/.prefab` UTF-8 JSON 数组(**严格按 Login_Layer.prefab schema**),字段含义: ```typescript type PrefabJson = Array type PrefabObject = | { __type__: "cc.Prefab", _name: string, ... } // 0 | { __type__: "cc.Node", _name: string, _children: [{__id__: number}], _components: [{__id__: number}], _lpos: Vec3, _lrot: Quat, _lscale: Vec3, ... } | { __type__: "cc.UITransform", _contentSize: Size, _anchorPoint: Vec2, ... } | { __type__: "cc.Sprite", _spriteFrame: { __uuid__: string, __expectedType__: "cc.SpriteFrame" }, _type: 0|1, _sizeMode: 0|1|2, ... } | { __type__: "cc.Label", _string: string, _fontSize: number, _horizontalAlign: 0|1|2, _verticalAlign: 0|1|2, _color: Color, _font: null, _isSystemFontUsed: true, ... } | { __type__: "cc.Widget", _alignFlags: number, _left?: number, _right?: number, _top?: number, _bottom?: number, _horizontalCenter?: number, _verticalCenter?: number, _isAbs*: true, _alignMode: 2, ... } | { __type__: "cc.CompPrefabInfo", fileId: string } | { __type__: "cc.PrefabInfo", root: {__id__: number}, asset: {__id__: number}, fileId: string, ... } | { __type__: string /* custom class UUID */, _name: "", _enabled: true, ... } ``` ### `out/legacy-migration/.diff` `diff -u` 格式,对比脚本输出 vs YouleNexus `prefabs//.prefab` 现有内容。**仅供人审**,不自动覆盖。 ### 输出根节点 `_contentSize` 默认 `1600×720`(与 Login_Layer.prefab 一致),**不按原 Project.GameSceneWidth/Height**——理由:YouleNexus Canvas 设计尺寸固定 1600×720(CLAUDE.md 第一准则精神:UI 层自己处理适配,原项目 ScreenWidth 是另一回事)。 ## 关键映射规则 ### Spirit → cc.Node | 原字段 | Cocos 字段 | |---|---| | `Spirit.Name` | `cc.Node._name` | | `Spirit.ID` | 用于生成 `_id`(cocos 内部 ID,与 fileId 不同) | | `Spirit.BelongGroupID = "0"` | 节点挂在 Layer 根节点下 | | `Spirit.BelongGroupID = "N"` (N>0) | 节点挂在 `group-N` 父节点下 | | `Spirit.IndexOfLayer` | 同 group 内的兄弟顺序(生成 `_children` 数组顺序) | | `Spirit.CoordParentID` | 父节点引用(=同 group 内其他 Spirit 的 ID) | ### Group 父节点自动生成 - 自动创建 `cc.Node _name = "group-"`,N = 原 BelongGroupID 值 - **保留全局编号**(不重新编号为 group-1/2/3),便于 cross-layer 调试(与 Login_Layer 复刻一致) - 父节点本身**无 sprite/label**,只挂 UITransform + Widget(与 Login_Layer.prefab 的 group-2 一致) - 多个 group 时按 group ID 升序排(group-1, group-2, ...) ### SpiritType → 组件 **逆向目标**(在 PoC 阶段 1 之前完成)。当前猜测: | SpiritType | 组件类型 | 说明 | |---|---|---| | 0 | `cc.Sprite` | 普通精灵 | | 1 | `cc.Label` | 文本 | | 2 | `cc.Button` | 按钮(待确认) | | 3 | `cc.ProgressBar` | 进度条(Login_Layer "进度条底" 是 type=3) | | ... | ... | 全部逆向 gameabc.min.js 后填表 | **未识别 type → throw**,不静默。 ### ImgResID → SpriteFrame 真 UUID 查 `output/gameabc_Image.json` 拿到 atlas 图片路径 → 查 `atlas-hall/atlas-login/atlas-room` atlas 的 SpriteFrame UUID(含 `@f9941` sub-asset 后缀)。 > **找不到 → throw + 列出所有未映射 ID**(CLAUDE.md 第二准则)。 ### 坐标/尺寸计算 **锚点转换公式(实测验证 2026-09-02,Login_Layer 渠道logo + 游客登录双节点匹配)**: 原项目精灵锚点 = **左上角**(X/Y 是精灵左上角的绝对屏幕坐标,基于 1280×720) Cocos 精灵锚点 = **中心**(_lpos 是精灵中心在父节点坐标系的位置,Y-up) ``` # Spirit → cc.Node._lpos(假设父节点 _contentSize 1600×720、anchorPoint (0.5, 0.5)) cocos_lpos.x = original_X + original_W / 2 - 640 # 原坐标系半宽 1280/2 cocos_lpos.y = 360 - (original_Y + original_H / 2) # Y-up 翻转 + 原坐标系半高 720/2 cocos_lpos.z = 0 ``` **验证证据**: - 渠道logo:原 (734, 143, 59, 148) → Cocos (763.5 - 640, 360 - 217) = (123.5, 143) ✓ - 游客登录:原 (540, 388, 320, 95) → Cocos (700 - 640, 360 - 435.5) = (60, -75.5) ✓ **SizeCalcMode 公式(待 PoC 阶段 1 逆向 gameabc.min.js)**: ``` _contentSize.width = Spirit.Width || 0 _contentSize.height = Spirit.Height || 0 # 如果 SizeCalcMode != 0,用 SizeParentID + SizeRelaWidth/Height 计算(公式逆向中) if Spirit.SizeCalcMode != 0: parent_size = lookup(Spirit.SizeParentID) _contentSize.width = parent_size.width * Spirit.SizeRelaWidth / 10000 _contentSize.height = parent_size.height * Spirit.SizeRelaHeight / 10000 ``` **Widget 锚定**: | 情况 | `_alignFlags` | 其他字段 | |---|---|---| | 节点相对 Canvas 全屏铺满 | 45 (= 左+右+上+下) | `_left/_right/_top/_bottom = 0` | | 节点水平居中 | 16 (= 水平居中) | `_horizontalCenter = Spirit.X` | | 节点垂直居中 | 8 | `_verticalCenter = Spirit.Y` | | 节点左上对齐 | 9 (= 左+上) | `_left = Spirit.X, _top = Spirit.Y` | | ... | ... | 全部逆向后填表 | **逆向不出公式 → throw**,不兜底默认值。 ## 错误处理(CLAUDE.md 第二准则:不静默兜底) | 场景 | 处理 | |---|---| | `gameabc_Image.json` 找不到 ImgResID | `throw new Error(\`ImgResID ${id} (Spirit ${sid}) not in gameabc_Image.json\`)` | | SpiritType 不在已知枚举 | `throw new Error(\`Unknown SpiritType ${type} (Spirit ${sid}) — reverse gameabc.min.js first\`)` | | CoordSelfPos/SizeCalcMode 公式逆向不出 | `throw new Error(\`Cannot compute coord/size for Spirit ${sid}: formula unknown\`)` | | XML 字段缺失或编码错 | `throw new Error(\`Spirit ${sid} missing field ${fieldName}\`)` | | 输出目录有同名 .prefab | **覆盖**(隔离目录是 transient)+ stdout 列 diff 路径 | | `gameabc_Image.json` schema 变更 | 启动时 schema 校验,失败直接 throw | | Layer 在 PoC 阶段 4 找不到目标 YouleNexus prefab | throw + 列出所有"无目标"Layer,让用户补映射 | ## 测试策略(TDD) 按 ui-asset-and-skin plan 风格:`node:test` + `node:assert` + `tsx`,零第三方依赖。 ### 测试套件:`framework-tests/legacy-layer-migration/` | 文件 | 覆盖 | |---|---| | `xml-parser.test.mjs` | GB18030 编码读、字段提取、BelongGroupID 分组 | | `spirit-to-node.test.mjs` | SpiritType=0/1/3 → Sprite/Text/ProgressBar 组件映射 | | `group-builder.test.mjs` | BelongGroupID → group-X 父节点合并 + 同 group 内 IndexOfLayer 排序 | | `coord-calculator.test.mjs` | CoordSelfPos/SizeCalcMode/WidthCalcMode/HeightCalcMode 公式正确性 | | `sprite-uuid-resolver.test.mjs` | ImgResID → 现有 atlas SpriteFrame UUID(基于真实 gameabc_Image.json fixture) | | `prefab-emitter.test.mjs` | 输出 JSON 格式严格按 Login_Layer.prefab schema(用 Login_Layer.prefab 实际内容作 fixture) | | `poc-iconbutton.test.mjs` | 端到端:输入 save/Layer*.xml → 输出 .prefab → 与 YouleNexus 原 prefab 做 schema 对比(**不对比内容,只对比结构**) | ### TDD 红绿循环(每个 PoC 阶段) 1. **红**:写测试(输入已知 Layer XML,期望已知 prefab JSON 结构) 2. **绿**:写脚本直到测试通过 3. **重构**:优化代码 4. **人工 diff**:`diff -u out/legacy-migration/.prefab prefabs//.prefab`,看 diff 5. **修订**:根据 diff 调整映射规则,回到 1 ### 测试 fixtures - `framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json` — gameabc.min.js 逆向结果固化 - `framework-tests/legacy-layer-migration/fixtures/gameabc_Image.json` — 真实 gameabc_Image.json 副本 - `framework-tests/legacy-layer-migration/fixtures/Layer00002.xml` — Login_Layer XML 副本(GB18030) - `framework-tests/legacy-layer-migration/fixtures/Login_Layer.prefab` — 已复刻的 Login_Layer prefab 副本(用于端到端对比) ## PoC 路径(4 阶段) | 阶段 | 目标 | 验证点 | |---|---|---| | **阶段 1** | IconButton(单 Sprite、无 Widget、无 group) | Spirit→Node + SpriteFrame 解析 + JSON 输出 + schema 对比 | | **阶段 2** | Layer10_Protol(Layer00010,group 37,含 Widget 锚定) | group-X 父节点合并 + Widget 锚定逻辑 | | **阶段 3** | Login_Layer(已有完整 1:1 复刻可对比) | 端到端验证 + 人工 diff 接近零差异 | | **阶段 4** | 批量跑剩 14 个 | 每个输出 .diff 供人审 | 阶段 1 必须先逆向 gameabc.min.js 中 IconButton 用到的 SpiritType/Coord 公式。 ## 不做的事(明确边界) - ❌ 不覆盖 YouleNexus 原 prefab(**只生成候选 + diff**) - ❌ 不处理动画(Spirit 的 `ani_*` 属性 → throw + 标出该 Spirit) - ❌ 不处理 Spine / DragonBones(unknown SpiritType → throw) - ❌ 不处理多语言(只读 `_string` 字面值,不查国际化表) - ❌ 不迁移协议字段(CLAUDE.md 第一准则:协议不动) - ❌ 不重写为 click handler / 业务逻辑(只做布局+精灵迁移,**业务挂脚本留 follow-up**) - ❌ 不处理 OptionList.tag/tag1-3 / EventList(除非 layer-to-prefab 有明确映射,待逆向) ## Risks & Open Questions 1. **gameabc.min.js 逆向深度**:SpiritType 枚举只有 5 种(0/1/3/4/5,全量解析已确认),但 SpiritType 4 和 5 的语义仍需逆向(猜测 4=Particle / 5=Button,但需 PoC 阶段 1 验证);CoordSelfPos / SizeCalcMode 公式逆向中。 2. **SpriteFrame UUID 跨 atlas 选择规则**:atlas 选哪个取决于原 Layer 来源场景 —— Login_Layer 用 `atlas-login`,MainScene 用 `atlas-hall`,Room 用 `atlas-room`。具体规则按 Layer 编号或 Spirit 名称模式决定,**PoC 阶段 1 实测后固化到 fixtures/spirit-semantics.json**。 3. **Layer00007 ewm_Layer 含 group=0**:表示有节点不在 group-X 父节点下(直接挂在 Layer 根)。脚本需支持 BelongGroupID=0 节点(按现有 group-X 父节点方案应直接挂 Layer 根)—— **已在范围表规则中说明**,待 PoC 验证。 4. **YouleNexus 原 prefab 的偏差**:某些 prefab 可能已经人工调整过布局,与原 Layer 不一致 —— **diff 工具帮人审,但最终覆盖决策在你** ## Out of Scope(follow-up) - 把所有 68 个 Layer 全迁移 - 自动生成 sprite atlas(走 ui-asset-and-skin plan Task 3 独立推进) - 迁移 OptionList.tag/tag1-3 业务字段 - 迁移 EventList 事件处理 ## Success Metrics - 17 个目标 prefab 全部生成;与 YouleNexus 现有版本 diff **仅显示手工已修字段的差异**(坐标/尺寸/spriteFrame UUID/Widget 锚定全部精确匹配) - 锚点转换公式精度:**至少 3 个已知 Spirit 的 _lpos 与人工复刻版 prefab 误差 < 0.001** - 7 个测试文件全部通过(node:test) - `framework-tests/legacy-layer-migration/` 覆盖率 ≥ 80% - 脚本可在 CI 跑(无 GUI 依赖) - gameabc.min.js 逆向结果固化到 fixtures/spirit-semantics.json,作为后续 PoC 阶段的事实源