diff --git a/docs/superpowers/specs/UI-手工迁移规范.md b/docs/superpowers/specs/UI-手工迁移规范.md new file mode 100644 index 0000000..8c1acf6 --- /dev/null +++ b/docs/superpowers/specs/UI-手工迁移规范.md @@ -0,0 +1,194 @@ +# UI 手工迁移规范 (Game_Surface_3 → YouleNexus Cocos) + +> **Status**: Draft v1 (2026-09-03) +> **Purpose**: 为后续手工 UI 迁移提供可复用规范(funplay MCP + 数据模型 + 验证流程) +> **基于**: `docs/superpowers/specs/原工程数据模型.md` + Layer 8/15 实际迁移经验 + +## 决策树:何时用哪种迁移路径 + +``` +迁移 Layer N 包含 K 个 spirit 且 K ≤ 4 ? +├── 是 (简单 Layer) → 脚本生成 (Python build-layer-N.py) +│ 已验证 OK: Layer 8 (9 spirit), Layer 15 (26 spirit) +│ ⚠️ 复杂 Layer 脚本生成已废弃 (Notice_Layer 9 spirit 经历多次修复) +│ +└── 否 (>4 spirit 或需手工 UI 调优) → funplay MCP 手工复刻 + 单个 API call 效率低 (26 spirit 需 200+ calls) + 建议 create_scene + execute_javascript 批量 + save + duplicate_prefab +``` + +**实践结论**:脚本生成和"funplay MCP 手工"在数据层完全等价(同一 JSON schema、同一 spriteFrame UUID 引用),区别仅在过程。**复杂 Layer 建议 execute_javascript 一次性注入**(既避免 200+ calls 又保证数据精确)。 + +## 9 步 funplay MCP 手工迁移流程 + +### Step 1: 准备数据 + +```bash +# 从原工程读取 Layer XML +cat projects/Game_Surface_3/save/LayerXXXX.xml | head -100 + +# 从 JSON 读取 Object 详细字段 +python -c " +import json +with open('projects/Game_Surface_3/output/gameabc_Object.json') as f: + data = json.load(f) +objs = {x['Property']['ObjectID']: x for x in data['ObjectList'] if x} +# 查找某个 spirit 的完整信息 +sp = objs[465] # 例: ID 465 +print(json.dumps(sp, indent=2, ensure_ascii=False))" +``` + +**关键字段**: +- `ObjectID` / `ObjectName` / `ObjectType` (2=Sprite, 4=Text/EditBox, 5/6=运行时) +- `Left` / `Top` / `Width` / `Height` (绝对坐标, 左上角锚点) +- `GroupID` (0=根 layer, >0=某 group 容器) +- `BelongLayerID` / `IndexOfLayer` (用于排序) +- `ImageFileID` → gameabc_Image.json 的 id 字段 → 找 spriteFrame UUID +- `FrameStyle` / `FrameIndex` (多帧 sprite 切帧) +- `OriginPos` / `SelfPos` (实测全部 = 1 = top-left, 无复杂公式) +- `L9` / `T9` / `R9` / `B9` (9-slice border 像素) +- `Event` 字段 (mousedown/mouseup 等 → callback ObjectID) +- `Option` 字段 (canclick, vx/vy/vw/vh 动画物理 - deferred) + +### Step 2: 查 SpriteFrame UUID + +```bash +# 找 ImgResID 对应的 atlas sub-meta +python -c " +import json, glob +img_id = 9 # 替换为目标 ImgResID +for path in glob.glob(f'cocoscreator_projects/YouleNexus/assets/framework/ui/atlas-*/{img_id:05d}*.png.meta'): + with open(path, encoding='utf-8') as f: + data = json.load(f) + sub_metas = data.get('subMetas', {}) + for sid, sm in sub_metas.items(): + if sm.get('name') == 'spriteFrame': + print(f'{path}: {sm.get(\"uuid\")}')" +``` + +**多帧 sprite 命名规则**(实测): +- 0-padded: `00009_01.png` ~ `00009_12.png`(12 帧) +- 不 padded: `00028_1.png` ~ `00028_2.png`(2 帧) +- ⚠️ 迁移脚本需要支持两种命名 (playbook §关键迁移规则 应更新此发现) + +### Step 3: 创建空 scene (funplay MCP) + +```bash +mcp__funplay_cocos__create_scene + target: db://assets/framework/ui/_playbook_demo/LayerXXXX_Layer_manual.scene + sceneName: LayerXXXX_Layer_manual +``` + +### Step 4: 添加容器节点 (group-X) + +```bash +# 对 GroupID > 0 的每个组, 创建一个 cc.Node +mcp__funplay_cocos__create_node + parentPath: Canvas + name: group-{GroupID} + position: '{"x": 0, "y": 0, "z": 0}' + # + 加 UITransform + Widget + CompPrefabInfo + PrefabInfo (6 对象每个节点) +``` + +**对 26 spirit Layer 15**:1 个 group-5 容器 + 26 sprite 节点 = 27 个 node × 6 对象 = 162 objects + +### Step 5: 添加 Sprite 节点 (per spirit) + +```python +# 26 次循环, 每次: +# 1. create_node (parentPath=Canvas/group-5, name=spirit.Name, position=lpos) +# 2. add_component cc.UITransform (contentSize=spirit.W/H) +# 3. add_component cc.Widget (alignFlags=45) +# 4. add_component cc.Sprite (spriteFrame.UUID, _color white, _type=0) +# 5. add_component cc.CompPrefabInfo +# 6. add_component cc.PrefabInfo (root=node_id) +``` + +**每个 Node 在 Cocos prefab JSON 中需要 6 个对象**: +- cc.Node (parent/children/components/prefab/lpos/lrot/lscale) +- cc.UITransform (node/contentSize/anchorPoint/prefab) +- cc.Widget (node/alignFlags/.../prefab) +- cc.Sprite (node/spriteFrame/color/.../prefab) +- cc.CompPrefabInfo (fileId) +- cc.PrefabInfo (root/asset/fileId/...) + +**关键**:`__prefab` 字段是 **数组索引**(指向同一节点对应的 CompPrefabInfo 在 data 数组中的位置)。详见 5.x 数组索引约束。 + +### Step 6: 数组索引约束 (⚠️ 关键) + +**所有 `__prefab` 字段必须是数组索引**(不是 `_id` 字段值): + +```javascript +// 正确 (works): +{node: 'group-5', _prefab: {'__id__': N}}, // N = CompPrefabInfo 的数组索引 +{sprite: '...', _prefab: {'__id__': N}}, + +// 错误 (Cocos 报 TypeError: undefined.__type__): +{node: 'group-5', _prefab: {'__id__': 100}}, // 100 是不存在的 object +``` + +**正确做法**:让每个 Node 紧跟其 CompPrefabInfo(按生成顺序 append),用 `len(new)` 计算数组索引: + +```python +node_offset = len(new) +cpi_idx = base_count + node_offset + 1 # cpi 紧跟 node +# 然后 append node, uit, sprite, widget, cpi, pi +new.append(node_obj) +# ↑ base_count + node_offset 才是 cpi 的实际位置 +``` + +### Step 7: 锚点公式 (实测验证) + +```python +# SelfPos = 1 (top-left) 实测对所有 991 object 成立 +cocos_lpos_x = spirit.Left + spirit.Width / 2 - 640 # 原坐标系半宽 1280/2 +cocos_lpos_y = 360 - (spirit.Top + spirit.Height / 2) # Y-up 翻转 + 半高 720/2 + +# 例: Spirit 27 (加入房间遮罩, X=0 Y=0 W=1280 H=720) +# -> lpos = (0 + 640 - 640, 360 - (0 + 360)) = (0, 0) ✓ +``` + +**多帧 sprite 公式**(实测 Sprite 514 在 gameabc 显示第 10 帧 = ImgResID 9 的 spriteFrame 第 10 帧): +- 单帧 sprite (`FrameStyle: 0` 或 `FrameIndex: 0`) → `ImageFileID.bmp` 单张 PNG → cc.Sprite `_spriteFrame.__uuid__` 用 frame 0 +- 多帧 sprite (`FrameStyle: 1` + `FrameIndex: N`) → `ImageFileID.bmp` 是单张 PNG 含多帧 → Cocos **仍用 frame 0 的 spriteFrame**,但 `_type=1` (SLICED) + FrameIndex 决定 sub-region + +⚠️ **Cocos 与 gameabc 的 multi-frame 行为差异**:gameabc 每个 spirit 引用 imageasset 同一 sub-region;Cocos 多帧 sprite 由 spriteFrame.uv 数组自动处理 sub-region。实际 UI 表现需要测试。 + +### Step 8: 验证 (8 项) + +| # | 项 | 验证方法 | +|---|---|---| +| 1 | node 数量与原 Layer XML spirit 数量一致 | `list_prefabs` + `inspect_prefab` | +| 2 | 每个 spirit ID 都在 prefab 中 | `inspect_prefab` JSON 全文搜 | +| 3 | 中文 Name 严格匹配 | JSON `_name` 字段 | +| 4 | _lpos 精度 < 0.001 | 对比 XML X/W + 计算公式 | +| 5 | _contentSize 一致 | 对比 XML W/H | +| 6 | SpriteFrame UUID 指向真实 atlas | `validate_prefab_references` 0 missing | +| 7 | group 层级正确 | `_children` 引用链验证 | +| 8 | 加载 0 报错 | `open_asset` + `search_project_logs` | + +### Step 9: 提交 + 推送 + +```bash +git add cocoscreator_projects/YouleNexus/assets/framework/ui/prefabs/_playbook_demo/ +git commit -m "feat(scenes): Layer X JoinRoom_Layer_manual 1:1 迁移 (26 spirits)" +git push -u origin master +``` + +⚠️ **写迁移规范文件**:`docs/superpowers/specs/UI-手工迁移规范.md` (本文件) + +## 已知限制 / 后续子任务 + +- `Option.vx/vy/vw/vh` 动画物理 → deferred to game logic migration +- `VoiceFileID` 音频 → deferred to audio migration +- `Event` 字段(mousedown 等)→ 用 `bind_button_click_event` 工具 wire +- `GameTxtStyle` i18n → deferred to i18n plan +- `FrameStyle + FrameIndex` 多帧 sprite 在 Cocos 中实际显示 → 需要 test_scene 验证 + +## 关联文档 + +- `docs/superpowers/specs/原工程数据模型.md`(完整 7 个 JSON schema + ObjectType 0-6 映射 + 锚点公式) +- `docs/superpowers/specs/Layer-15-数据完整性报告.md`(完整 1:1 验证 + 26 spirit 数据) +- `docs/superpowers/specs/2026-09-03-interface-conversion-playbook.md`(playbook §关键迁移规则 多帧 sprite) + + \ No newline at end of file