Files
youle_cocos/docs/superpowers/specs/2026-08-28-legacy-ui-migration-design.md
T
joywayerandClaude Opus 5 733090ec13 docs(spec): 旧 gameabc UI 数据迁移与分辨率适配设计(子系统 A)
把「旧平台 UI 搬到 Cocos」拆成三个子系统, 本 spec 只覆盖 A(UI 数据迁移);
B(引擎机制映射)与 C(平台逻辑移植)另立。

关键决策: 设计分辨率保持 1280x720 + fitHeight(坐标 1:1 迁移)、多帧图切成散图
(1306 张)、同组挂同一父节点、锚点按中心点三区推断(仅水平)、全部 55 界面转进框架、
走「中间产物 + 编辑器批量」而非直接生成 prefab(红线)。

帧序经实证: 1 基 + 行优先(读 00014.png 网格内容与 11 个按钮的 FrameIndex 对照,
六项精确命中)。并推翻了早期「51.6% 帧未被引用」的错误统计。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:14:15 +08:00

22 KiB
Raw Blame History

旧 gameabc UI 数据 → Cocos 平台界面:迁移与分辨率适配 设计

状态:已与用户确认(brainstorming 通过),待转 writing-plans。 适用工程:cocoscreator_projects/YouleNexus(框架真源)。 数据源:projects/Game_Surface_3/output/gameabc_*.json(旧 H5 工程的 UI 导出数据)。 关联:2026-08-27-ui-asset-and-skin-design.md(资源归属 §2 / 图集划分 / override 白名单 / theme §4); 2026-06-28-cocos-framework-design.md §5(换肤)。 本 spec 只覆盖子系统 A,B(引擎机制映射)与 C(平台逻辑移植)另立,见 §0.1。


0. 目标与背景

0.1 范围:这是三个子系统中的第一个

把「旧平台 UI 搬到 Cocos」拆成三件互相独立的事,本 spec 只做 A:

子系统 产出 依赖
A UI 数据迁移(本 spec) 991 对象 / 55 界面 → 中间描述 → prefab + 1306 张散图 无
B 引擎机制映射 ~10 个 ifast_* API + set_self/get_self 属性号对照表 → Cocos 等价物 需逆向 js/gameabc.min.js(186KB)
C 平台逻辑移植 11_GameUI.js(9862 行)等 → TypeScript A 与 B 都要先有

A 与 B 互不依赖、可并行;C 必须等两者。

B 的紧迫性:平台层有 set_self 1773 处 + get_self 447 处调用,全部使用数字属性号(37/7/18/20/43…)。若属性号解不出来,C 就只能重写而非移植,工作量差一个量级。建议 A 开工后尽快并行启动 B。

0.2 决策依据(实测数据)

以下全部实测自 projects/Game_Surface_3,2026-08-28。记录在此,便于日后回看每个取舍的依据。

布局数据规模

  • gameabc_Object.json:991 个对象,ObjectType 仅两种——2 = 图片精灵(782)、4 = 文字(209)
  • gameabc_Layer.json:55 个 Layer = 55 个界面(Logo_Layer / Login_Layer / MainMenu_Layer / Player_Head_Score_Layer / inputPannel …)
  • gameabc_GroupList.json:80 个组,覆盖 990/991 个对象(仅 1 个未分组)
  • 109 个对象使用九宫格(L9/T9/R9/B9 非零)
  • 52 个对象为满屏尺寸(≥1280×720),其中 40 个是所在层的首个对象
  • 222 个对象坐标超出画布边界;Left 范围 −286 ~ 1501、Top 范围 −318 ~ 1206

图片资源

  • gameabc_Image.json:439 张图,其中 139 张多帧(31.7%)
  • 多帧图全部为规整网格:w × h = frame_all,139 张零例外
  • 切分后总计 1006 帧 + 300 张单帧图 = 1306 张散图
  • 78/139 张多帧图被多个对象共用,最多一张被 13 个对象共用
  • 389 个对象引用多帧图,其中 FrameIndex = 0 的仅 2 个

