9.1 音频播放与音效池
前八章把《方块冲刺》的画面、动画、输入都跑通了,但游戏还是「哑」的。玩家起跳没有音效,吃到金币没有反馈,冲刺时也没有 BGM。声音不是画面的附属品,它承担了很大一部分即时反馈:金币入账的那一声「叮」,让玩家不用盯着 UI 就知道自己收集成功了。
音频看起来比渲染简单——播放一个文件而已。真正上手就会发现,麻烦全在并发与复用上:同一秒里可能有五次金币音效叠加,玩家连点跳跃会堆出十几条声音,如果每播一次就 new Audio(),很快就会卡顿甚至崩溃。本节的目标是给 mini2d 写出一层可复用、有上限、可抢占的音频系统。
与站内
game/顶层「引擎概念综述」的分工:那批文章讲「引擎一般怎么设计音频」,本套书则亲手把mini2d的音频层写出来并真跑测试。Cocos Creator 3.8 的对照放在本节末。
9.1.1 为什么不能「每次播放都新建 Audio」
浏览器原生的播放方式是最朴素的:
const a = new Audio('/sfx/coin.wav');
a.volume = 0.6;
a.play();
单次播放没问题,但放进游戏循环就暴露三个问题:
| 问题 | 后果 | 《方块冲刺》里的场景 |
|---|---|---|
| 反复构造元素 | 每次 new Audio 都要解析、解码,GC 压力大 | 一局收集几十枚金币 |
| 无法追踪在播实例 | 想停掉某类音效时找不到对象 | 暂停游戏时残留音效继续响 |
| 无并发上限 | 同类音效无限叠加,音量爆表且破音 | 连点跳跃、连续撞击 |
更关键的是第三点:同一时刻同时播放的音频流是有上限的。桌面浏览器一般允许几十路,移动端往往只有十几路甚至更少。超限时浏览器会静默丢弃新声音,或者干脆抛错。与其等浏览器替我们做决定,不如在游戏层就主动控制并发。
9.1.2 播放实例与最小接口
先定义「一个正在播放的声音」该长什么样。它不需要继承 HTMLAudioElement,只需要把「停止」「改音量」这两件游戏层关心的事暴露出来:
// mini2d/audio/play-handle.ts
export interface PlayHandle {
/** 音效标识,用于按名停止同类声音 */
readonly name: string;
/** 播放该实例的起始时间戳(毫秒),抢占策略据此判断「最旧」 */
readonly startedAt: number;
/** 当前音量(0~1),抢占策略据此判断「最轻」 */
readonly volume: number;
stop(): void;
setVolume(v: number): void;
}
有了这层抽象,音频后端可以换:今天用 HTMLAudioElement,将来接 Web Audio API 的 AudioBufferSourceNode 时,上层代码一行不用改。这也是我们把它写成接口而不是具体类的原因——依赖抽象,不依赖实现。
9.1.3 对象池:复用播放实例
对象池的核心思路一句话:用过的实例不销毁,擦干净放回池子,下次要用时取出来。这样 new Audio 只发生在池子扩容时,稳态运行下几乎零分配。
// mini2d/audio/audio-pool.ts
import type { PlayHandle } from './play-handle';
interface PooledSlot {
el: HTMLAudioElement;
busy: boolean;
name: string;
startedAt: number;
volume: number;
}
export class AudioPool {
private readonly slots: PooledSlot[] = [];
private readonly srcCache = new Map<string, string>();
/** 预热:预先创建 N 个空槽位,避免首次播放时的构造抖动 */
constructor(private readonly capacity = 12) {
for (let i = 0; i < capacity; i++) {
this.slots.push(this.createSlot());
}
}
private createSlot(): PooledSlot {
const el = new Audio();
el.preload = 'auto';
return { el, busy: false, name: '', startedAt: 0, volume: 1 };
}
}
srcCache 记录每个音效名对应的 URL,避免每次播放都去查表或拼路径。注意 preload = 'auto':让浏览器在空闲时就把音频解码好,播放时才不会卡第一拍。
取用槽位的逻辑分两步——先找空闲槽,找不到再按策略抢占:
/** 取一个可用槽位;返回 null 表示当前无法播放(被丢弃) */
acquire(name: string, now: number): PooledSlot | null {
let slot = this.slots.find((s) => !s.busy);
if (!slot) slot = this.steal(name, now);
if (!slot) return null; // 池满且不允许抢占
slot.busy = true;
slot.name = name;
slot.startedAt = now;
return slot;
}
find 在槽位只有十几个时完全够用;如果池子开到上百个,再换成空闲索引队列。过早优化不如先让它正确——这是 mini2d 一路遵循的取舍。
9.1.4 抢占策略:最旧优先,其次最轻
当池子满了又要播新声音时,必须抢一个正在播放的槽位。抢谁?我们有两条启发式规则:
- 最旧优先(oldest-first):播放最久的那个大概率快结束了,抢它损失最小。
- 同权重时取最轻(quietest):音量最低的那个最不显眼,抢它听感影响最小。
private steal(name: string, now: number): PooledSlot | null {
const busy = this.slots.filter((s) => s.busy);
if (busy.length === 0) return null;
// 第一优先级:播放时长最久
busy.sort((a, b) => a.startedAt - b.startedAt);
const oldest = busy[0];
// 仅当候选确实「老」(超过 1.5 秒)才允许抢占,保护刚起步的重要音效
if (now - oldest.startedAt < 1500) return null;
oldest.el.pause();
oldest.busy = false;
return oldest;
}
那个 1500ms 阈值是保护阈值:如果池子里所有声音都是刚播的(比如玩家瞬间触发了十几个不同音效),我们宁可丢弃新声音,也不粗暴掐断正在播的。丢弃一次新音效,玩家几乎无感;掐断十路声音,听起来就是一片爆音。
生产项目里还可以给音效分级:
priority高的(如受伤、死亡)永远不被抢占,priority低的(如脚步)随时可被替换。我们在 9.2 引入总线时再顺带补上这个字段。
9.1.5 播放、停止与音量
把池子和接口接起来,就得到对外的最小 API:
// mini2d/audio/audio-system.ts
import { AudioPool } from './audio-pool';
import type { PlayHandle } from './play-handle';
export class AudioSystem {
private readonly pool = new AudioPool(12);
play(name: string, volume = 1): PlayHandle | null {
const now = performance.now();
const slot = this.pool.acquire(name, now);
if (!slot) return null; // 池满且不可抢占,静默丢弃
const src = this.pool.srcOf(name);
if (slot.el.src !== src) slot.el.src = src;
slot.el.currentTime = 0;
slot.el.volume = volume;
slot.volume = volume;
void slot.el.play();
return this.makeHandle(slot);
}
}
slot.el.currentTime = 0 是复用元素的关键一步:复用的 <audio> 还停留在上次播放的位置,必须回绕到开头,否则会从中途接着播。void slot.el.play() 前面的 void 表示我们有意忽略它返回的 Promise(自动播放策略下它可能被拒绝,9.3 会专门处理)。
停止和音量调整都按名字批量操作,这正是 PlayHandle.name 存在的意义:
/** 停止所有同类音效,例如离开关卡时清掉全部脚步 */
stopByName(name: string): void {
for (const slot of this.pool.busySlots()) {
if (slot.name === name) {
slot.el.pause();
this.pool.release(slot);
}
}
}
/** 批量调音量,例如滑杆拖动时实时生效 */
setVolumeByName(name: string, v: number): void {
const clamped = Math.max(0, Math.min(1, v));
for (const slot of this.pool.busySlots()) {
if (slot.name === name) {
slot.el.volume = clamped;
slot.volume = clamped;
}
}
}
setVolume 里做了 clamp:音量是 0~1 的归一化值,越界会被浏览器忽略甚至抛错。入口做校验、内部不再判,能少写一堆防御代码。
9.1.6 在《方块冲刺》里接上音效
现在给几个核心事件挂上声音,代码就一句话:
// game/scenes/level.ts
const audio = new AudioSystem();
function onCoinCollected(): void {
audio.play('coin', 0.7);
}
function onJump(): void {
audio.play('jump', 0.5);
}
function onPlayerHurt(): void {
audio.stopByName('jump'); // 受伤时掐掉未播完的跳跃音,避免叠加
audio.play('hurt', 0.9);
}
连点跳跃时,池子会按「最旧优先」把 1.5 秒前的跳跃音换掉,听感上是一串干脆的短音,而不是越叠越吵的噪音墙。这就是音效池存在的全部意义。
9.1.7 Cocos 对照:AudioSource 与 playOneShot
Cocos Creator 3.8 把「音源」做成组件:AudioSource 挂在节点上,播放的素材是 AudioClip 资源。以下为 L2 片段(import { ... } from 'cc'):
import { _decorator, Component, AudioSource, AudioClip } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('SfxPlayer')
export class SfxPlayer extends Component {
@property(AudioSource)
source: AudioSource | null = null;
@property(AudioClip)
coinClip: AudioClip | null = null;
onCoinCollected(): void {
if (!this.source || !this.coinClip) return;
// 短音效用 playOneShot:不打断当前播放,可叠加多路
this.source.playOneShot(this.coinClip, 0.7);
}
}
cc.d.ts 里的关键签名是 playOneShot(clip: AudioClip, volumeScale?: number): void——它内部正是维护了一组并发通道,与我们手写的音效池目标一致,只是 Cocos 把它封在引擎里了。AudioSource 另有 play() / pause() / stop() 与 clip / loop / volume 属性,适合单例长音(BGM);短促可叠加的音效一律走 playOneShot。
AudioSource.maxAudioChannel是引擎给出的并发上限,超限时同样会丢弃或复用通道。- 若节点上没挂
AudioSource,可以先const src = node.addComponent(AudioSource),再赋src.clip。 - 音量控制上,Cocos 允许
AudioSource.volume,但总线式音量要自己用节点层级或全局状态管理——那正是下一节 9.2 的主题。
下一节我们把「一条声音」升级成「一套混音系统」:主音量、BGM、音效三级总线如何相乘,BGM 如何交叉淡入淡出,静音状态又如何持久化。
阅读导航:上一节:目录 · 下一节:9.2 BGM、淡入淡出与音量总线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。