10.1 音频管理器与音效池
第 9 章把《方块冲刺》的视觉反馈补齐了:角色在跑、金币在转、拾取时炸开粒子。但玩家拾起金币的那一刻,如果没有任何声音,整个反馈仍然是「哑」的。声音是最廉价也最有效的反馈手段——一个「叮」能顶半秒的特效。
Cocos 播放音频的入口是 AudioSource 组件。但直接到处 playOneShot 是坑的开始:BGM 和音效抢音量、静音开关各管各的、同一帧十几个音效同时响导致爆音。这一节的目标是把音频收进一个常驻节点上的管理器,让它成为工程里唯一碰 AudioSource 的地方。
10.1.1 三类音频需求,三套策略
音频不是一类东西。先按用途分开,策略才能分开:
| 类型 | 例子 | 数量 | 策略 |
|---|---|---|---|
| BGM | 主界面循环曲、关卡循环曲 | 同时 1 条 | 独占一个 AudioSource,loop = true |
| 音效(SFX) | 跳跃、拾取、受击、按钮 | 同帧多条 | 共享一个 AudioSource,playOneShot |
| 语音 | 角色台词、教程旁白 | 一次 1 条 | 独占,可打断前一条 |
三类的差异在于并发性:BGM 永远只有一条,切歌就是换 clip;音效可能同一帧响好几声(一次拾取三个金币),必须允许叠加;语音一次只该有一条,新的来了要打断旧的。
Cocos 的 AudioSource 有两个播放方法,正好对应这两类:
play():播放clip属性指定的音频,一个AudioSource同一时刻只播一条。playOneShot(clip, volumeScale?):把一条音频「叠加」上去,不影响AudioSource当前播放的内容。
所以 BGM 用 play() + loop,音效用 playOneShot,各挂一个 AudioSource。
10.1.2 AudioSource 的关键属性
AudioSource 上真正需要关心的属性不多:
| 属性 | 类型 | 说明 |
|---|---|---|
clip | AudioClip | null | 要播放的音频资源 |
loop | boolean | 是否循环(BGM 用) |
volume | number | 音量,0.0 ~ 1.0 |
playOnAwake | boolean | 组件激活时自动播放 |
playing | boolean(只读) | 是否正在播放 |
currentTime | number | 当前播放位置(秒) |
duration | number(只读) | 音频总时长(秒) |
注意 playOneShot 的音量是两个数相乘:clip 自身的音量 × volumeScale 参数。这给了「同一段音效按场景调轻重」的能力——普通拾取 volumeScale = 1,连续拾取时的第 5 个可以降到 0.6,避免叠起来刺耳。
10.1.3 playOneShot 的并发上限
playOneShot 不是无限的。引擎给音频通道设了上限,超过的会被丢弃。AudioSource 上有一个静态属性暴露这个上限:
import { AudioSource } from 'cc';
// 引擎允许的最大并发音频通道数
const max = AudioSource.maxAudioChannel;
console.log(`音频通道上限: ${max}`);
在 Web 端这个值通常是 32 左右,不同平台可能不同。这意味着同一瞬间播超过上限条音效,多出来的会被静默丢弃。一屏十几个金币被磁铁同时吸走、每个都 playOneShot,就很容易撞上这个墙——表现为「有的金币响、有的不响」,而且是随机的。
两个应对办法:
- 节流:同一音效在 50 毫秒内只允许播一次。连续拾取时只响前几声。
- 优先级调度:给音效分级,通道满了就丢弃低优先级的。这是音效池要解决的问题。
10.1.4 常驻节点上的 AudioManager
把音频收进一个常驻节点上的管理器。它要满足三点:全局唯一、跨场景存活、对外只暴露几个方法。下面这个组件可以过 tsc --noEmit:
import { _decorator, Component, AudioSource, AudioClip, director } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AudioManager')
export class AudioManager extends Component {
@property(AudioSource)
private bgm: AudioSource | null = null;
@property(AudioSource)
private sfx: AudioSource | null = null;
@property({ tooltip: '音效总音量 0~1' })
private sfxVolume = 1.0;
private muted = false;
static instance: AudioManager | null = null;
protected onLoad(): void {
if (AudioManager.instance && AudioManager.instance !== this) {
this.node.destroy();
return;
}
AudioManager.instance = this;
// 跨场景存活:切关卡时 BGM 不断
director.addPersistRootNode(this.node);
}
playBGM(clip: AudioClip): void {
if (!this.bgm) {
return;
}
if (this.bgm.clip === clip && this.bgm.playing) {
return;
}
this.bgm.clip = clip;
this.bgm.loop = true;
this.bgm.volume = this.muted ? 0 : 0.6;
this.bgm.play();
}
playSFX(clip: AudioClip, volumeScale = 1.0): void {
if (!this.sfx || this.muted) {
return;
}
this.sfx.playOneShot(clip, volumeScale * this.sfxVolume);
}
setMuted(value: boolean): void {
this.muted = value;
if (this.bgm) {
this.bgm.volume = value ? 0 : 0.6;
}
}
isMuted(): boolean {
return this.muted;
}
}
几个设计决策值得说清:
director.addPersistRootNode(this.node):把音频节点提升为常驻节点,切场景时不销毁。BGM 因此能跨关卡连续播放,不会每次切场景都断一下。- 单例用
static instance:第二个实例在onLoad里自毁。这是 Cocos 里做管理器的标准写法——不用全局变量,靠节点生命周期保证唯一。 - 静音不是停播:
setMuted(true)把 BGM 音量设 0 而不是stop(),这样取消静音时能无缝续上。
10.1.5 音效池:用纯逻辑做优先级调度
playSFX 还太朴素。真正的音效池要在通道有限的前提下做取舍:哪些音效必须响、哪些可以丢、同一种音效连续触发怎么合并。
这部分是纯逻辑,与引擎无关,可直接单测:
export interface SfxRequest {
id: string; // 音效标识,如 'pickup'
priority: number; // 越大越重要
volume: number;
time: number; // 请求发生的时间(秒)
}
export interface SfxDecision {
play: boolean;
reason: 'ok' | 'muted' | 'throttled' | 'channel-full';
}
/** 音效池调度:节流 + 通道上限 + 优先级 */
export class SfxPool {
private readonly throttle: Map<string, number> = new Map();
private readonly throttleWindow: number;
private readonly maxChannels: number;
private active: SfxRequest[] = [];
constructor(maxChannels = 16, throttleWindow = 0.05) {
this.maxChannels = maxChannels;
this.throttleWindow = throttleWindow;
}
request(req: SfxRequest, muted = false): SfxDecision {
if (muted) {
return { play: false, reason: 'muted' };
}
const last = this.throttle.get(req.id);
if (last !== undefined && req.time - last < this.throttleWindow) {
return { play: false, reason: 'throttled' };
}
this.throttle.set(req.id, req.time);
if (this.active.length >= this.maxChannels) {
const weakest = this.active.reduce(
(min, r) => (r.priority < min.priority ? r : min),
this.active[0],
);
if (weakest.priority >= req.priority) {
return { play: false, reason: 'channel-full' };
}
// 顶掉最弱的一条
this.active = this.active.filter((r) => r !== weakest);
}
this.active.push(req);
return { play: true, reason: 'ok' };
}
/** 音频播完后由引擎回调,把通道释放出来 */
release(req: SfxRequest): void {
this.active = this.active.filter((r) => r !== req);
}
}
三条规则依次生效:
- 静音:直接拒绝。
- 节流:同一种音效在
throttleWindow(默认 50ms)内只放行一次。连续拾取十个金币,声音只响前一声,其余静默——听感上反而更干净。 - 通道上限:满了就找当前最弱的一条比优先级,赢了顶掉它,输了拒绝。
可以用 vitest 断言:给上限 2 的池子连发三条同优先级请求,第三条应返回 channel-full;发一条高优先级请求,它应顶掉低优先级的那条。
10.1.6 音量分组与持久化
AudioManager 里 BGM 和音效各有一个音量。玩家在设置界面要能分别调,还要记住选择。分组和持久化的做法:
| 设置项 | 存储键 | 默认值 |
|---|---|---|
| 音乐音量 | setting.bgm | 0.6 |
| 音效音量 | setting.sfx | 1.0 |
| 总静音 | setting.muted | false |
存档用 sys.localStorage(H5 与原生通用),微信端在第 11 章会换成 wx.setStorageSync。写入时机放在「玩家松手」而不是「滑动过程中」——音量条拖动时每帧写一次 localStorage 会让 UI 卡顿。
10.1.7 编辑器里搭 AudioManager 常驻节点
这一步属于 L3——需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
- 在启动场景
assets/scenes/main.scene的层级管理器里,右键 → 创建 → 空节点,命名AudioManager。 - 给它添加两个子节点:
BGM和SFX。 - 分别给
BGM、SFX节点添加AudioSource组件。BGM的Loop勾选、Play On Awake取消;SFX的Loop取消、Play On Awake取消。 - 给
AudioManager节点添加刚才写的AudioManager脚本组件。 - 把
BGM节点的AudioSource拖到脚本的bgm字段,SFX的拖到sfx字段。 - 把
assets/audio/下的音频拖进资源目录:bgm-main.mp3、sfx-pickup.mp3、sfx-jump.mp3、sfx-hurt.mp3。
操作完成后,属性面板上应显示:AudioManager 节点上挂着 AudioManager 组件,bgm 与 sfx 两个字段都已指向对应子节点的 AudioSource;BGM 节点的 AudioSource 的 Clip 为空(运行时由代码设),Loop 已勾选。
打开 main.scene 你会看到层级里多了一棵 AudioManager / BGM + SFX 子树,三个节点各挂一个组件——.scene 文件里对应三条 _components 记录,__type__ 分别是 cc.AudioSource 和脚本类名。
10.1.8 工程视角的收口
这一节做完,工程里多了:
assets/scripts/core/sfx-pool.ts:纯逻辑音效池,有单测;assets/scripts/platform/audio-manager.ts:常驻节点上的管理器;assets/audio/目录(新建):BGM 与音效资源;- 启动场景里
AudioManager常驻节点子树。
下一节接手的入口是 AudioManager.instance:玩法代码只调 playSFX,不直接碰任何 AudioSource。但这里埋着一个跨端的大坑——浏览器和小游戏都不允许「用户没交互就自动播声音」。playBGM 在 main.scene 一加载就调,在 Web 端会被静默拒绝,在微信端可能整个音频系统都不初始化。10.2 节专门处理这个限制。
阅读导航:上一节:目录 · 下一节:10.2 多端音频限制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。