坐标系与适配(现状)

  • 旧引擎原点左上、Y 向下、精灵锚点左上,position = (Left, Top),设计分辨率 1280×720
  • OriginID / Parent 全为 0,OriginPos / SelfPos 全为 1 → 零相对锚定数据
  • 引擎(js/gameabc.min.js)用 innerWidth/innerHeight + scale + onresize 做整画布等比缩放,靠 letterbox 适配

文字

  • 209 个 Type4 对象全部没有 ImageFileID、TextFrames 为空 → 纯动态文本占位
  • 样式在代码里:Utl.setFontColor(spid, value)(08_Utl_Output.js:824),引擎用 canvas fillText
  • → 转换只能得到几何信息,字体/字号/颜色须由 theme.fonts 提供

运行时动态精灵(B 的线索,A 需为其保留信息)

  • ifast_addtospritefromspritecopy(父精灵ID, 模板精灵ID, x, y, tag) —— 88 处调用
  • ifast_dllpritefromspritecopy —— 74 处调用
  • → 典型「克隆模板 → 用完删除」的列表/对象池模式;991 个对象中有一部分是模板、一部分是容器

0.3 帧序:1 基 + 行优先(实证结论)

列 = (FrameIndex − 1) % w
行 = ⌊(FrameIndex − 1) / w⌋
源矩形 = (列 × w1, 行 × h1, w1, h1)

验证方法(记录在此,因为这是最容易搞错且后果最广的一处):00014.png 为 450×280、网格 3×4,被 11 个按钮共用。直接读取该 PNG,其网格内容为:

我的    福利    任务
战绩    背包    仓库
吐槽    财富榜  邀请码
通知    设置    (空)

对照对象的 FrameIndex:通知=10、设置=11、战绩=4、背包=5、仓库=6、任务=3。按「1 基 + 行优先」换算,六项全部精确命中;列优先与 0 基行优先均对不上。用户随后独立确认了该结论。

FrameIndex = 0 的 2 个对象按帧 1 处理。

一处被推翻的统计:早期曾得出「1306 帧中 674 帧(51.6%)从未被引用」并险些据此裁剪资源。该数字错误且不得使用,两个原因:① 帧号 1 基,统计时把下标 0 当成了有效帧;② 平台层有 set_self 1773 处调用,帧是运行时切换的,静态数据里的 FrameIndex 只是初始帧。按钮的按下态/选中态就在那些「未引用」帧里。任何帧都不得因静态未引用而删除。

0.4 已确认决策

  • 设计分辨率保持 1280×720 + fitHeight —— 991 个对象的 X/Y 全部 1:1 迁移,不做任何坐标缩放。现代宽屏由 fitHeight 天然获得(20:9 下水平可见区约等于 1600×720)。
  • 多帧图切成散图(1306 张)—— 多帧网格是旧引擎缺图集能力的手工替代品,Cocos 的 Auto Atlas 在构建期自动打包;照搬它等于把引擎缺陷当成新框架的约定。
  • 同组对象挂同一父节点 —— GroupList 覆盖 990/991,是旧引擎用来做整体操作的机制,语义等价于父节点。
  • 锚点按中心点三区自动推断,仅水平方向(§2.3)。
  • 背景四边拉伸,美术后续手动更换正确尺寸的图片。
  • 全部 55 个界面转进框架(含牌桌内的平台部分)。
  • 落地走「中间产物 + 编辑器批量」(§4.1),不直接生成 .prefab 文件。

0.5 红线

  • 绝不手写 .prefab / .meta / .scene(CLAUDE.md)。序列化资源只能由编辑器经官方 API 产生。这一条直接否决了「Node 脚本直接吐 prefab」这一最直觉的做法(§4.1)。
  • 服务器零改动:本 spec 不触及协议。
  • 数据源权威唯一、下游不兜底:中间描述是转换的唯一真相;转换失败必须显式报错,不得静默产出残缺节点。
  • 不得因静态未引用而裁剪帧(§0.3)。

