案例研究:一个带有网络音频的 HTML5 游戏的故事
Fieldrunners 是一款屡获殊荣的塔防风格游戏,最初发布 iPhone 于 2008 年 。此后它已被移植到许多其他平台。 最新的平台之一是 的 Chrome 浏览器 2011 年 10 月 。将 Fieldrunners 移植到 HTML5 平台的挑战之一是如何播放声音。
Fieldrunners 并没有对音效进行复杂的使用,但它确实对如何与其音效进行交互提出了一些期望。 游戏共有88种音效,预计可以同时播放大量音效。 大多数这些声音都很短,需要尽可能及时地播放,以避免与图形呈现产生任何脱节。
出现了一些挑战
在将 Fieldrunners 移植到 HTML5 时,我们遇到了带有 Audio 标签的音频播放问题,因此我们很早就决定专注于 Web Audio API 。 使用 WebAudio 帮助我们解决了一些问题,例如为我们提供 Fieldrunners 所需的大量并发效果播放。 尽管如此,在为 Fieldrunners HTML5 开发音频系统时,我们还是遇到了一些其他开发人员可能希望注意的细微问题。
AudioBufferSourceNodes 的性质
AudioBufferSourceNodes 是您使用 WebAudio 播放声音的主要方法。 了解它们是一次性使用的对象非常重要。 您创建一个 AudioBufferSourceNode,为其分配一个缓冲区,将其连接到图形,然后使用 noteOn 或 noteGrainOn 进行播放。 之后,您可以调用 noteOff 停止播放,但您将无法通过调用 noteOn 或 noteGrainOn 再次播放源——您必须创建另一个 AudioBufferSourceNode。 你可以——这是关键——重用相同的底层 AudioBuffer 对象,但是(事实上,你甚至可以有多个活动的 AudioBufferSourceNodes 指向同一个 AudioBuffer 实例!)。 您可以在 Give Me a Beat 中找到 Fieldrunners 的播放片段。
非缓存内容
在发布时,Fieldrunners HTML5 服务器显示了对音乐文件的大量请求。 这个结果源于 Chrome 15 继续分块下载文件,然后不缓存它。 作为回应,我们决定像其他音频文件一样加载音乐文件。 这样做是次优的,但其他浏览器的某些版本仍然这样做。
失焦时沉默
以前很难检测游戏的标签何时失焦。 Fieldrunners 在 Chrome 13 之前开始移植,其中 页面可见性 API 取代了我们复杂的代码来检测标签模糊的需要。 如果不暂停整个游戏,每个游戏都应该使用 Visibility API 编写一个小片段来静音或暂停它们的声音。 由于 Fieldrunners 使用 requestAnimationFrame API,游戏暂停被隐式处理,但不是声音暂停。
暂停声音
奇怪的是,在获得对本文的反馈时,我们被告知我们用于暂停声音的技术并不合适——我们利用了 Web Audio 当前实现中的一个错误来暂停声音的播放。 由于这将在未来修复,您不能通过断开节点或子图来停止播放来暂停声音。
一个简单的网络音频节点架构
Fieldrunners 有一个非常简单的音频模型。 该模型可以支持以下功能集:
- 控制音效的音量。
- 控制背景音乐曲目的音量。
- 静音所有音频。
- 游戏暂停时关闭播放声音。
- 当游戏恢复时,重新打开这些相同的声音。
- 当游戏的选项卡失去焦点时关闭所有音频。
- 根据需要播放声音后重新开始播放。
为了通过 Web Audio 实现上述功能,它使用了 3 个提供的可能节点:DestinationNode、GainNode、AudioBufferSourceNode。 AudioBufferSourceNodes 播放声音。 GainNodes 将 AudioBufferSourceNodes 连接在一起。 DestinationNode 由 Web Audio 上下文创建,称为目的地,为播放器播放声音。 Web Audio 有更多类型的节点,但只有这些我们可以为游戏中的声音创建一个非常简单的图表。
网络音频节点图从叶节点通向目标节点。 Fieldrunners 使用了 6 个永久增益节点,但 3 个足以允许轻松控制音量并连接大量将播放缓冲区的临时节点。 首先,主增益节点将每个子节点附加到目的地。 与主增益节点直接相连的是两个增益节点,一个用于音乐通道,另一个用于连接所有音效。
由于错误使用错误作为功能,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 后,我们发现单独断开节点或子图不会暂停 AudioBufferSourceNodes 的播放。 我们实际上利用了 WebAudio 中的一个错误,该错误当前停止播放未连接到图中 Destination 节点的节点。 因此,为了确保我们为未来的修复做好准备,我们需要一些类似以下的代码:
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; } } };
如果我们早点知道我们正在滥用一个错误,我们的音频代码的结构就会大不相同。 因此,这影响了本文的许多部分。 它不仅在这里有直接影响,而且在 Losing Focus and Give Me a Beat 中的代码片段中也有影响。 要了解这实际上是如何工作的,需要更改 Fieldrunners 节点图(因为我们创建了用于缩短播放的节点)和附加代码,这些代码将记录和提供 Web Audio 无法自行执行的暂停状态。
失去焦点
我们的主节点为此功能发挥作用。 当浏览器用户切换到另一个选项卡时,游戏不再可见。 眼不见心不烦,声音也应该消失。 有一些技巧可以用来确定游戏页面的特定可见性状态,但使用 Visibility API 变得更加容易。
由于使用 requestAnimationFrame 调用其更新循环,Fieldrunners 将仅作为活动选项卡播放。 但是,当用户在另一个选项卡中时,Web 音频上下文将继续播放循环效果和背景音轨。 但是我们可以通过一个非常小的 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(); } }); }
在写这篇文章之前,我们认为断开主控足以暂停所有声音而不是静音。 通过当时断开节点的连接,我们停止了它及其子节点的处理和播放。 当它重新连接时,所有的声音和音乐都会从它们离开的地方开始播放,就像游戏将在它离开的地方继续一样。 但这是意料之外的行为。 仅仅断开连接以停止播放是不够的。
页面可见性 API 使您可以很容易地知道您的选项卡何时不再处于焦点。 如果您已经有了暂停声音的有效代码,那么在隐藏游戏选项卡时只需要写几行声音暂停。
给我一个节拍
我们现在设置了一些东西。 我们有一个节点图。 我们可以在玩家暂停游戏时暂停声音,并为游戏菜单等元素播放新的声音。 当用户切换到新标签页时,我们可以暂停所有声音和音乐。 现在我们需要实际播放声音。
Fieldrunners 不是为一个游戏实体的多个实例播放多个声音副本,例如角色死亡,而是在其持续时间内只播放一次声音。 如果在播放完毕后需要声音,则它可以重新启动,但不能在已播放时重新启动。 这是 Fieldrunners 音频设计的决定,因为它具有要求快速播放的声音,否则如果允许重新启动会结结巴巴,或者如果允许播放多个实例会产生令人不快的杂音。 AudioBufferSourceNodes 预计将用作一次性。 创建一个节点,附加一个缓冲区,根据需要设置循环布尔值,连接到图上将导致目的地的节点,调用 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 最初推出时带有带有音频标签的背景音乐。在发布时,我们发现请求音乐文件的次数与请求其余游戏内容的次数不成比例。经过一些研究,我们发现当时 Chrome 浏览器没有缓存音乐文件的流式传输块。这导致浏览器在播放曲目结束时每隔几分钟请求一次播放曲目。在最近的测试中,Chrome 缓存了流媒体曲目,但其他浏览器可能还没有这样做。使用音频标签流式传输大型音频文件以实现音乐播放等功能是最佳的,但对于某些浏览器版本,您可能希望以与加载声音效果相同的方式加载音乐。
由于所有音效都是通过 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 的有趣的困境和决定。 重申一个如果没有其他的,AudioBufferSourceNodes 是一次使用对象。 创建它们,附加一个音频缓冲区,将其连接到 Web 音频图,然后使用 noteOn 或 noteGrainOn 进行播放。 需要再次播放那个声音吗? 然后创建另一个 AudioBufferSourceNode。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 了解 CSS 滤镜效果
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论