小型过山车
Fieldrunners 是一款屡获殊荣的塔防类游戏,最初于 2008 年面向 iPhone 发布。自那以后,它已移植到许多其他平台。最新的平台之一是 2011 年 10 月的 Chrome 浏览器。将 Fieldrunners 移植到 HTML5 平台时遇到的一个挑战是如何播放音效。
Fieldrunners 不会复杂地使用音效,但它对如何与音效互动有一些预期。该游戏有 88 个音效,其中有许多音效可能会同时播放。这些音效大多很短,并且需要尽可能及时播放,以免与图形演示产生任何脱节。
出现了一些问题
在将 Fieldrunners 移植到 HTML5 时,我们遇到了使用 Audio 标记的音频播放问题,因此在早期就决定改为专注于 Web Audio API。使用 WebAudio 帮助我们解决了一些问题,例如让 Fieldrunners 能够同时播放大量特效。不过,在为 Fieldrunners HTML5 开发音频系统时,我们遇到了一些其他开发者可能需要注意的细微问题。
AudioBufferSourceNode 的性质
AudioBufferSourceNode 是使用 WebAudio 播放声音的主要方法。请务必注意,它们是一次性对象。您可以创建一个 AudioBufferSourceNode,为其分配一个缓冲区,将其连接到图表,然后使用 noteOn 或 noteGrainOn 播放它。之后,您可以调用 noteOff 来停止播放,但您将无法通过调用 noteOn 或 noteGrainOn 再次播放该源 - 您必须创建另一个 AudioBufferSourceNode。不过,您可以(这一点至关重要)重复使用相同的底层 AudioBuffer 对象(事实上,您甚至可以拥有多个指向同一 AudioBuffer 实例的有效 AudioBufferSourceNode!)。您可以在“给我节奏”中找到 Fieldrunners 的播放片段。
无法缓存的内容
发布时,Fieldrunners HTML5 服务器显示了大量音乐文件请求。出现这种结果是因为 Chrome 15 会继续分块下载文件,然后不将其缓存。为此,我们当时决定像其他音频文件一样加载音乐文件。这样做效果不佳,但其他浏览器的某些版本仍会这样做。
失焦时静音
以前,很难检测游戏标签页何时失去焦点。Fieldrunners 在 Chrome 13 之前就开始了移植,在该版本中,Page Visibility API 取代了我们用于检测标签页模糊处理的复杂代码。每款游戏都应使用 Visibility API 编写一个小段代码,用于静音或暂停游戏音效(如果不暂停整个游戏)。由于 Fieldrunners 使用了 requestAnimationFrame API,因此系统会隐式处理游戏暂停,但不会处理音效暂停。
暂停提示音
很奇怪,在收到这篇文章的反馈时,我们得知自己用于暂停音频的技术不适用,因为我们利用的是 Web Audio 当前实现中的一个 bug 来暂停音频的播放。由于此问题日后会得到修复,因此您无法仅通过断开节点或子图来暂停音频以停止播放。
简单的 Web Audio 节点架构
Fieldrunners 的音频模型非常简单。该模型可以支持以下功能集:
- 控制音效的音量。
- 控制背景音乐曲目的音量。
- 将所有音频设为静音。
- 在游戏暂停时关闭游戏音效。
- 在游戏恢复后,重新开启这些音效。
- 当游戏的标签页失去焦点时,关闭所有音频。
- 根据需要在播放音效后重启播放。
为了使用 Web Audio 实现上述功能,该示例使用了 3 个提供的可能节点:DestinationNode、GainNode、AudioBufferSourceNode。AudioBufferSourceNode 会播放声音。GainNode 会将 AudioBufferSourceNode 连接在一起。Web Audio 上下文创建的 DestinationNode(称为“目的地”)会为播放器播放声音。Web Audio 有更多类型的节点,但仅使用这些节点,我们就可以为游戏中的声音创建一个非常简单的图表。
Web Audio 节点图从叶节点延伸到目标节点。Fieldrunners 使用了 6 个永久增益节点,但 3 个就足以轻松控制音量并连接更多用于播放缓冲区的临时节点。首先,有一个主增益节点将每个子节点附加到目的地。主增益节点的直接附件是两个增益节点,一个用于音乐声道,另一个用于关联所有音效。
由于错误地将 bug 用作功能,Fieldrunners 有 3 个额外的增益节点。我们使用这些节点从图表中剪除一组正在播放的音效,从而停止其播放进度。我们这样做是为了暂停声音。由于该值不正确,我们现在将仅使用 3 个总增益节点,如上所述。以下许多代码段都包含错误的节点,展示了我们所做的工作以及短期内如何解决此问题。但从长远来看,您不应在 coreEffectsGain 节点后使用我们的节点。
function AudioManager() {
// map for loaded sounds
this.sounds = {};
// create our permanent nodes
this.nodes = {
destination: this.audioContext.destination,
masterGain: this.audioContext.createGain(),
backgroundMusicGain: this.audioContext.createGain(),
coreEffectsGain: this.audioContext.createGain(),
effectsGain: this.audioContext.createGain(),
pausedEffectsGain: this.audioContext.createGain()
};
// and setup the graph
this.nodes.masterGain.connect( this.nodes.destination );
this.nodes.backgroundMusicGain.connect( this.nodes.masterGain );
this.nodes.coreEffectsGain.connect( this.nodes.masterGain );
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
this.nodes.pausedEffectsGain.connect( this.nodes.coreEffectsGain );
}
大多数游戏都允许单独控制音效和音乐。这可以通过上图轻松实现。每个增益节点都有一个“增益”属性,该属性可设置为介于 0 和 1 之间的任何小数值,可用于控制音量。由于我们希望分别控制音乐和音效通道的音量,因此为每个通道都提供了一个增益节点,以便控制其音量。
function setArbitraryVolume() {
var musicGainNode = this.nodes.backgroundMusicGain;
// set music volume to 50%
musicGainNode.gain.value = 0.5;
}
我们可以使用同样的方法来控制音效和音乐的音量。设置主节点的增益将影响游戏中的所有声音。如果将增益值设置为 0,则会将声音和音乐静音。 AudioBufferSourceNode 也具有增益参数。您可以跟踪所有正在播放的声音的列表,并单独调整其增益值以控制总音量。如果您使用 Audio 标记制作音效,就必须这样做。不过,Web Audio 的节点图让您可以更轻松地修改无数声音的音量。 以这种方式控制音量还能让您轻松获得额外电量。我们可以直接将 AudioBufferSourceNode 附加到主节点,以播放音乐并控制其自己的增益。不过,您每次创建 AudioBufferSourceNode 以播放音乐时都必须设置此值。而是仅在玩家更改音乐音量和启动时更改一个节点。现在,我们可以使用缓冲区源的增益值来执行其他操作。对于音乐,一个常见用途是创建一个音轨渐强渐弱到另一个音轨的效果。Web Audio 提供了一种轻松执行此操作的好方法。
function arbitraryCrossfade( track1, track2 ) {
track1.gain.linearRampToValueAtTime( 0, 1 );
track2.gain.linearRampToValueAtTime( 1, 1 );
}
Fieldrunners 没有专门使用交叉淡化。如果我们在最初对音频系统进行传递时就知道 WebAudio 的值设置功能,我们可能就不会遇到这个问题。
暂停提示音
当玩家暂停游戏时,他们可能会希望某些音效仍会播放。在游戏菜单中常见的界面元素按压操作中,声音是反馈的重要组成部分。由于 Fieldrunners 在游戏暂停时提供了许多供用户互动的界面,因此我们希望用户仍能继续玩游戏。不过,我们不希望任何长音或循环音持续播放。使用 Web Audio 停止这些声音非常简单,至少我们是这样认为的。
AudioManager.prototype.pauseEffects = function() {
this.nodes.effectsGain.disconnect();
}
已暂停的效果节点仍处于连接状态。允许忽略游戏暂停状态的任何声音都将继续播放。当游戏取消暂停时,我们可以重新连接这些节点,并立即让所有声音再次播放。
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}
发布 Fieldrunners 后,我们发现仅断开节点或子图的连接不会暂停 AudioBufferSourceNode 的播放。实际上,我们利用了 WebAudio 中的一个 bug,该 bug 目前会停止播放未连接到图中的目的地节点的节点。因此,为了确保我们为未来的修复做好准备,我们需要一些类似以下的代码:
AudioManager.prototype.pauseEffects = function() {
this.nodes.effectsGain.disconnect();
var now = Date.now();
for ( var name in this.sounds ) {
var sound = this.sounds[ name ];
if ( !sound.ignorePause && ( now - sound.source.noteOnAt < sound.buffer.duration * 1000 ) ) {
sound.pausedAt = now - sound.source.noteOnAt;
sound.source.noteOff();
}
}
}
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
var now = Date.now();
for ( var name in this.sounds ) {
if ( sound.pausedAt ) {
this.play( sound.name );
delete sound.pausedAt;
}
}
};
如果我们早就知道自己在滥用 bug,我们的音频代码结构将会大不相同。因此,这影响了本文的多个部分。这不仅会直接影响此处的代码,还会影响“失去焦点”和“给我节奏”中的代码段。如需了解此机制的实际运作方式,需要同时更改 Fieldrunners 节点图(因为我们创建了用于缩短播放时间的节点),以及记录和提供 Web Audio 无法自行提供的暂停状态的额外代码。
失去焦点
我们的主节点会用于此功能。当浏览器用户切换到其他标签页时,游戏将不再显示。人不在眼前,心不在眼前,声音也应如此。您可以使用一些技巧来确定游戏页面的具体公开范围状态,但借助可见性 API,您可以更轻松地完成此操作。
由于使用 requestAnimationFrame 调用其更新循环,Fieldrunners 只会在处于活跃标签页时运行。不过,当用户在其他标签页中时,Web Audio 上下文会继续播放循环效果和背景轨道。不过,我们可以使用一个非常小的 Visibility API 感知代码段来阻止这种情况。
function AudioManager() {
// map and node setup
// ...
// disable all sound when on other tabs
var self = this;
window.addEventListener( 'webkitvisibilitychange', function( e ) {
if ( document.webkitHidden ) {
self.nodes.masterGain.disconnect();
// As noted in Pausing Sounds disconnecting isn't enough.
// For Fieldrunners calling our new pauseEffects method would be
// enough to accomplish that, though we may still need some logic
// to not resume if already paused.
self.pauseEffects();
} else {
self.nodes.masterGain.connect( this.nodes.destination );
self.resumeEffects();
}
});
}
在撰写本文之前,我们认为断开主音箱连接即可暂停所有声音,而不是将其静音。通过当时断开节点的连接,我们阻止了该节点及其子节点进行处理和播放。重新连接后,所有声音和音乐都会从上次中断的位置开始播放,游戏进度也会从上次中断的位置继续。但这属于意外行为。仅断开连接并不能停止播放。
借助 Page Visibility API,您可以非常轻松地了解标签页何时失去焦点。如果您已经有有效的代码来暂停声音,只需几行代码即可在游戏标签页隐藏时暂停声音。
Give Me a Beat
我们现在需要设置一些内容。我们有一个节点图。我们可以在玩家暂停游戏时暂停声音,并为游戏菜单等元素播放新声音。当用户切换到新标签页时,我们可以暂停所有声音和音乐。现在,我们需要实际播放音效。
Fieldrunners 不会为游戏实体的多个实例(例如角色死亡)播放多个音频副本,而是在其播放期间只播放一次音频。如果需要在播放完毕后播放音效,则可以重新开始播放,但不能在播放期间重新开始播放。这是 Fieldrunners 音频设计的决定,因为其中有一些音效需要快速播放,如果允许重启,则会出现卡顿;如果允许播放多个实例,则会产生令人不快的杂音。AudioBufferSourceNode 应作为一次性使用。创建一个节点、附加一个缓冲区、根据需要设置 loop 布尔值、连接到图上通往目的地的节点、调用 noteOn 或 noteGrainOn,并可选调用 noteOff。
对于 Fieldrunners,该字段如下所示:
AudioManager.prototype.play = function( options ) {
var now = Date.now(),
// pull from a map of loaded audio buffers
sound = this.sounds[ options.name ],
channel,
source,
resumeSource;
if ( !sound ) {
return;
}
if ( sound.source ) {
var source = sound.source;
if ( !options.loop && now - source.noteOnAt > sound.buffer.duration * 1000 ) {
// discard the previous source node
source.stop( 0 );
source.disconnect();
} else {
return;
}
}
source = this.audioContext.createBufferSource();
sound.source = source;
// track when the source is started to know if it should still be playing
source.noteOnAt = now;
// help with pausing
sound.ignorePause = !!options.ignorePause;
if ( options.ignorePause ) {
channel = this.nodes.pausedEffectsGain;
} else {
channel = this.nodes.effectsGain;
}
source.buffer = sound.buffer;
source.connect( channel );
source.loop = options.loop || false;
// Fieldrunners' current code doesn't consider sound.pausedAt.
// This is an added section to assist the new pausing code.
if ( sound.pausedAt ) {
source.start( ( sound.buffer.duration * 1000 - sound.pausedAt ) / 1000 );
source.noteOnAt = now + sound.buffer.duration * 1000 - sound.pausedAt;
// if you needed to precisely stop sounds, you'd want to store this
resumeSource = this.audioContext.createBufferSource();
resumeSource.buffer = sound.buffer;
resumeSource.connect( channel );
resumeSource.start(
0,
sound.pausedAt,
sound.buffer.duration - sound.pausedAt / 1000
);
} else {
// start play immediately with a value of 0 or less
source.start( 0 );
}
}
在线播放过多
Fieldrunners 最初发布时,背景音乐是通过 Audio 标记播放的。在发布时,我们发现音乐文件的请求次数与游戏内容的其余部分相比不成比例。经过一番研究,我们发现当时 Chrome 浏览器并未缓存音乐文件的流式传输数据块。这导致浏览器每隔几分钟就会请求播放曲目。在最近的测试中,Chrome 缓存了流式传输的曲目,但其他浏览器可能尚未这样做。使用 Audio 标记流式传输大型音频文件以实现音乐播放等功能是最佳方式,但对于某些浏览器版本,您可能需要像加载音效一样加载音乐。
由于所有音效都是通过 Web Audio 播放的,因此我们也将背景音乐的播放移到了 Web Audio。这意味着,我们将以与使用 XMLHttpRequest 和 arraybuffer 响应类型加载所有效果相同的方式加载轨道。
AudioManager.prototype.load = function( options ) {
var xhr,
// pull from a map of name, object pairs
sound = this.sounds[ options.name ];
if ( sound ) {
// this is a great spot to add success methods to a list or use promises
// for handling the load event or call success if already loaded
if ( sound.buffer && options.success ) {
options.success( options.name );
} else if ( options.success ) {
sound.success.push( options.success );
}
// one buffer is enough so shortcut here
return;
}
sound = {
name: options.name,
buffer: null,
source: null,
success: ( options.success ? [ options.success ] : [] )
};
this.sounds[ options.name ] = sound;
xhr = new XMLHttpRequest();
xhr.open( 'GET', options.path, true );
xhr.responseType = 'arraybuffer';
xhr.onload = function( e ) {
sound.buffer = self._context.createBuffer( xhr.response, false );
// call all waiting handlers
sound.success.forEach( function( success ) {
success( sound.name );
});
delete sound.success;
};
xhr.onerror = function( e ) {
// failures are uncommon but you want to do deal with them
};
xhr.send();
}
摘要
将 Fieldrunners 引入 Chrome 和 HTML5 是一件很棒的事。除了将数千行 C++ 代码转换为 JavaScript 的大量工作之外,还会遇到一些特定于 HTML5 的有趣两难和决策。需要再次强调的是,AudioBufferSourceNode 是一次性对象。创建它们,附加音频缓冲区,将其连接到 Web Audio 图表,然后使用 noteOn 或 noteGrainOn 进行演奏。需要再次播放该提示音吗?然后,创建另一个 AudioBufferSourceNode。