0.6 方法论教训(写给后续维护者)

本 spec 的调研过程中两次因看错字段而误判「数据不存在」:

  1. 查 OriginID/Parent 全为 0 → 结论「没有层级信息」。错:层级语义在 GroupList 里。
  2. 统计 FrameIndex 引用 → 结论「51.6% 帧是死重量」。错:帧号 1 基,且帧在运行时切换。

教训:在 gameabc 这类自研引擎的数据上,「某字段全为空」不等于「该语义不存在」——它可能编码在另一个字段、另一个文件,或根本在代码里。下结论前先把 output/ 下全部 JSON 与相关引擎 API 都看一遍。


1. 中间描述的数据模型

1.1 形态:一层一个 JSON + 一份 manifest

tools/ui-migration/intermediate/
├─ manifest.json          # 55 界面索引 + ObjectID 全局映射 + 转换器版本
├─ 002-Login_Layer.json
├─ 004-MainMenu_Layer.json
└─ …(共 55 个)

一层一文件:局部重跑与人工校对的单位就是界面。改了锚点规则只想重转登录页,不该动其余 54 个。

1.2 节点模型

{
  "id": 254,
  "name": "微信登录按钮",              // ObjectName 原样保留(中文名信息量大)
  "kind": "sprite",                   // ObjectType 2→sprite, 4→label
  "position": { "x": -245, "y": 125 }, // 已转为 Cocos 坐标系(§2.2)
  "size": { "width": 150, "height": 70 },
  "siblingIndex": 7,                  // 来自 IndexOfLayer,决定 z 序
  "widget": { "horizontal": "center", "horizontalCenter": -245 },  // §2.3

  "sprite": {
    "frame": "atlas-login/00014_05.png",   // 当前帧的逻辑路径(§3)
    "frameSet": {                          // 供 FrameSet 组件(§3.4)
      "prefix": "atlas-login/00014",       // 帧文件名前缀
      "count": 12,                         // 帧总数,索引 1..count
      "pad": 2                             // 补零位数,用于拼出 00014_01 … 00014_12
    },
    "type": "sliced",                      // L9/T9/R9/B9 任一非零 → sliced
    "slice": { "left": 12, "right": 12, "top": 8, "bottom": 8 }
  },

  "flags": {
    "stretch": false,      // 背景类,四边拉伸(§2.4)
    "offscreen": false,    // 坐标超出画布(§2.5)
    "animation": false     // FrameStyle=1,整图动画(§3.5)
  },

  "legacy": {              // 原始信息,不参与渲染,但**承重**(§1.3)
    "objectId": 254,
    "imageFileId": 14, "frameIndex": 5, "frameStyle": 0,
    "groupId": 3, "voiceFileId": 0, "timerInterval": 0,
    "left": 320, "top": 200,          // 原始左上坐标,供回溯对照
    "events": ["mousedown", "mouseup"]
    // 校验:x = 320 + 150/2 − 640 = −245;y = 360 − 200 − 70/2 = 125
    // cx = 320 + 75 = 395 ∈ [320, 960] → 水平居中,horizontalCenter = 395 − 640 = −245
  }
}

kind: "label" 的节点只有 position / size / widget / legacy,没有样式字段——依据 §0.2,样式在旧代码里,数据中不存在。字体字号颜色由 theme.fonts 提供(见 2026-08-27-ui-asset-and-skin-design.md §4)。

1.3 legacy.objectId 是承重字段,不是纪念品

旧平台逻辑通过 set_self(精灵ID, 属性号, …) 操作 UI,全平台层 1773 处调用。子系统 C 移植这些逻辑时,必须能由 ObjectID 定位到 Cocos 节点——丢掉这个映射,那 1773 处调用就没有着落,C 会从「移植」退化成「重写」。

因此:

  • 每个节点保留完整 legacy 块
  • manifest.json 额外维护一份全局索引:ObjectID → { 界面, 节点路径 }

同理保留 groupId(B 需要它理解容器语义)与 events(C 需要它接事件)。

