docs(spec): UI 手工迁移规范 (9 步 funplay MCP 流程 + 8 项验证清单 + 多帧 sprite 命名规则)
基于 Layer 8 (Notice 9 spirit) + Layer 15 (JoinRoom 26 spirit) 实际迁移经验. 含决策树 (脚本生成 vs funplay MCP 手工) + __prefab 数组索引约束 + SelfPos=1 top-left 锚点公式. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
</content>
|
||||
</invoke>
|
||||
Reference in New Issue
Block a user