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

Z Goddard
Z Goddard

《炮塔防御》

《炮塔防御》屏幕截图
《炮塔防御》屏幕截图

《炮塔防御》是一款屡获殊荣的塔防类游戏,最初于 2008 年针对 iPhone 发布。自那以后,它便被移植到许多其他平台。最新的平台之一是 2011 年 10 月发布的 Chrome 浏览器。将《炮塔防御》移植到 HTML5 平台所面临的挑战之一是如何播放声音。

《炮塔防御》并没有复杂运用音效,但玩家肯定会期待这款游戏与音效的交互方式。该游戏拥有 88 种音效,其中很多音效应该同时播放。这些声音中大多数都非常短,因此需要尽可能及时地播放,以避免与图形显示效果产生任何脱节。

出现的一些挑战

在将《炮塔防御》移植到 HTML5 的过程中,我们遇到了使用 Audio 标签播放音频方面的问题,因此在早期决定将重点转向 Web Audio API。使用 WebAudio 帮助我们解决了问题,例如为我们提供《炮塔防御》所需的大量并发播放效果。尽管如此,在为《炮塔防御》HTML5 开发音频系统时,我们遇到了一些细微的问题,其他开发者可能也已经注意到了。

AudioBufferSourceNodes 的性质

AudioBufferSourceNodes 是使用 WebAudio 播放声音的主要方法。请务必了解它们都是一次性使用对象。您创建一个 AudioBufferSourceNode,为它分配一个缓冲区,将它连接到图,然后使用 noteOn 或 noteGrainOn 进行播放。之后,您可以调用 noteOff 以停止播放,但将无法通过调用 noteOn 或 noteGrainOn 再次播放来源。您必须再创建一个 AudioBufferSourceNode。不过,您可以(这也是关键)重复使用相同的底层 AudioBuffer 对象(事实上,您甚至可以有多个指向同一 AudioBuffer 实例的活动 AudioBufferSourceNodes!)。你可以在“播放音乐”中找到《炮塔防御》的播放片段。

非缓存内容

在发布时,《炮塔防御》HTML5 服务器显示了大量针对音乐文件的请求。出现这一结果是因为 Chrome 15 继续以块的形式下载文件,而未进行缓存。当时为了做出响应,我们决定像其他音频文件一样加载音乐文件。虽然这种做法不太理想,但其他浏览器的某些版本仍可做到这一点。

失焦时静音

以前,要检测游戏标签页何时不处于焦点状态并非易事。《炮塔防御》在 Chrome 13 之前便开始移植。在 Chrome 13 之前,Page Visibility API 取代了复杂的代码来检测标签页模糊处理的情况。每款游戏都应使用 Visibility API 编写一小段代码,以便在不暂停整个游戏的情况下静音或暂停游戏。由于《炮塔防御》使用的是 requestAnimationFrame API,游戏暂停是隐式处理的,而不是声音暂停的。

暂停提示音

奇怪的是,在收到对本文的反馈时,我们被告知用于暂停声音的技术并不合适,我们当时利用网络音频当前实现中的一个错误来暂停声音播放。由于此问题将在将来得到解决,因此您不能只通过断开连接节点或子图来暂停播放声音。

简单的网络音频节点架构

《炮塔防御》的音频模型非常简单。该模型可以支持以下特征集:

  • 控制音效的音量。
  • 控制背景音乐曲目的音量。
  • 完全静音。
  • 在游戏暂停时关闭声音播放。
  • 在游戏继续时重新开启这些声音。
  • 在游戏的标签页失去焦点时关闭所有音频。
  • 根据需要,在声音播放完毕后重新开始播放。

为了通过网络音频实现上述功能,它使用了提供的可能节点中的 3 个:DestinationNode、GainNode、AudioBufferSourceNode。AudioBufferSourceNodes 用于播放声音。GainNodes 将 AudioBufferSourceNodes 连接在一起。DestinationNode 称为目标,由网络音频上下文创建,用于为播放器播放声音。网络音频具有更多类型的节点,但只有这些节点类型,我们才能为游戏中的声音创建一个非常简单的图表。

节点图

网络音频节点图从叶节点通向目标节点。《炮塔防御》使用了 6 个永久增益节点,但 3 个节点足以支持轻松控制音量并连接更多用于播放缓冲区的临时节点。首先是将每个子节点连接到目标的主增益节点。与主增益节点直接相连的两个增益节点,一个用于音乐频道,另一个用于连接所有音效。

由于误将错误用作功能,《炮塔防御》有 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 );
}

大多数游戏都允许单独控制音效和音乐。使用上图可以轻松实现此目的。每个增益节点都有一个“gain”属性,该属性可设置为 0 到 1 之间的任何十进制值,可用于本质上控制音量。由于我们想分别控制音乐和声效频道的音量,因此我们为每个要控制其音量的增益节点分别设置了一个增益节点。

function setArbitraryVolume() {
  var musicGainNode = this.nodes.backgroundMusicGain;

  // set music volume to 50%
  musicGainNode.gain.value = 0.5;
}