1.4 层级信息

{
  "layerId": 2,
  "name": "Login_Layer",
  "bucket": "login",                // login | hall | room | common | unassigned(§3.3)
  "backgroundNodeId": 2,            // 「层内首个 + 满屏」识别(40/52 命中)
  "groups": [                       // 来自 GroupList,同组挂同一父节点(§2.1)
    { "groupId": 2, "nodeIds": [2, 405, 285, /* … */] }
  ],
  "nodes": [ /* 按 siblingIndex 排序 */ ]
}

1.5 本节有意不做的事

  • 不推断按钮。Event 含 mousedown 的对象包括背景图(对象 1 即是),据此判断交互性不可靠。事件原样存入 legacy,留给 C。
  • 不识别模板/容器。§0.2 表明存在运行时克隆的模板与容器,但要认出「哪个 ObjectID 是 ConstVal.myRoomList.bgSp」需要读平台层代码——那是 B/C 的范围。A 只需忠实保留 ObjectID,使后续可交叉引用。

2. 坐标与锚点转换规则

2.1 group → 父节点:父节点必须是满屏容器

子节点坐标相对父节点,而旧数据全是绝对坐标。若 group 父节点用包围盒或零尺寸,两件事会同时出问题:子坐标要重算,且 子节点的 Widget 会锚到组边界而非屏幕边界,适配立刻失效。

解法是让父节点不产生坐标偏移、且与屏幕同尺寸:

Layer 根节点     1280×720,Widget 四边拉伸
└─ Group 节点    1280×720,Widget 四边拉伸,position (0,0)
   └─ 对象节点    §2.2 转换后的绝对坐标

于是子对象坐标 = 转换后的绝对坐标(无需再减父偏移),其 Widget 锚定的满屏父节点等价于锚定屏幕边界。

2.2 坐标系转换

节点锚点采用 Cocos 惯例 (0.5, 0.5):

x = Left + Width / 2 − 640
y = 360 − Top − Height / 2

为何不用 (0, 1)(左上):那样公式更简(x = Left − 640、y = 360 − Top)且与旧引擎语义一一对应,但用户明确会在编辑器里人工审核并修改节点树,而 Cocos 的编辑器、对齐工具、Widget 全部假定锚点居中。转换公式复杂一点是转换器一次性的成本,人工编辑的别扭是长期成本。

原始 Left/Top 存入 legacy,回溯对照随时可查。

2.3 锚点推断:只需要水平方向

fitHeight 下垂直方向永远精确撑满、不会溢出,因此不需要任何垂直适配——垂直用固定位置即可。这是保持 1280×720 设计分辨率带来的实质简化。

水平方向按对象中心点 cx = Left + Width / 2 三区推断:

条件 锚定 Widget 值
cx < 320 靠左 left = Left
cx > 960 靠右 right = 1280 − (Left + Width)
其余 水平居中 horizontalCenter = cx − 640

分界取 1280 的四等分点。这是机械规则,不追求完美——用户已确认转换后会逐屏人工审核修正,规则只需「大部分对、且错了容易看出」。

2.4 背景:四边拉伸

识别规则:层内首个 + 满屏尺寸(实测 40/52 命中)。此类节点 Widget 四边置 0 全拉伸,中间描述标 flags.stretch = true。

另外 12 个满屏但非层内首个的(如「创建房间遮罩」)同样四边拉伸——它们本就是覆盖全屏的遮罩,行为一致。

转换器不做任何图片处理:美术后续会手动更换为正确尺寸的图片。

2.5 屏外对象

222 个对象坐标超出画布(Left −286 ~ 1501、Top −318 ~ 1206),多为动画入场的暂存位。

处理:坐标原样转换,不裁剪、不钳制。三区规则照常适用(cx 为负自然落入「靠左」)。额外标 flags.offscreen = true,供人工审核时快速筛出——它们最可能需要手工确认锚点是否合理。


3. 多帧图切分与命名约定

3.1 切分规则

