- CD_LEFT/CD_RIGHT/CD_SELF(1150-1152)由文字精灵改为多帧图数字精灵: 资源 NUM_COUNTDOWN,纯秒数无后缀;对应布局节点 P_COUNTDOWN 的 h 改取 NUM_STYLE.COUNTDOWN.charHeight,w 留到运行时注入。 - 结算得分 RESULT_SCORE_LEFT/RIGHT/SELF(1805-1807)同理由文字精灵改为 数字图精灵(NUM_RESULT_WIN/NUM_RESULT_LOSE),与大局结算已有的 ACC_TPL_SCORE_NUM 保持一致;布局节点随之调整,移除不再使用的 TEXT_STYLE.SCORE_BIG。 - NUM_CALL_SCORE 第16帧由"待美术与规则确认"改为明确的"无后缀"(叫分档位 面板只显示纯数字,"N子"角标是独立文字精灵);NUM_MAIN_SUIT_COUNT / NUM_RESULT_WIN / NUM_RESULT_LOSE 第16帧仍待确认,不改。 - Layout_Action.js 的 CALL_BTN_SCORE_TEXT、Layout_Result.js 的 RESULT_SCORE_TEXT 改名为 CALL_BTN_SCORE_NUM / RESULT_SCORE_NUM: 两者驱动的都是数字图精灵,_TEXT 后缀误导;同步测试引用。 - docs/client/development-guide/02:订正"SpriteManager 第1154行"这一失效 行号引用(回退后该行号落在 setTextWithWidth 体内),改为不写死行号。 - docs_dev 清单同步:§2.4/§5.4/§5.6/§6.6/§6.9 倒计时与结算得分的精灵类型 描述、§6.6/§6.9 对应布局参数。 - test_constants.js 的 NUM_STYLE 同源守卫表新增 COUNTDOWN、RESULT_WIN、 RESULT_LOSE 三对(连同改名后的 CALL_BTN_SCORE_NUM),共 6 条新断言。 精灵总数、布局节点总数均未变(378 / 61),只改类型与命名,不增删。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 KiB
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(id, text, charWidth) |
文字 + 自动定宽 | 文字精灵:设文本并把宽度设为「字符数 × charWidth」,中文按 2 个字符计 |
setNumberImage(id, text, charWidth) |
多帧图美术字数字 | 多帧图片精灵:一个精灵显示整串(见下) |
drawImage(id, imgId, dx,dy,dw,dh) |
在精灵上叠绘整张图 | 只能在绘制回调里调(见 §5),用于标记叠加 |
showGroup/hideGroup(gid) |
群组批量 | 整块 UI 显隐 |
showLayer/hideLayer(lid) |
图层批量 | 整个界面显隐 |
exists(id) |
存在性检查 |
// 一精灵切帧表现不同牌面;刷新弃牌区先全 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。
三种显示机制:文字精灵 / 多帧图片精灵切帧 / 多帧图数字
同样是"在精灵上显示内容",SpriteManager 给了三条路,能力边界完全不同,选错了会在后期变成返工:
| 机制 | API | 一次能显示 | 字形 | 适用 |
|---|---|---|---|---|
| 文字精灵 | setText(id, text)、setTextWithWidth(id, text, charWidth) |
任意长度的文本 | 系统字体(只能调字号/颜色,做不出描边、渐变、投影) | 纯数据型文本:昵称、局数、N对/主N 角标、2子、提示文案 |
| 多帧图片精灵切帧 | setFrame(id, frame) |
一帧(整张精灵就是那一帧) | 美术出图 | "选其一"的状态:按钮可用/置灰、单选框选中/未选、花色图标、牌面(一张牌 = 一帧) |
| 多帧图数字 | setNumberImage(id, text, charWidth) |
整串(多位数字 + 符号 + 后缀),都在同一个精灵里 | 美术出图 | 多位美术字数字:倒计时、结算得分、叫分档位分数、张数、比分 |
选用判据(按顺序问自己三个问题):
- 要不要美术字形(描边/渐变/投影)?不要 → 文字精灵,到此为止,最省。需要按字符数定宽就用
setTextWithWidth(中文按 2 个字符计)。 - 要美术字形,显示的是**"若干形态里选一个"**吗(一张牌、一个图标、一个按钮态)?是 →
setFrame。 - 要美术字形,显示的是一串数字/符号(可能两位、带正负号、带
分/张后缀)?→setNumberImage。
⚠️ 最容易踩的坑:显示多位数字时拆成多个精灵,或用
setFrame逐位切。setFrame是"整个精灵显示第 N 帧",一个精灵一次只显示一位。有人据此把两位数拆成"十位精灵 + 个位精灵",三位数拆三个——精灵数随位数线性膨胀,布局、显隐、清理全要按位处理(十位为 0 还得单独隐藏),位数一变就得重排。 平台早就给了setNumberImage,一个精灵显示整串,别再造这个轮子。 真实事故:某次评审误判"一个精灵只能显示一位",先把选主张数拆成十位/个位 8 个精灵,随后又差点为此新造一套"图集叠绘"渲染器——直到发现core/SpriteManager.js的setNumberImage本来就有这个接口。 判据一句话:值可能超过一个字符,用setNumberImage,既不拆精灵也不用setFrame。
setNumberImage 怎么工作(读实现,不要凭印象):它把文本里的符号编码后设给精灵的 TEXT 属性,由引擎按字符逐帧渲染(帧号由字符直接换算),并把精灵宽度设为 字符数 × charWidth。所以:
// 资源须是 16 帧、帧序固定为 0123456789.+-x/p 的多帧图片
SpriteManager.setNumberImage(spriteId, '70', charWidth); // 叫分档位分数(两位数,一个精灵)
SpriteManager.setNumberImage(spriteId, '+120', charWidth); // 结算得分(帧12 = '+'、帧13 = '-')
SpriteManager.setNumberImage(spriteId, '26张', charWidth); // 带后缀('张' 落在第 16 帧)
// charWidth 来自常量文件,禁裸值;精灵宽度由该接口自动设,无需再 setSize
资源规格(写进图片资源常量的注释里,供美术照做):
| 帧 | 1–10 | 11 | 12 | 13 | 14 | 15 | 16 |
|---|---|---|---|---|---|---|---|
| 内容 | 0–9 |
. |
+ |
- |
x |
/ |
可变后缀(每套资源自己的后缀字,只支持 分 / 倍 / 张) |
- 帧序不可重排、不可省略占位:帧号由字符直接换算,用不到的帧位也要按顺序留出来,否则帧号对不上、画面串位。
- 16 帧等宽(后缀字与数字同宽),整图宽 =
16 × 单字符宽;单字符宽即代码里传的charWidth。 - 第 16 帧的后缀只认
分/倍/张这三个字(接口内部把它们统一编码到该帧),别的字写进去不会被识别。 - 复制精灵也能用:
setNumberImage走的是属性设置、不依赖绘制回调,SpriteCopyUtils复制出的字符串 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,获得统一的生命周期与事件自动清理。
标准结构与生命周期
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)。组件本地保存的只是「用于画界面的数据副本」,据此渲染、不做权威计算。为此每个组件遵循统一范式:
- 数据集中在
this.data:组件及其下每个「零件 UI」的自有数据全部收拢到this.data.*,不散落在实例其他字段或全局;界面能否重建只取决于this.data。 - 每个 UI 都有
setXxx/refreshXxx成对方法:setXxx(...)只写数据到this.data、不碰精灵;refreshXxx()只据this.data画界面、不改数据。两者职责单一、互不越界。 - 组件有一个总
refresh():顺序调用其下所有零件的refreshXxx(),据this.data完整重画。任何时候调refresh()都能无歧义重建正确界面(断线重连、切 app 回来、网页刷新都复用它,不另写一套渲染)。 - 显隐走
showXxx/hideXxx接口:组件(及每个「零件 UI」)的精灵/群组显隐,由组件自身暴露的showXxx()/hideXxx()语义方法控制;外部只调这些接口,禁止在别处直接用该组件/零件的精灵 ID、群组 ID 去SpriteManager.show/hide(或showGroup/hideGroup)——那样绕过组件、状态分散,重连/刷新时不一致。
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) 切换(自动隐藏旧场景组件、显示新场景组件)。
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)):
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」分发到注册回调。
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。
⚠️ 转发这一段必须真的接上,且参数顺序要逐个对齐
handleXxx的签名。转发缺失或参数错位不会报错,只会让所有精灵交互与绘制回调静默失效——按钮点了没反应、叠绘的标记一片空白,却查不到任何异常日志。接线时对着SpriteEventController里各handleXxx的形参表逐个核对,转发层只转发、不写业务。
对每个绘制精灵统一处理(不针对某个固定 ID)用 registerGlobalDraw(fn)——框架对每个 draw 事件都回调它、但不认识其业务含义,保持中立(“精牌标记”这类玩法叠绘即以此挂载,框架零感知,见 05「框架中立」)。
更高层:手势识别 SpriteGestureRecognizer
识别双击 / 滑动 / 点击等手势用框架 system/SpriteGestureRecognizer.js(建在 SpriteEventController 之上),把原始按下/移动/松开识别为语义手势,业务只写回调:
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。
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 切换状态 |
为每种牌面建一个精灵 |
多位美术字数字用 setNumberImage,一个精灵显示整串 |
用 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。