《Cocos 游戏开发入门》9.1 音频播放与音效池

画面之外,《方块冲刺》还缺声音。本节给 mini2d 写出音频层:用 HTMLAudioElement 封装播放实例,用对象池复用元素避免反复创建,处理同时播放上限与最旧/最轻抢占策略,并给出统一的 play/stop/setVolume 接口;末节对照 Cocos Creator 3.8 的 AudioSource、AudioClip 与 playOneShot。

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 抢占策略:最旧优先,其次最轻

当池子满了又要播新声音时,必须抢一个正在播放的槽位。抢谁?我们有两条启发式规则:

  1. 最旧优先(oldest-first):播放最久的那个大概率快结束了,抢它损失最小。
  2. 同权重时取最轻(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、淡入淡出与音量总线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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