见 §0.3 的公式与实证。139 张多帧图全部满足 w × h = frame_all,切分是纯机械操作。

3.2 命名:保持 1 基,补零对齐字典序

00014.png (3×4)  →  00014_01.png … 00014_12.png
00019.png (13×1) →  00019_01.png … 00019_13.png

用 1 基而非 0 基,使文件名与 FrameIndex 直接对齐:C 移植时代码里的 set_self(spid, 帧属性号, 7) 能一眼对上 _07,无需心算减一。这类 off-by-one 是最易反复踩的坑(§0.3 已经踩过一次统计口径)。

补零位数按 frame_all 决定,保证字典序 = 帧序(否则 _10 会排在 _2 前,图集打包与人工浏览都会乱)。

3.3 图集归属

每张图按使用它的对象所在层决定 bucket;跨多个 bucket 的图归 atlas-common。

层 → bucket 用实测出的 LayerID 段位规律 + 可覆盖的显式表:

LayerID 段 bucket 典型层
1–29 login / hall Logo/Login/Auth/Bind → login;MainMenu/Pay/Task/rankList/wareHouse → hall
50 / 202 / 4xx room MainScene / Player_Head_Score / Chat / Interact / btnPannel
6xx common inputPannel / Tips / Loading / Reconnect / Kick

命名笼统的层(Layer6 / Layer13 / Layer403 / Layer411 / Layer601 / Layer603 / Layer618)标 bucket: "unassigned",不猜,由人工归位。转换器遇到 unassigned 应告警而非静默归入 common。

3.4 运行时切帧:FrameSet 组件

每个引用多帧图的节点挂一个 FrameSet 组件,持有该图全部帧的 SpriteFrame 数组,索引与 FrameIndex 同为 1 基:

frameSet.setFrame(7);   // ← C 移植时对应 set_self(spid, 帧属性号, 7)

副作用是必要的:全部帧都被数组引用 → 都会进图集、不会被依赖树裁掉,符合 §0.3「任何帧都不得因静态未引用而删除」。

3.5 不切的情况

  • 300 张单帧图原样保留,仅按 bucket 分目录
  • FrameStyle = 1 的 54 个对象(整图动画)照常切帧,节点额外标 flags.animation = true,供后续做逐帧动画识别

3.6 物理组织与命名保留

framework/ui/atlas-hall/00014_01.png … 00014_12.png
framework/ui/atlas-login/00006_01.png … 00006_05.png

沿用原编号 + 帧号,不重命名为语义名。两个理由:① 原编号是与 gameabc_Image.json 对照回溯的唯一线索;② 实测证明对象名与美术内容会脱节(对象叫「更多」,图上是「我的」;对象叫「分享」,图上是「福利」),语义命名会把错误信息固化进文件名。


4. 落地管线与验证

4.1 管线形态

