Fieldrunners
《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!)。您可以在“Give Me a Beat”中找到 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 连接在一起。DestinationNode 由 Web Audio 上下文创建,名为 destination,可为玩家播放声音。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,则会使音效和音乐静音。 AudioBufferSourceNodes 也有一个增益参数。您可以跟踪所有正在播放的声音的列表,并单独调整其增益值以控制整体音量。如果您使用音频标签制作音效,就必须这样做。而 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,我们的音频代码结构就会大不相同。因此,本文的许多部分都受到了影响。它不仅在这里有直接影响,在《Losing Focus》和《Give Me a Beat》的代码段中也有影响。若要了解其具体工作原理,需要同时更改 Fieldrunners 节点图(因为我们创建了用于缩短播放时间的节点)和附加代码,这些代码将记录并提供 Web Audio 无法自行提供的暂停状态。
失去焦点
我们的主节点在此功能中发挥作用。当浏览器用户切换到另一个标签页时,游戏将不再可见。眼不见,心不念,声音也应随之消失。虽然可以通过一些技巧来确定游戏页面的具体可见性状态,但使用 Visibility API 会简单得多。
Fieldrunners 仅在作为活动标签页时才会运行,因为它使用 requestAnimationFrame 来调用其更新循环。但是,当用户在另一个标签页中时,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,您可以轻松了解标签页何时不再处于焦点位置。如果您已经有有效的代码来暂停声音,那么只需编写几行代码,即可在游戏标签页隐藏时暂停声音。
来点节奏
我们现在已经设置了一些内容。我们有一个节点图。我们可以在玩家暂停游戏时暂停音效,并为游戏菜单等元素播放新的音效。当用户切换到新标签页时,我们可以暂停所有声音和音乐。现在,我们需要实际播放声音。
当多个游戏实体(例如角色死亡)同时发生时,Fieldrunners 不会为每个实体重复播放音效,而是在音效持续时间内仅播放一次。如果需要在播放完毕后再次播放,可以重新启动,但不能在播放过程中重新启动。这是 Fieldrunners 的音频设计决策,因为该游戏需要快速播放声音,如果允许重新启动,声音会断断续续,如果允许播放多个实例,则会产生令人不悦的杂音。AudioBufferSourceNode 应作为一次性节点使用。创建一个节点,附加一个缓冲区,根据需要设置循环布尔值,连接到图表上将通向目的地的节点,调用 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 播放背景音乐。这意味着,我们将使用 XMLHttpRequests 和 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。