案例研究 - 一款使用网络音频的 HTML5 游戏的故事

Z Goddard
Z Goddard

小型过山车

Fieldrunners 屏幕截图
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!)。您可以在“给我节奏”中找到 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。