gameabc_*.json ──[Node 转换器]──► 中间描述 55 份 + 散图 1306 张
                                          │
                                          ▼
                                  [编辑器侧批量落地]
                                          │
                                          ▼
                              framework/ui/*.prefab(55 个)

Node 侧为纯函数:读 JSON、算坐标、推锚点、切帧、分 bucket。可 TDD,沿用既有工具链约定(node:test、零第三方依赖、显式路径提交)。

编辑器侧先用 MCP 跑通一个界面(建议 Login_Layer,18 个对象,规模合适且链路完整),确认节点树、Widget、SpriteFrame 引用无误,再决定是否值得写编辑器扩展批量跑完其余 54 个。不预先写扩展——先证明单条链路成立。

为何不直接用 Node 生成 .prefab:那要手写 UUID 引用的序列化格式,直接违反 §0.5 红线;991 个对象 × 55 个 prefab,格式错一点就是「资源导入失败」且只能人工恢复。

4.2 验证:分四层

991 个对象、1306 张图无法靠肉眼验证,故分层且尽量自动:

① 几何与规则层(自动,TDD) 坐标换算、三区锚点推断、bucket 归属——纯函数,用已知样例断言(如「通知」按钮的 Left/Top → 期望的 Cocos 坐标与 Widget 值写死在测试中)。

② 切图正确性(自动,且是完全验证) 切分是可逆操作:把切出的帧按原网格重组,必须与原图逐字节相同。

切分(原图) → N 帧 → 重组(N 帧) ≡ 原图(逐字节)

对全部 139 张多帧图跑重组校验,即对全部 1006 帧的完全验证,成本近零。行优先/1 基这类错误会被当场抓住——比靠读图判断可靠得多。

③ prefab 结构(自动) 落地后回读 prefab:节点数、层级归属(group → 父节点)、z 序、Widget 参数,与中间描述逐项比对,差异即报错。

④ 视觉验证(半自动,抽样) 用 MCP cocos_capture 截图,与原项目同界面截图并排比对。有 ①②③ 兜底后,这一层只需回答「规则本身定得对不对」(某按钮该锚右却锚了中),而非「转换器有没有 bug」。

4.3 与已交付工具链的衔接

切出的 1306 张图落进 framework/ui/atlas-*,正是 2026-08-27-ui-asset-and-skin-design.md §3.1 定义的可覆盖白名单那 8 个目录。因此:

  • check-skin 的「框架可覆盖资源」将从当前 0 个变为 1306 个——换肤机制首次有真实对象
  • 该 spec §6 中三条一直 ⏸ 的承重假设(Auto Atlas 构建期打包、meta 尺寸行为、Spine)首次有真实素材可验证

A 落地时应顺手复测这些假设,而非任其继续悬挂。

4.4 幂等与可重跑

  • 中间描述是纯函数产物,转换器可任意重跑
  • prefab 落地以层为单位,支持局部重跑(改了某界面的规则只重落该层)
  • 散图切分同样幂等;重跑前需清理旧产物,避免残留帧文件混入图集

5. 待验证项

# 待验证 验证方法 失败后的备选
1 Auto Atlas 尺寸上限:1306 张图分 4 bucket,某 bucket 可能超单张图集 2048×2048 落地后构建,检查图集张数与尺寸 按加载时机进一步细分 bucket(如 atlas-hall-1/2)
2 包体:1306 张图全部进每个子游戏包 第一次真实构建后量实际包体 评估是否将低频界面(如 Help/Protol)转为远程 bundle
3 MCP 批量建节点的可行性与耗时(991 节点) 先跑 Login_Layer 18 个对象计时外推 写编辑器扩展批量落地
4 三区锚点规则的实际命中率 抽样 5 个界面视觉比对 调整分界阈值,或改为逐元素人工标注

6. 非目标(YAGNI)

  • 不做嵌套节点树的进一步细分。转换只产出「Layer → Group → 对象」两层结构;更细的节点树由用户在编辑器中人工组织。
  • 不识别按钮/交互组件。事件信息保留在 legacy,由 C 处理。
  • 不迁移任何平台逻辑。本 spec 只做静态 UI;逻辑属 C。
  • 不处理图片本身(背景拉伸只设 Widget,不做缩放/重采样;美术后续手动更换)。
  • 不重命名资源为语义名(§3.6)。
  • 不做逐帧动画的时间轴。flags.animation 只做标记。

7. 与其它 spec 的接口

  • 2026-08-27-ui-asset-and-skin-design.md:切出的散图落入其定义的 8 个可覆盖目录;bucket 划分沿用其 §2 规则 3 的目录表;label 样式取自其 §4 的 theme.fonts。
  • 子系统 B(引擎机制映射,待写):需要 A 产出的 legacy.objectId / groupId / events;其产出的属性号对照表是 C 的前置。
  • 子系统 C(平台逻辑移植,待写):依赖 A 的 ObjectID → 节点路径全局索引(§1.3)与 B 的 API 映射表。
  • 组件替换 spec(待写):SeatView / 按人数布局。牌桌内的 Player_Head_Score_Layer(60 个对象)由 A 转出静态版本后,该 spec 决定哪些改为按人数动态生成。