《Cocos 游戏开发实战》10.1 音频管理器与音效池

第 9 章让《方块冲刺》动起来了,但没有声音的反馈是空的。本节从 AudioSource 讲起,说清 BGM、音效、语音三类需求的差异,给出常驻节点上的 AudioManager 组件、playOneShot 的并发上限,以及一个可单测的音效池优先级调度器。

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 上真正需要关心的属性不多:

属性类型说明
clipAudioClip | null要播放的音频资源
loopboolean是否循环(BGM 用)
volumenumber音量,0.0 ~ 1.0
playOnAwakeboolean组件激活时自动播放
playingboolean(只读)是否正在播放
currentTimenumber当前播放位置(秒)
durationnumber(只读)音频总时长(秒)

注意 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,就很容易撞上这个墙——表现为「有的金币响、有的不响」,而且是随机的。

两个应对办法:

  1. 节流:同一音效在 50 毫秒内只允许播一次。连续拾取时只响前几声。
  2. 优先级调度:给音效分级,通道满了就丢弃低优先级的。这是音效池要解决的问题。

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);
  }
}

三条规则依次生效:

  1. 静音:直接拒绝。
  2. 节流:同一种音效在 throttleWindow(默认 50ms)内只放行一次。连续拾取十个金币,声音只响前一声,其余静默——听感上反而更干净。
  3. 通道上限:满了就找当前最弱的一条比优先级,赢了顶掉它,输了拒绝。

可以用 vitest 断言:给上限 2 的池子连发三条同优先级请求,第三条应返回 channel-full;发一条高优先级请求,它应顶掉低优先级的那条。

10.1.6 音量分组与持久化

AudioManager 里 BGM 和音效各有一个音量。玩家在设置界面要能分别调,还要记住选择。分组和持久化的做法:

设置项存储键默认值
音乐音量setting.bgm0.6
音效音量setting.sfx1.0
总静音setting.mutedfalse

存档用 sys.localStorage(H5 与原生通用),微信端在第 11 章会换成 wx.setStorageSync。写入时机放在「玩家松手」而不是「滑动过程中」——音量条拖动时每帧写一次 localStorage 会让 UI 卡顿。

10.1.7 编辑器里搭 AudioManager 常驻节点

这一步属于 L3——需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

  1. 在启动场景 assets/scenes/main.scene 的层级管理器里,右键 → 创建 → 空节点,命名 AudioManager。
  2. 给它添加两个子节点:BGM 和 SFX。
  3. 分别给 BGM、SFX 节点添加 AudioSource 组件。BGM 的 Loop 勾选、Play On Awake 取消;SFX 的 Loop 取消、Play On Awake 取消。
  4. 给 AudioManager 节点添加刚才写的 AudioManager 脚本组件。
  5. 把 BGM 节点的 AudioSource 拖到脚本的 bgm 字段,SFX 的拖到 sfx 字段。
  6. 把 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 多端音频限制 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画