Files
erqiwang_youle/docs/client/development-guide/02-渲染与UI组件体系.md
T
joywayerandClaude Opus 4.8 363f97cdbe 文档修复:移除指向不存在文件的失效链接引用
client/02、client/03 中指向 docs/important/client/友乐游戏引擎精灵与资源管理接口规范.md
(该文件不在仓库)的 markdown 链接改为纯文字规范名,保留文意、去除失效链接。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:51:28 +08:00

17 KiB
Raw Blame History

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) 存在性检查
// 显示手牌:一个精灵切帧表现不同牌面
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 范围与注释格式细则以《友乐游戏引擎精灵与资源管理接口规范》为准。)

三者如何配合(新增一块 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,获得统一的生命周期与事件自动清理。

标准结构

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(防内存泄漏)

// ❌ 直接 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() 才清理监听。

组件数据自持 + set-refresh 范式(核心)

前端只负责界面展示与玩家交互;所有核心运算与胜负裁定以服务端为准、数据以服务端为权威(见 01/05)。组件本地保存的只是「用于画界面的数据副本」,据此渲染,不做权威计算。为此每个组件遵循统一范式:

  1. 数据集中在 this.data:组件(及其下每个「零件 UI」)的自有数据全部收拢到 this.data.*,不散落在实例其他字段或全局;界面能否重建只取决于 this.data。

  2. 每个 UI 都有 setXxx / refreshXxx 成对方法:组件本身、以及组件内每个「零件 UI」(如玩家信息条、分数、手牌区、按钮组)都提供一对——

    • setXxx(...):只写数据到 this.data,不碰精灵;
    • refreshXxx():只据 this.data 画界面,不改数据。 两者职责单一、互不越界。
  3. 组件有一个总 refresh():顺序调用其下所有零件的 refreshXxx(),把整块界面据 this.data 完整重画。任何时候调用 refresh() 都能无歧义重建正确界面(断线重连、切 app 回来、网页刷新都复用它,不另写一套渲染)。

var GameView = Object.create(BaseComponent);
GameView.init = function (config) {
    this.data = { players: [], score: 0 /* ...自有数据只放这里 */ };
    // ...
};

// 零件 UI:分数。set 只写数据,refresh 只据数据画
GameView.setScore     = function (v) { this.data.score = v; };
GameView.refreshScore = function () { SpriteManager.setText(SCORE_ID, this.data.score); };

// 总 refresh:据 this.data 重画所有零件
GameView.refresh = function () {
    this.refreshPlayers();
    this.refreshScore();
    // ...其余零件
};

收包的正确流程:先 setXxx 把数据写进 this.data(必要时 refresh 刷新静态界面),再做动画/表现。动画只是体验层,其「开始/结束/出错」等生命周期回调里只刷新界面、绝不设置核心数据。这样即使动画卡住/播错、或开发前期还没做动画,数据、逻辑与界面依然正确、互不影响。


4. UIManager:注册与场景切换

UIManager 管理所有组件实例与场景(一组组件的具名快照)。

// 启动时(在启动编排内)
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 时传入配置:

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 注册回调

// 在组件 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 事件都回调它,但不认识其业务含义,保持中立:

// 例:某种标记叠绘——子游戏自行挂载,框架零感知
SpriteEventController.registerGlobalDraw(function (spriteId) {
    // 子游戏自定义的叠绘逻辑
});

这套通用精灵事件分发原先躺在子游戏 controllers 里、还被框架反向依赖;现已抽进框架,并把「精牌标记」这类玩法耦合改为全局 draw 钩子(见 05「框架中立」)。

更高层:手势识别 SpriteGestureRecognizer

需要识别双击 / 滑动 / 点击等手势时,用框架 system/SpriteGestureRecognizer.js(建立在 SpriteEventController 之上)。它把原始按下/移动/松开识别为语义手势,业务只写回调:

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。
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 讲表现系统:事件总线、动画、音频与 Spine。