我们可以使用相同的功能来控制音效和音乐等所有内容的音量。设置主节点的增益会影响游戏中的所有声音。如果您将增益值设置为 0,则会将声音和音乐静音。 AudioBufferSourceNodes 也有增益参数。您可以跟踪所有播放声音的列表,并针对整体音量单独调整它们的增益值。如果您使用音频标签制作音效,则必须这样做。相反,网络音频的节点图可让您更轻松地修改无数声音的音量。 以这种方式控制音量还可以获得额外的功能,而不会增加复杂性。我们只需将 AudioBufferSourceNode 直接连接到主节点,即可播放音乐并控制其自己的增益。但若要播放音乐,则每次创建 AudioBufferSourceNode 时都必须设置此值。而是只应在玩家更改音乐音量和启动时更改一个节点。现在,我们在缓冲区源上使用增益值来执行其他操作。对于音乐,一种常见用途是在一个音轨之间建立淡入淡出,并在一首音轨和另一音轨进入时进行淡入淡出。网络音频提供了一种用于轻松执行此操作的好方法。

function arbitraryCrossfade( track1, track2 ) {
  track1.gain.linearRampToValueAtTime( 0, 1 );
  track2.gain.linearRampToValueAtTime( 1, 1 );
}

《炮塔防御》并未具体使用淡入淡出。在我们最初对声音系统进行遍历的过程中,我们得知了 WebAudio 的值设置功能。

暂停提示音

在玩家暂停游戏后,他们可能会希望系统仍然播放某些声音。在针对游戏菜单中界面元素的常规按下操作的反馈中,声音占了很大一部分。《炮塔防御》有多个界面可供用户在游戏暂停期间进行互动,因此我们仍然希望这些界面可以一直播放。但是,我们不希望持续播放任何冗长或循环的声音。使用网络音频可以很容易地停止这些声音,或者至少我们是这么认为的。

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();
}

暂停的音效节点仍保持连接状态。任何允许忽略游戏暂停状态的声音都将继续播放。当游戏取消暂停时,我们可以重新连接这些节点,立即重新播放所有声音。

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}

在发布《炮塔防御》之后,我们发现仅断开节点或子图不会暂停 AudioBufferSourceNodes 的播放。实际上,我们利用了 WebAudio 中的一个错误,即当前停止播放未连接到图表中目标节点的节点。因此,为了确保为将来的修复做好准备,我们需要一些如下所示的代码:

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,那么音频代码的结构将会截然不同。因此,这对本文的许多部分都产生了影响。它不仅会直接发挥作用,还会对“失去焦点”和“播放音乐”的代码段产生影响。为了了解这一实际运作方式,需要同时更改《炮塔防御》节点图(因为我们创建了节点来限制播放)以及其他代码,这些代码用于录制并提供网络音频无法自行提供的暂停状态。

失去焦点

我们的主节点会开始使用此功能。当浏览器用户切换到其他标签页时,游戏将不再可见。失明、失明,而且声音也应消失。可以通过一些技巧来确定游戏页面的具体可见性状态,而借助 Visibility API,可以大大简化这个过程。

《炮塔防御》只会在活动标签页中运行,这是因为使用 requestAnimationFrame 调用其更新循环。但是,当用户查看另一个标签页时,网络音频环境会继续播放循环效果和背景曲目。不过,我们可以使用一个非常小的 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,我们可以非常轻松地了解您的标签页何时不再获得焦点。如果您已有用于暂停声音的有效代码,那么在“游戏”标签页处于隐藏状态时,只需几行代码即可在暂停声音期间写入内容。

播放音乐

现在,我们需要完成一些设置。这里有一张节点图。我们可以在玩家暂停游戏时暂停声音,并播放游戏菜单等元素的新声音。我们可以在用户切换到新标签页时暂停所有声音和音乐。现在,我们需要实际播放声音。

《炮塔防御》并非针对游戏实体的多个实例(例如角色死亡)播放多个声音副本,而是在其持续时间内仅播放一个声音。如果在播放完声音后需要该声音,则声音可以重新开始,但不能在已经播放时播放。这是根据《炮塔防御》的音频设计做出的,因为它具有需要快速播放的声音,否则在允许重新启动时就会卡顿,或者在允许播放多个实例时产生令人不快的噪音。AudioBufferSourceNodes 预计会被使用为一次性节点。创建一个节点,附加一个缓冲区,根据需要设置循环布尔值,连接到图表上的一个指向目的地的节点,调用 noteOn 或 noteGrainOn,并视需要调用 noteOff。

对于《炮塔防御》,显示如下:

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 );
  }
}

流式传输过多

《炮塔防御》最初发布时会使用音频标记来播放背景音乐。在发布时,我们发现请求音乐文件的次数与请求其余游戏内容的次数相比不成比例。经过一些研究,我们发现当时 Chrome 浏览器没有缓存流式传输的音乐文件块。这导致浏览器在播放完毕后每隔几分钟就会请求播放一首曲目。在最近的测试中,Chrome 缓存了流式视频轨道,但是其他浏览器可能尚未这样做。使用音频标记流式传输大型音频文件以实现音乐播放等功能是最佳选择,但对于某些浏览器版本,您可能需要按照加载音效的方式来加载音乐。

由于所有音效都是通过网络音频播放的,因此我们还将背景音乐的播放改为了网络音频。这意味着,我们要加载曲目的方式与使用 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();
}

摘要

《炮塔防御》在 Chrome 和 HTML5 上掀起了热潮。除了将数以千计的 C++ 代码行编入 JavaScript 代码自身的大量工作之外,还引发了一些特定于 HTML5 的有趣困境和决策。如果没有其他对象,则可以重用一个对象,AudioBufferSourceNodes 是一次性使用的对象。创建它们,附加音频缓冲区,将其连接到 Web Audio 图表,然后使用 noteOn 或 noteGrainOn 进行播放。需要再次播放该声音吗?然后创建另一个 AudioBufferSourceNode。