10.2 多端音频限制
10.1 的 AudioManager 在桌面浏览器里调试时一切正常:main.scene 一加载,BGM 就响起来。但把同样的包发到手机上,第一件事往往是——没有声音。
这不是代码写错了,而是三端各有各的限制:浏览器有自动播放策略、微信小游戏有独立的音频初始化、iOS 有物理静音开关。这一节把这些限制逐条讲清,并给出应对代码。判错运行平台是卷二最常见的一类 bug,所以每个平台判定都落到具体的 sys.Platform 成员上。
10.2.1 浏览器的自动播放策略
Chrome、Safari、Firefox 都有一条共同规则:页面在没有收到任何用户交互之前,不允许播放音频。AudioSource.play() 在这个阶段调用不会报错,只是被静默忽略——所以你看到的是「代码跑了、日志也打了、就是没声音」。
规则的两面:
- 首次交互之前:音频被拒。这里的「交互」指真实的用户手势——点击、触摸、按键。程序触发的
input事件不算。 - 首次交互之后:音频解锁,后续所有播放正常。
所以《方块冲刺》的正确做法是:不要求 BGM 在场景加载时就响,而是等到玩家第一次点击(开始按钮、屏幕任意处)再播。这在体验上也说得通——玩家还没准备好玩,先别放音乐。
10.2.2 微信小游戏的音频差异
微信小游戏有自己的音频体系。Cocos 3.8 会把它适配到 AudioSource 上,但底层行为仍有差异:
| 项 | Web 浏览器 | 微信小游戏 |
|---|---|---|
| 自动播放限制 | 有,需用户交互 | 有,且更严格 |
| 音频通道上限 | 约 32 | 约 10~16,更易撞墙 |
volume 生效 | 是 | 部分机型不生效 |
| 后台播放 | 随页面挂起 | 需显式配置 |
| 静音跟随系统 | 否 | 是(跟系统静音开关) |
两个要点:
- 通道上限更低。10.1 里那个音效池的
maxChannels默认值 16,在微信端要调低到 8~10,否则「有的音效不响」会更频繁。 volume可能不生效。某些 Android 机型的微信里,AudioSource.volume设了没反应。对策是用音效资源本身的音量(在编辑器里对AudioClip预处理),而不是运行时靠volume调。
微信端还可以调 wx.setInnerAudioOption 控制是否与系统音量同步,但这是平台 API,需要先判定环境。
10.2.3 用 sys.platform 判定运行环境
sys 模块暴露了当前运行平台。判定必须精确到具体成员,不能用「有没有 wx 对象」这种土办法——微信小程序里也有 wx,但音频能力不同。
import { sys } from 'cc';
export function platformName(): string {
switch (sys.platform) {
case sys.Platform.WECHAT_GAME:
return 'wechat-game';
case sys.Platform.MOBILE_BROWSER:
return 'mobile-browser';
case sys.Platform.DESKTOP_BROWSER:
return 'desktop-browser';
case sys.Platform.ANDROID:
return 'android-native';
case sys.Platform.IOS:
return 'ios-native';
default:
return 'unknown';
}
}
sys.platform 返回的是 sys.Platform 枚举值,常用成员如下:
| 成员 | 含义 |
|---|---|
sys.Platform.WECHAT_GAME | 微信小游戏 |
sys.Platform.BYTEDANCE_MINI_GAME | 抖音小游戏 |
sys.Platform.MOBILE_BROWSER | 手机浏览器 |
sys.Platform.DESKTOP_BROWSER | 桌面浏览器 |
sys.Platform.ANDROID | Android 原生 |
sys.Platform.IOS | iOS 原生 |
注意:枚举里没有 NATIVE 这个成员。要判断「是不是原生平台」,用布尔属性 sys.isNative,而不是拿 sys.platform 去比某个值。要判断「具体是哪个平台」才用 sys.platform。这个区别看着小,但写错了在原生端会永远判 false。
10.2.4 首次交互解锁音频
把「等第一次交互再播」写成组件。下面这段可以过 tsc --noEmit:
import { _decorator, Component, AudioSource, input, Input } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AudioUnlocker')
export class AudioUnlocker extends Component {
@property(AudioSource)
private bgm: AudioSource | null = null;
private unlocked = false;
protected onLoad(): void {
// 触摸或按键都算用户交互,哪个先来用哪个
input.once(Input.EventType.TOUCH_END, this.unlock, this);
input.once(Input.EventType.KEY_DOWN, this.unlock, this);
}
private unlock(): void {
if (this.unlocked) {
return;
}
this.unlocked = true;
if (this.bgm && !this.bgm.playing) {
this.bgm.play();
}
}
}
要点:
input.once而不是input.on——交互解锁只需要一次,用once免去手动off。- 同时监听
TOUCH_END和KEY_DOWN,覆盖手机(触摸)和桌面(键盘)。 unlock里有this.unlocked双保险:两个事件可能几乎同时触发,once各自注册,所以要再挡一次。
把这个组件挂在 AudioManager 节点上,bgm 指向 BGM 的 AudioSource。这样无论玩家是先点开始按钮还是先按空格,BGM 都会在第一次交互后正确响起。
10.2.5 iOS 的静音开关与音频会话
iPhone 侧边有一个物理静音拨片。Web 端(含微信小游戏)的音频默认跟随这个开关——拨到静音,游戏就没声。这是设计如此,不是 bug,开发者无法绕过。
原生 iOS 构建则可以通过音频会话类别控制。Cocos 原生构建里这个配置在 settings/v2/packages/*.json 的音频相关项里(需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现)。但多数休闲游戏不需要动它——跟随系统静音反而是用户期望的行为。
给策划的结论:不要把关键信息只放在声音里。如果「金币到手」只靠音效提示,玩家手机静音时就收不到反馈。视觉(9.3 的粒子)必须能独立完成信息传达。
10.2.6 音频格式与体积
三端对音频格式的支持不同,选错会导致「某端放不出来」:
| 格式 | 体积 | Web | 微信小游戏 | 原生 | 建议用途 |
|---|---|---|---|---|---|
.mp3 | 中 | 支持 | 支持 | 支持 | 通用兜底 |
.ogg | 小 | 支持(Safari 旧版弱) | 部分 | 支持 | 音效 |
.m4a / .aac | 小 | 支持 | 支持 | 支持 | 移动端首选 |
.wav | 大 | 支持 | 支持 | 支持 | 极短音效(<0.5s) |
《方块冲刺》的取舍:
- BGM:用
.m4a,单曲控制在 1 MB 以内。BGM 是首包体的大头,微信小游戏首包有 4 MB 硬限制,一段 2 分钟的 mp3 就能吃掉一半。 - 音效:用
.ogg或.m4a,单条 20 KB 以内。跳跃、拾取这种短音效压缩后很小。 - 不要用
.wav做长音频——它是未压缩的,一段 3 秒的 44.1kHz 立体声 wav 就有约 1 MB。
10.2.7 中断与恢复:切后台、来电
手机来电、玩家切到别的 App,游戏会进后台。此时音频必须暂停,回到前台再恢复——否则会出现「切走了音乐还在响」或者「回来音乐没了」两种情况。
Cocos 用 game 上的生命周期事件通知前后台切换:
import { _decorator, Component, AudioSource, Game, game, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AudioInterruptHandler')
export class AudioInterruptHandler extends Component {
@property(AudioSource)
private bgm: AudioSource | null = null;
private wasPlaying = false;
protected onEnable(): void {
game.on(Game.EVENT_HIDE, this.onHide, this);
game.on(Game.EVENT_SHOW, this.onShow, this);
}
protected onDisable(): void {
game.off(Game.EVENT_HIDE, this.onHide, this);
game.off(Game.EVENT_SHOW, this.onShow, this);
}
private onHide(): void {
this.wasPlaying = this.bgm?.playing ?? false;
this.bgm?.pause();
}
private onShow(): void {
// 微信端恢复前台后重新播放偶发失效,交给下一次用户交互解锁
if (this.wasPlaying && sys.platform !== sys.Platform.WECHAT_GAME) {
this.bgm?.play();
}
}
}
两个细节:
Game.EVENT_HIDE是切后台,Game.EVENT_SHOW是回前台,都是字符串常量,注册在game对象上。wasPlaying记录切走前是否在播。如果玩家本来就把 BGM 关了,回前台就不该自动打开。- 微信端有个已知的偶发问题:回前台后
play()可能不生效。最稳的做法是回前台时把音频重新标记为「未解锁」,等下一次用户交互再解锁。
10.2.8 音频资源的加载时机
音频文件不是代码,不会随脚本一起进包。放哪里决定了它在什么时候被加载:
- 进首包:
assets/audio/下的资源会被打进主包。BGM 和几个高频音效适合进首包,随游戏一起下载。 - 进分包 / 远程:低频音效(如某些关卡专属的)可以放进分包,用到时再拉。分包策略在第 15 章细讲。
- 运行时加载:如果音频放在
assets/resources/下,可以用resources.load按需拉取:
import { AudioClip, resources } from 'cc';
// 前提:音频放在 assets/resources/audio/ 下(resources 是内置 Bundle)
resources.load('audio/sfx-pickup', AudioClip, (err, clip) => {
if (err) {
console.error(err.message);
return;
}
// 拿到 clip 后交给 AudioManager 播放
console.log(clip.getDuration()); // 时长要走方法,AudioClip 上没有 duration 属性
});
一个容易忽略的点:BGM 别等第一次交互才加载。如果 playBGM 里现拉资源,玩家点开始按钮后会先卡一下(等下载)再出声。正确做法是在启动场景 main.scene 的加载阶段就把 BGM 预加载好,交互解锁时只是 play(),零延迟。
10.2.9 三端对照表
把这一节的所有差异收进一张表,发布前逐条核对:
| 限制 | Web | 微信小游戏 | 原生 |
|---|---|---|---|
| 需用户交互解锁 | 是 | 是 | 否 |
| 音频通道上限 | ~32 | 较高 | |
volume 生效 | 是 | 部分机型否 | 是 |
| 跟随系统静音 | 否 | 是 | 可配 |
| 后台自动暂停 | 随页面 | 需配置 | 需配置 |
| 推荐音效格式 | .ogg/.m4a | .m4a | .m4a |
10.2.10 工程视角的收口
这一节做完,工程里多了:
assets/scripts/platform/audio-unlocker.ts:首次交互解锁;assets/scripts/platform/audio-interrupt.ts:前后台中断恢复;assets/scripts/platform/platform-detect.ts:platformName()等环境判定;AudioManager的maxChannels在微信端下调到 10。
到这里,音频的「响」和「不响」都管住了。但反馈还差最后一层——触觉与节奏。手机能震动,动作能有「顿帧」,这些是打击感的来源。10.3 会讲怎么用平台震动 API、怎么用 timeScale 做顿帧、怎么用节点位移做屏幕震动,把《方块冲刺》的反馈闭环补完。
阅读导航:上一节:10.1 音频管理器与音效池 · 下一节:10.3 震动、顿帧与打击感 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。