個案研究 - 採用 Web Audio 的 HTML5 遊戲

Z Goddard
Z Goddard

Fieldrunners

Fieldrunners 螢幕截圖
Fieldrunners 螢幕截圖

《Fieldrunners》是一款獲獎肯定的塔防風格遊戲,最初於 2008 年在 iPhone 上推出。之後也移植到許多其他平台。最近一次是在 2011 年 10 月,Chrome 瀏覽器也加入支援。將 Fieldrunners 移植到 HTML5 平台時,其中一項挑戰是如何播放音效。

Fieldrunners 並未複雜地使用音效,但確實對音效互動方式有一些期望。這款遊戲有 88 種音效,其中許多音效可能會同時播放。這些音效大多非常短,必須盡可能及時播放,以免與圖形呈現脫節。

出現一些挑戰

將 Fieldrunners 移植到 HTML5 時,我們遇到音訊標記的音訊播放問題,因此決定改用 Web Audio API。使用 WebAudio 幫助我們解決了問題,例如提供 Fieldrunners 需要的大量並行效果播放。不過,在為 Fieldrunners HTML5 開發音訊系統時,我們遇到了一些細微問題,其他開發人員可能需要注意。

AudioBufferSourceNodes 的性質

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 目前實作中的錯誤來暫停播放音效。由於這個問題日後會修正,因此你無法只透過中斷節點或子圖連線來暫停聲音,藉此停止播放。

簡單的 Web Audio 節點架構

《Fieldrunners》的音訊模型非常簡單。該模型支援下列功能集:

  • 控制音效音量。
  • 控制背景音樂曲目的音量。
  • 將所有音訊設為靜音。
  • 關閉遊戲暫停時播放音效的功能。
  • 並在遊戲恢復時重新開啟。
  • 當遊戲分頁失去焦點時,關閉所有音訊。
  • 視需要重新播放音效。

為了透過 Web Audio 實現上述功能,我們使用了 3 個提供的可能節點:DestinationNode、GainNode、AudioBufferSourceNode。AudioBufferSourceNodes 會播放音效。GainNode 會將 AudioBufferSourceNode 連結在一起。DestinationNode 是由 Web Audio 結構定義建立,稱為目的地,會為玩家播放音效。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 );
}

大多數遊戲都允許分別控制音效和音樂。只要使用上圖,就能輕鬆完成這項操作。每個增益節點都有一個「gain」屬性,可設為 0 到 1 之間的任何小數值,基本上可用來控制音量。由於我們想分別控制音樂和音效頻道的音量,因此每個頻道都有一個增益節點,可用來控制音量。

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

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

我們可以使用這項功能控制所有音效和音樂的音量。設定主節點的增益會影響遊戲中的所有音效。如果將增益值設為 0,音效和音樂就會靜音。AudioBufferSourceNodes 也有增益參數。您可以追蹤所有播放音效的清單,並個別調整其增益值,以調整整體音量。如果使用 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 後,我們發現單純中斷節點或子圖的連線,並不會暫停 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;
    }
  }
};

如果我們早知道自己是在濫用錯誤,音訊程式碼的結構就會大不相同。因此,本文有多個部分受到影響。這會直接影響這裡,也會影響「失去焦點」和「給我節拍」中的程式碼片段。如要瞭解實際運作方式,必須變更 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 是只能使用一次的物件。建立這些節點、附加 Audio Buffer、連結至 Web Audio 圖表,然後使用 noteOn 或 noteGrainOn 播放。想再聽一次嗎?接著建立另一個 AudioBufferSourceNode。