Files
erqiwang_youle/docs/client/development-guide/02-渲染与UI组件体系.md
T

303 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(gid)` / `hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
| `showLayer(lid)` / `hideLayer(lid)` | 图层批量 | 整个界面显隐 |
| `exists(id)` | 存在性检查 | |
```js
// 显示手牌:一个精灵切帧表现不同牌面
for (var i = 0; i < handCards.length; i++) {
var sid = handSpriteIds[i];
SpriteManager.show(sid);
SpriteManager.setFrame(sid, handCards[i].code - 1); // 帧号 = code-1
}
// 刷新弃牌区:先全隐藏,再按数据显示
for (var k = 0; k < maxDiscards; k++) { SpriteManager.hide(discardIds[k]); }
for (var j = 0; j < discards.length; j++) {
SpriteManager.show(discardIds[j]);
SpriteManager.setFrame(discardIds[j], discards[j].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 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `SpriteManager` 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
---
## 2. 资源与布局常量三件套
界面所需的“数据”全部集中在常量文件里,业务代码只引用、不内联裸值。三类常量各司其职:
| 常量类别 | 管什么 | 一句话 |
|----------|--------|--------|
| 精灵结构常量 | **精灵 ID 与结构** | 每个界面有哪些精灵、归哪个图层/群组 |
| 图片资源常量 | **图片资源 ID** | 每个精灵用哪张图、几帧、每帧什么、尺寸 |
| 布局常量 | **坐标/尺寸/偏移** | 纯数据,精灵摆在哪、多大、怎么排 |
外加一个**整合入口**把三类统一为一份精灵常量,并**最后加载**。
### 资源与精灵手动创建,常量靠注释指路
**精灵、图片、声音、Spine 资源全部由开发者手动创建**——精灵在游戏编辑器里逐个建好,图片/音频/Spine 文件按平台规范放入资源目录;**代码不创建任何资源,只按常量 ID 引用**,ID 必须与编辑器/资源目录里实际存在的**完全一致**(见 §1,绝不编造)。
正因资源是"先手动建、再按 ID 引用",**常量定义必须写准注释,让人据注释就能准确创建出对应资源**:
- **精灵结构常量**:每个精灵注明【精灵类型(图片精灵 / 文字精灵)+ 功能 + 关联的图片资源 ID + 帧数说明】。
- **图片资源常量**:每个资源注明【帧数(单帧 / N 帧,且每帧含义)+ 用途 + 尺寸】。
(声音、Spine 资源的注释要求见 03。资源 ID 范围与注释格式细则以《[友乐游戏引擎精灵与资源管理接口规范](../../../docs/important/client/友乐游戏引擎精灵与资源管理接口规范.md)》为准。)
### 三者如何配合(新增一块 UI 的流程)
以“新增一个弹窗”为例:
1. **精灵结构常量**:定义精灵结构与 ID(弹窗图层 301–400 段、群组、各精灵 ID 1001–3000)。
2. **图片资源常量**:定义所需图片资源 ID(背景、行模板…),注明帧数/帧义/尺寸。
3. **布局常量**:定义坐标尺寸(列表容器、行高、各列偏移)。
4. **整合入口**:把新结构并入统一的精灵常量。
5. **写组件**:UI 代码只引用精灵常量与 `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.isVisible = false;
this.isInitialized = false;
this.isDestroyed = false;
this.name = config.name || 'MyView';
this.layer = config.layer || 102;
// 创建精灵(ID 从精灵常量取,见 §2)
this.addSprite(spriteConstants.SOME_PANEL_BG);
// 注册事件(用 addEventListener,destroy 时自动清理)
var self = this;
this.addEventListener(EventBus.Events.GAME_STARTED, function (data) {
self._onGameStarted(data);
});
this.isInitialized = true;
};
```
### 生命周期钩子
| 钩子 | 何时 | 是否覆盖 |
|------|------|----------|
| `init(config)` | 创建时 | ✅ 子类必须实现:建精灵、注册事件 |
| `show()` / `hide()` | 显隐 | ❌ 通常不覆盖(内部显隐图层并回调 `onShow/onHide`) |
| `destroy()` | 销毁 | ❌ 不覆盖(自动清理事件监听、隐藏精灵,回调 `onDestroy`) |
| `onShow/onHide/onDestroy` | 上述内部末尾 | ✅ 可选:播放动画、清定时器/动态精灵 |
### 事件必须走 `addEventListener`(防内存泄漏)
```js
// ❌ 直接 EventBus.on:destroy 时不会自动 off,监听残留 → 泄漏
EventBus.on(EventBus.Events.GAME_STARTED, fn);
// ✅ BaseComponent.addEventListener:destroy() 自动 off
this.addEventListener(EventBus.Events.GAME_STARTED, fn);
```
`BaseComponent.addEventListener` 会包装处理函数(绑定 `this`、捕获异常、销毁后忽略事件),并记录下来在 `destroy()` 时统一 `EventBus.off`。**销毁组件用 `destroy()` 而非只 `hide()`** —— 只有 `destroy()` 才清理监听。
---
## 4. UIManager:注册与场景切换
`UIManager` 管理所有组件实例与**场景**(一组组件的具名快照)。
```js
// 启动时(在启动编排内)
UIManager.init(); // 若有 Loading/Message/Confirm 全局 UI,则注入其精灵常量(见下)
var gameView = MyView.create({ name: 'GameView', layer: 102 });
UIManager.registerComponent('GameView', gameView);
UIManager.registerComponent('RoomView', RoomView.create({ name: 'RoomView' }));
// 注册场景:场景名 → 组件名列表
UIManager.registerScene(UIManager.SCENES.GAME, ['GameView', 'SomeOtherView']);
UIManager.registerScene(UIManager.SCENES.ROOM, ['RoomView']);
// 切换场景:自动隐藏旧场景组件、显示新场景组件
UIManager.switchToScene(UIManager.SCENES.GAME);
```
`UIManager` 还提供全局 UI:`showLoading/hideLoading`、`showMessage`、`showConfirm`。这三个全局 UI 的精灵常量由**子游戏注入**——框架不读取任何子游戏全局名,子游戏在 `init` 时传入配置:
```js
UIManager.init({
loadingUI: spriteConstants.LOADING_UI,
messageUI: spriteConstants.MESSAGE_UI,
confirmUI: spriteConstants.CONFIRM_UI
}); // 不传则跳过全局 UI;也可后续 UIManager.configureGlobalUI(config)
```
**游戏专属弹窗**用“向 UIManager 挂方法”的扩展模式(在独立 JS 里 `UIManager.showCreateRoom = function(){...}`),保持框架本身中立。
---
## 5. 精灵交互事件:SpriteEventController
精灵的点击/拖拽/绘制等交互,由框架 `gameabc-framework/system/SpriteEventController.js` 统一分发——它是**游戏中立**的通用能力,玩法专属逻辑通过钩子注册,不写进框架。
平台引擎的交互事件先进受限入口 `Game_Modify.utlmousedown/mouseup/utlmousemove/utlgamemydrawbegin/gamemydraw`,这些入口**只把事件转调**给 `SpriteEventController.handle*`,由它按「精灵 ID」分发到注册的回调。
### 按精灵 ID 注册回调
```js
// 在组件 init 里注册(精灵 ID 从常量取,见 §2)
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`。
### 全局 draw 钩子
需要对**每个**绘制精灵统一处理(不针对某个固定 ID)的能力,用 `registerGlobalDraw` 注册——框架对每个 draw 事件都回调它,但**不认识**其业务含义,保持中立:
```js
// 例:某种标记叠绘——子游戏自行挂载,框架零感知
SpriteEventController.registerGlobalDraw(function (spriteId) {
// 子游戏自定义的叠绘逻辑
});
```
> 这套通用精灵事件分发原先躺在子游戏 `controllers` 里、还被框架反向依赖;现已抽进框架,并把「精牌标记」这类玩法耦合改为全局 draw 钩子(见 05「框架中立」)。
### 更高层:手势识别 `SpriteGestureRecognizer`
需要识别**双击 / 滑动 / 点击**等手势时,用框架 `system/SpriteGestureRecognizer.js`(建立在 `SpriteEventController` 之上)。它把原始按下/移动/松开识别为语义手势,业务只写回调:
```js
var handle = SpriteGestureRecognizer.attach(spriteIds, {
onPress: function (e) {}, // 按下即时反馈(如元素站起)
onDoubleTap: function (e) {}, // 双击同一精灵
onSwipe: function (e) {}, // 沿 e.direction 滑动越阈
onTap: function (e) {} // 点击(小位移按下→松开)
}, { 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` |
| 销毁用 `destroy()`,`onDestroy` 清动态精灵与定时器 | 只 `hide()` 就当销毁 |
| 动态列表用 `DynamicSpriteList` 并 `destroy` | 手搓 SpriteCopy 又忘清理 |
下一篇 [03-事件·动画·音频·Spine](./03-事件·动画·音频·Spine.md) 讲表现系统:事件总线、动画、音频与 Spine。
</content>