Files
erqiwang_youle/docs/client/development-guide/02-渲染与UI组件体系.md
T
2026-07-06 17:16:05 +08:00

229 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 02 · 渲染与 UI 组件体系
本篇讲**界面怎么搭**:精灵 ID 体系与分层调用、资源/布局常量三件套、UI 组件的生命周期、场景切换与动态列表。这是前端日常写得最多的部分。
> 麻将作举例(手牌、弃牌、吃碰杠按钮),但精灵/常量/组件的范式对任意子游戏一致。
---
## 1. 精灵与渲染分层
gameabc 的画面由**精灵(Sprite)**组成,按**图层(Layer)**、**群组(Group)**组织。操作精灵走一条严格分层链:
```
UI 组件 → SpriteManager(业务级 API:ID 范围校验 + 单位换算)
→ GameABCUtils(唯一直接调引擎原生 API 的模块) → gameabc.min.js(引擎)
```
**铁律:UI 代码只调 `SpriteManager`,禁止直接调 `GameABCUtils` 或引擎原生 API**(`set_self`/`get_self` 等)。ID 校验、单位换算、引擎版本隔离都集中在这层边界,业务层无感。
### SpriteManager 常用 API
| API | 作用 | 说明 |
|-----|------|------|
| `show(id)` / `hide(id)` | 显示/隐藏 | 返回 `boolean`,**失败要查返回值** |
| `setFrame(id, frame)` | 切换多帧图片的帧 | 一精灵多帧、靠切帧表现不同牌面,**胜过为每张牌建一个精灵** |
| `setPosition(id, x, y)` | 设置坐标 | |
| `setScale(id, scale)` | 缩放 | 业务用倍数(`1.2`),框架自动转引擎百分比 |
| `setOpacity(id, o)` | 透明度 | 业务用 `0.0–1.0`,框架自动转 `0–255` |
| `setText(id, text)` / `setTextWithWidth(...)` | 文字 | 文字精灵 |
| `showGroup/hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
| `showLayer/hideLayer(lid)` | 图层批量 | 整个界面显隐 |
| `exists(id)` | 存在性检查 | |
```js
// 一精灵切帧表现不同牌面;刷新弃牌区先全 hide 再按数据 show + setFrame(帧号 = code-1)
SpriteManager.show(sid); SpriteManager.setFrame(sid, card.code - 1);
```
### 精灵 ID 二元性
精灵 ID 有两种,`SpriteManager` 透明支持、无需区分:**数字 ID**(编辑器预置,如 `1121`)与**字符串 ID**(运行时由 `SpriteCopyUtils` 动态复制,格式 `"容器IDadd标签"`,如 `"2836add0"`)。
### ID 范围(必须遵守)
| 资源 | 子游戏可用范围 | 框架保留 |
|------|----------------|----------|
| 精灵 ID | **1001–3000** | 1–1000、3001+ |
| 群组 ID | **≥ 201** | 1–200 |
| 图文资源 | **≥ 1001** | 1–1000 |
| 图片资源 | **≥ 501** | 1–500 |
| 声音 | **≥ 101** | 1–100 |
| 图层 | **101–200、301–400、501–600、701+** | 1–100、201–300、401–500、601–700 |
图层按 100 为段与框架交替分配:子游戏用 `101–200`(常规界面)、`301–400`(弹窗,示例用 302)、`501–600`、`701+`;其余段框架保留,**不可占用**。
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
---
## 2. 资源与布局常量三件套
界面所需的“数据”全部集中在常量文件里,业务代码只引用、不内联裸值。三类常量各司其职,外加一个**整合入口**统一为一份精灵常量并**最后加载**:
| 常量类别 | 管什么 | 一句话 |
|----------|--------|--------|
| 精灵结构常量 | **精灵 ID 与结构** | 每个界面有哪些精灵、归哪个图层/群组 |
| 图片资源常量 | **图片资源 ID** | 每个精灵用哪张图、几帧、每帧什么、尺寸 |
| 布局常量 | **坐标/尺寸/偏移** | 纯数据,精灵摆在哪、多大、怎么排 |
### 资源手动创建,常量靠注释指路
**精灵、图片、声音、Spine 资源全部由开发者手动创建**——精灵在编辑器里逐个建好,图片/音频/Spine 文件按平台规范放入资源目录;**代码不创建任何资源,只按常量 ID 引用**,ID 必须与编辑器/资源目录里实际存在的完全一致(见 §1,绝不编造)。因此**常量注释必须写准,让人据注释就能准确创建对应资源**:
- **精灵结构常量**:每个精灵注明【精灵类型(图片精灵 / 文字精灵)+ 功能 + 关联的图片资源 ID + 帧数说明】。
- **图片资源常量**:每个资源注明【帧数(单帧 / N 帧,且每帧含义)+ 用途 + 尺寸】。
(声音、Spine 资源的注释要求见 03。资源 ID 范围与注释格式细则以平台的精灵与资源管理接口规范为准。)
### 三者如何配合(新增一块 UI 的流程)
以“新增一个弹窗”为例:① **精灵结构常量**定义精灵结构与 ID(弹窗图层 301–400 段、群组、各精灵 1001–3000);② **图片资源常量**定义所需图片资源 ID(背景、行模板…),注明帧数/帧义/尺寸;③ **布局常量**定义坐标尺寸(列表容器、行高、各列偏移);④ **整合入口**并入统一的精灵常量;⑤ **写组件**只引用精灵常量与 `SpriteManager`,不出现任何裸数字。
### 几条约定
- **按钮只要 1 帧普通态图**:点击的缩放/透明反馈由框架统一处理,不为“按下态”单独配图。
- **帧动画只在图片资源常量记资源 ID**,帧区间/帧间隔/循环等播放参数放动画配置(见 03),不在资源文件重复。
- **布局文件是纯数据**:无函数、无副作用。侧视角的“透视倾斜”用每张牌累积的透视偏移量表达。
- **多文件用保护性声明** `var XxxConstants = XxxConstants || {};`,便于拆到主界面/弹窗多个文件、加载后合并为一份。
> 模板 `gameabc-framework/templates/*.template.js` 是这三类文件的**写法蓝本**(含字段注释与 `@see` 指向实现文件)。派生新子游戏时复制模板再按实际资源填充。
---
## 3. UI 组件:BaseComponent
所有视图组件继承框架的 `BaseComponent`,获得统一的**生命周期**与**事件自动清理**。
### 标准结构与生命周期
```js
var MyView = Object.create(BaseComponent);
MyView.init = function (config) {
// ⚠️ 必须在 init 内重新声明实例属性,避免原型共享污染
this.sprites = []; this.eventListeners = {}; this.data = { /* 自有数据只放这里,见 §3 范式 */ };
this.layer = config.layer || 102;
this.addSprite(spriteConstants.SOME_PANEL_BG); // 精灵 ID 从常量取(见 §2)
var self = this;
this.addEventListener(EventBus.Events.GAME_STARTED, function (d) { self._onGameStarted(d); });
this.isInitialized = true;
};
```
| 钩子 | 何时 | 是否覆盖 |
|------|------|----------|
| `init(config)` | 创建时 | ✅ 子类必须实现:建精灵、注册事件 |
| `show()` / `hide()` | 显隐 | ❌ 通常不覆盖(内部显隐图层并回调 `onShow/onHide`) |
| `destroy()` | 销毁 | ❌ 不覆盖(自动清理事件监听、隐藏精灵,回调 `onDestroy`) |
| `onShow/onHide/onDestroy` | 上述内部末尾 | ✅ 可选:播放动画、清定时器/动态精灵 |
### 事件必须走 `addEventListener`(防内存泄漏)
直接 `EventBus.on(...)` 在 `destroy` 时不会自动 `off`、监听残留 → 泄漏;一律改用 `this.addEventListener(...)`:它包装处理函数(绑定 `this`、捕获异常、销毁后忽略事件)并记录下来,在 `destroy()` 时统一 `EventBus.off`。**销毁组件用 `destroy()` 而非只 `hide()`**——只有 `destroy()` 才清理监听。
### 组件数据自持 + set-refresh 范式(核心)
**前端只负责界面展示与玩家交互;所有核心运算与胜负裁定以服务端为准、数据以服务端为权威**(见 01/05)。组件本地保存的只是「用于画界面的数据副本」,据此渲染、不做权威计算。为此每个组件遵循统一范式:
1. **数据集中在 `this.data`**:组件及其下每个「零件 UI」的自有数据全部收拢到 `this.data.*`,**不散落**在实例其他字段或全局;界面能否重建只取决于 `this.data`。
2. **每个 UI 都有 `setXxx` / `refreshXxx` 成对方法**:`setXxx(...)` **只写数据**到 `this.data`、不碰精灵;`refreshXxx()` **只据 `this.data` 画界面**、不改数据。两者职责单一、互不越界。
3. **组件有一个总 `refresh()`**:顺序调用其下所有零件的 `refreshXxx()`,据 `this.data` 完整重画。**任何时候调 `refresh()` 都能无歧义重建正确界面**(断线重连、切 app 回来、网页刷新都复用它,不另写一套渲染)。
```js
GameView.setScore = function (v) { this.data.score = v; }; // 只写数据
GameView.refreshScore = function () { SpriteManager.setText(SCORE_ID, this.data.score); }; // 只据数据画
GameView.refresh = function () { this.refreshPlayers(); this.refreshScore(); /* ...其余零件 */ };
```
**收包的正确流程**:先 `setXxx` 把数据写进 `this.data`(必要时 `refresh` 刷新静态界面),**再**做动画/表现。动画只是体验层,其「开始/结束/出错」等生命周期回调里**只刷新界面、绝不设置核心数据**。这样即使动画卡住/播错、或开发前期还没做动画,数据、逻辑与界面依然正确、互不影响。
---
## 4. UIManager:注册与场景切换
`UIManager` 管理所有组件实例与**场景**(一组组件的具名快照):`registerComponent(name, comp)` 注册组件、`registerScene(sceneName, [组件名...])` 注册场景、`switchToScene(name)` 切换(自动隐藏旧场景组件、显示新场景组件)。
```js
UIManager.init(config); // 见下:注入全局 UI 常量
UIManager.registerComponent('GameView', MyView.create({ name: 'GameView', layer: 102 }));
UIManager.registerScene(UIManager.SCENES.GAME, ['GameView', 'SomeOtherView']);
UIManager.switchToScene(UIManager.SCENES.GAME);
```
全局 UI `showLoading/hideLoading`、`showMessage`、`showConfirm` 的精灵常量由**子游戏注入**——框架不读任何子游戏全局名,子游戏在 `init` 时传入(不传则跳过全局 UI;也可后续 `UIManager.configureGlobalUI(config)`):
```js
UIManager.init({ loadingUI: spriteConstants.LOADING_UI, messageUI: spriteConstants.MESSAGE_UI, confirmUI: spriteConstants.CONFIRM_UI });
```
**游戏专属弹窗**用“向 UIManager 挂方法”的扩展模式(独立 JS 里 `UIManager.showCreateRoom = function(){...}`),保持框架本身中立。
---
## 5. 精灵交互事件:SpriteEventController
精灵的点击/拖拽/绘制交互由框架 `gameabc-framework/system/SpriteEventController.js` 统一分发——**游戏中立**,玩法专属逻辑通过钩子注册、不写进框架。平台引擎交互先进受限入口 `Game_Modify.utlmousedown/mouseup/utlmousemove/utlgamemydrawbegin/gamemydraw`,这些入口**只把事件转调**给 `SpriteEventController.handle*`,由它按「精灵 ID」分发到注册回调。
```js
SpriteEventController.registerMouseUp(sprites.BTN_START, function (event) { self._onStart(); }); // event 含 spriteId/坐标/偏移
SpriteEventController.registerMouseMove(sprites.HAND_AREA, function (event) { self._onDrag(event.offset); });
// 批量 registerBatch(spriteIds, 'mouseDown', handler);注销 unregister(spriteId[, eventType])
```
支持的事件类型:`mouseDown` / `mouseDownNoMove`(长按) / `mouseUp` / `mouseMove`(拖拽) / `drawBegin` / `draw`。
对**每个**绘制精灵统一处理(不针对某个固定 ID)用 `registerGlobalDraw(fn)`——框架对每个 draw 事件都回调它、但**不认识**其业务含义,保持中立(“精牌标记”这类玩法叠绘即以此挂载,框架零感知,见 05「框架中立」)。
### 更高层:手势识别 `SpriteGestureRecognizer`
识别**双击 / 滑动 / 点击**等手势用框架 `system/SpriteGestureRecognizer.js`(建在 `SpriteEventController` 之上),把原始按下/移动/松开识别为语义手势,业务只写回调:
```js
var handle = SpriteGestureRecognizer.attach(spriteIds, {
onPress: fn, // 按下即时反馈(如元素站起)
onDoubleTap: fn, // 双击同一精灵
onSwipe: fn, // 沿 e.direction 滑动越阈
onTap: fn // 点击(小位移按下→松开)
}, { doubleTapInterval: 300, swipe: { direction: 'up', threshold: 50 }, minMove: 10 });
// handle.detach() / resetDoubleTap() / resetDrag()
```
阈值由子游戏注入(框架只给默认值、不反读子游戏常量);双击/上滑/点击判定全在框架,选中/出牌等业务全留子游戏。
---
## 6. 动态列表:SpriteCopyUtils 与 DynamicSpriteList
行数不定的列表(听牌提示、战绩行)用**动态复制精灵**实现,不为每行预置精灵:
- **`SpriteCopyUtils`**(底层):从“模板精灵”复制出带 `tag` 的子精灵、返回字符串 ID;`create/remove/removeRange`。
- **`DynamicSpriteList`**(高级):封装容器裁剪区、行高、每列模板、滚动与点击,开发者只 `setData` + `onClick`。
```js
var list = new DynamicSpriteList({
containerId: spriteConstants.SOME_LIST_CONTAINER, clipArea: { x: 0, y: 0, width: 640, height: 480 }, rowHeight: 80,
templates: { rowBg: { spriteId: 2837 }, card: { spriteId: 2838, offset: { x: 20, y: 10 } }, score: { spriteId: 2839, offset: { x: 120, y: 10 } } }
});
list.setData(rows); // 数据驱动
list.onClick = function (type, rowIndex, rowData) { /* ... */ };
// 组件 onDestroy 中:list.destroy(); ← 必须,否则复制精灵残留
```
---
## 7. 本篇 DO / DON'T
| DO ✅ | DON'T ❌ |
|------|---------|
| UI 只调 `SpriteManager` | 直接调 `GameABCUtils`/引擎原生 API |
| 一精灵多帧、`setFrame` 切换 | 为每种牌面建一个精灵 |
| 精灵 ID 1001–3000、群组 ≥201、从编辑器查证 | 随意编造 ID / 超范围 ID |
| 检查 `SpriteManager` 返回值 | 忽略 `false` 返回 |
| 精灵/资源/坐标全进常量文件 | 在业务代码内联裸数字 |
| 组件继承 `BaseComponent`,事件用 `addEventListener` | 自建组件类 / 直接 `EventBus.on` |
| 自有数据集中 `this.data`;每个 UI/零件有 set/refresh 对;总 `refresh` 可据数据重建界面 | 数据散落各处 / `set` 里画界面 / `refresh` 里改数据 / 动画回调写核心数据 |
| 销毁用 `destroy()`,`onDestroy` 清动态精灵与定时器 | 只 `hide()` 就当销毁 |
| 动态列表用 `DynamicSpriteList` 并 `destroy` | 手搓 SpriteCopy 又忘清理 |
下一篇 [03-事件·动画·音频·Spine](./03-事件·动画·音频·Spine.md) 讲表现系统:事件总线、动画、音频与 Spine。