Files
erqiwang_youle/docs/client/development-guide/03-事件·动画·音频·Spine.md
T

185 lines
9.0 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.
# 03 · 事件 · 动画 · 音频 · Spine
本篇讲**表现系统**:模块间怎么用事件解耦、动画怎么发起与配置、音效/语音怎么分层、Spine 骨骼动画怎么播。四套系统的共同主线是——**框架提供中立能力,子游戏只填玩法专属的配置**。
---
## 1. EventBus:模块间事件总线
`EventBus` 是发布-订阅总线,用于解耦模块(控制器改完数据 `emit`,视图 `on` 后刷新),避免直接相互调用。
### 命名与中立原则
- 事件名两段式 `"模块:动作"`,统一定义在 `EventBus.Events` 常量里,**不硬编码字符串**。
- **框架不预置任何事件常量**:`gameabc-framework/system/EventBus.js` 只提供发布-订阅机制与一个**空容器** `EventBus.Events = {}`,不内置哪怕通用语义的事件名。
- **全部事件由子游戏集中定义**:在子游戏侧的事件常量文件把本子游戏用到的**全部**事件(通用语义 + 玩法专属)注册到 `EventBus.Events`,业务侧统一 `EventBus.Events.XXX` 引用。
```js
// 子游戏事件常量文件 —— 在 EventBus 之后、使用者之前加载
(function () {
if (typeof EventBus === 'undefined' || !EventBus.Events) {
throw new Error('[GameEvents] EventBus 未加载;请检查 index.html 加载顺序');
}
var E = EventBus.Events;
// 通用语义事件
E.GAME_STARTED = 'game:started';
E.PLAYER_DISCARDED = 'player:discarded';
E.SCENE_CHANGED = 'ui:sceneChanged';
// 玩法专属事件
E.TILES_DEALT = 'mahjong:tilesDealt';
E.MELD_FORMED = 'mahjong:meldFormed';
// ...本子游戏用到的全部事件
})();
```
> 这正是本仓库的落点:框架 `EventBus` 不预置任何事件常量、保持纯粹中立,所有事件(含通用语义)都由子游戏在其事件常量文件定义——既保证框架可被任意玩法复用,也避免「引用了未定义常量」导致的 `emit(undefined)`(见 05「框架中立」)。
### 用法
```js
EventBus.emit(EventBus.Events.GAME_STARTED, { playerCount: 4 }); // 发布
this.addEventListener(EventBus.Events.GAME_STARTED, function (d) { // 订阅(组件内)
self._onStart(d);
});
EventBus.once(EventBus.Events.GAME_FINISHED, fn); // 一次性
```
组件内**务必用 `BaseComponent.addEventListener`** 订阅(`destroy` 自动清理,见 02),不要裸用 `EventBus.on`。
---
## 2. 动画:AnimationManager + 配置
### 数据优先、表现延后(核心节奏)
收到服务端推送后的固定节奏:
```
1) 先更新数据模型(不动 UI)
2) 播放动画(纯视觉过渡,不改数据)
3) 动画完成回调里刷新静态界面
```
好处:动画是“锦上添花”,**即使不播动画,静态界面也始终正确**(重画函数据本地数据即可还原,见 04 重连)。**动画期间绝不修改游戏数据。**
### AnimationManager API(框架,通用)
```js
AnimationManager.move(id, fromX, fromY, toX, toY, duration, 'easeOut', cb);
AnimationManager.scale(id, 1.0, 1.3, 150, 'easeOut', cb);
AnimationManager.rotate(id, 0, 360, 300, 'linear', cb);
AnimationManager.fade(id, 1.0, 0.0, 200, 'linear', cb);
AnimationManager.frame(id, startFrame, endFrame, duration, { loop:false, callback:cb });
// 串/并行队列
var q = AnimationManager.createQueue('serial'); // 或 'parallel'
AnimationManager.addToQueue(q, { type:'move', spriteId:id, /* ... */ });
AnimationManager.playQueue(q, cb);
```
缓动可选:`linear/easeIn/easeOut(最常用)/easeInOut/bounce/elastic`。
### 动画参数集中配置(子游戏)
**所有动画时长/帧区间/帧间隔/循环模式集中在子游戏的动画配置文件**,做到“配置即改、不改代码”。业务代码从配置读取,**禁止硬编码时长/帧数**。
```js
// 动画配置(子游戏):一个动作一份配置
SOME_ACTION: { PLAY_TYPE:'frame', RESOURCE_ID:554, START_FRAME:1, END_FRAME:35,
FRAME_INTERVAL:50, LOOP:false, HIDE_ON_COMPLETE:true }
// 使用:用工具方法从配置自动算帧数/时长,再交给框架播放
AnimationManager.frame(spriteId, opt.startFrame, opt.endFrame, opt.duration, opt);
```
### 框架动画 vs 游戏动画分层
- **`AnimationManager`(框架)**:通用的 move/scale/fade/frame/队列,不懂具体玩法元素。
- **游戏动画封装(子游戏)**:玩法专属封装——算各元素坐标、组织分段动画、从动画配置取参数。
业务调游戏动画封装的语义化方法,由它再去调 `AnimationManager`。
---
## 3. 音频:AudioManager + 资源 + 游戏音频管理
三层分离,**框架不含任何玩法专属的音效 ID 映射**:
```
业务代码
└─ 游戏音频管理(子游戏:概念→键名,如 'peng'/牌值→资源键)
├─ 音效资源常量(子游戏:键名→文件 ID)
└─ AudioManager(框架:通用播放、按性别选语音)
└─ Utl(引擎播放)
```
### 各层职责
- **`AudioManager`(框架)**:`playSound(file)`、`playVoice(baseId, sex)`、`playVoiceBySeat(seat, baseId)`、`playMusic/stopMusic`。约定女音 ID = 男音 ID + 偏移;**不含任何牌值/动作映射**。
- **音效资源常量(子游戏)**:集中定义音效/语音文件 ID(音效、男/女语音等分类)。新增音效**只改这里**。
- **游戏音频管理(子游戏)**:把“出牌/碰/某张牌”等概念映射到音效资源键,再调 `AudioManager`。**不写文件名/数字 ID**。
```js
// 子游戏音频管理:按概念播放,内部映射到资源键并按座位性别选男/女语音
// 通用音效直接走框架:AudioManager.playSound(音效资源常量.某音效)
```
**声音文件手动准备**:音频文件由开发者**手动**按平台命名规范放入资源目录(文件名/存放目录/ID→文件名转换规则以《[友乐…规范](../../../docs/important/client/友乐游戏引擎精灵与资源管理接口规范.md)》为准),代码只按 ID 引用、不生成文件。因此**音效资源常量每个 ID 必须注明【类型(背景音乐 / 音效 / 语音)+ 用途 + 时长 + 播放方式(循环 / 单次)】**,让人据注释就能准确准备对应音频。
**DO**:新音效只改音效资源常量;业务只调游戏音频管理。**DON'T**:在游戏音频管理里写裸文件名/ID;硬编码性别映射。
---
## 4. Spine:骨骼动画
Spine 用于复杂特效动画(吃碰杠胡的炫光等),集成进 gameabc 绘制循环,零侵入主循环。
### 三层
| 层 | 归属 | 职责 |
|----|------|------|
| 框架 | `spine/SpineMgr.js` | 加载/播放/每帧更新绘制、回调转发 |
| 子游戏配置 | Spine 动作配置 | 概念→Spine 资源(文件 + 动画名)映射 |
| 子游戏分发 | Spine 回调分发 | 集中接收 Spine 回调并按模块分发 |
### 用法
```js
// Spine 动作配置(子游戏):概念 → 资源
'chi': { spineId:'chi', animName:'play', scale:1.0 },
'tianhu': { spineId:'tianhu', animName:'play', scale:1.0 },
// 播放(SpineMgr)
SpineMgr.load(id, jsonFile, atlasFile, { scale:1.0, animation:'idle', loop:true });
SpineMgr.setAnimation(id, 'play', false, 0);
SpineMgr.playOnce(id, 'play', 0); // 播一次后隐藏
SpineMgr.playQueue(id, ['p1','p2'], true);
SpineMgr.updateAndDraw(ctx); // 每帧(引擎循环内自动)
```
### 回调集中分发
`SpineMgr` 把动画完成/帧事件转发到全局 `gameabc_face.spine_onComplete/spine_onEvent`,由子游戏的 **Spine 回调分发器统一分发**到各模块,不要在业务里散接回调。
### 资源手动制作与变更流程
Spine 骨骼资源(`json` / `atlas` / 贴图)由开发者**手动**制作并放入资源目录,代码只按概念引用、不生成资源。改了 Spine 动作配置的资源映射后,**运行 Spine 数据构建脚本**重新生成 Spine 数据清单(`generated/spine_*`)。
**Spine 动作配置每个动作必须注明【概念(对应哪个玩法动作)+ 资源文件 + 动画名 + `scale` 等参数】**,让人据注释就能准确准备对应 Spine 资源。
**DO**:概念走 Spine 动作配置、回调走 Spine 回调分发器、改资源后跑构建脚本。**DON'T**:硬编码 `spineId/animName`;绕过分发器直接接回调。
---
## 5. 本篇 DO / DON'T 总表
| 系统 | DO ✅ | DON'T ❌ |
|------|------|---------|
| 事件 | 名字进 `EventBus.Events`;专属事件放子游戏事件常量文件 | 硬编码事件字符串;往框架塞玩法事件 |
| 动画 | 先改数据→播动画→回调刷 UI;参数取自动画配置 | 动画期改数据;硬编码时长/帧数 |
| 音频 | 新音效只改音效资源常量;业务只调游戏音频管理 | 游戏音频管理写裸 ID;框架含玩法映射 |
| Spine | 概念走 Spine 动作配置,回调走 Spine 回调分发器,改资源跑脚本 | 硬编码 spineId/animName;散接回调 |
下一篇 [04-网络对接与启动编排](./04-网络对接与启动编排.md) 讲:怎么收发包、新旧架构如何对接、一局怎么启动。
</content>