個案研究 - HTML5 遊戲搭配網路音訊的故事

Z Goddard
Z Goddard

Fieldrunners

Fieldrunners 螢幕截圖
Fieldrunners 螢幕截圖

Fieldrunners 是一款獲獎的塔防遊戲,最初於 2008 年推出,適用於 iPhone。自此之後,這款遊戲已移植到許多其他平台。其中一個最新平台是 2011 年 10 月的 Chrome 瀏覽器。將 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 物件 (這點很重要),甚至可以有多個處於活動狀態的 AudioBufferSourceNode 指向相同的 AudioBuffer 例項!你可以在 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 會播放音訊。GainNodes 會將 AudioBufferSourceNodes 連結在一起。由 Web Audio 內容建立的 DestinationNode (稱為 destination) 會為播放器播放音效。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 也有增益參數。您可以追蹤播放中所有音訊的清單,並個別調整增益值以調整整體音量。如果您是使用 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,您可以輕鬆瞭解分頁何時不再處於焦點狀態。如果您已經有有效的程式碼可暫停音效,只要在遊戲分頁隱藏時,寫入幾行程式碼即可暫停音效。

Give Me a Beat

我們現在已完成一些設定。我們有節點圖表。我們可以在玩家暫停遊戲時暫停音效,並為遊戲選單等元素播放新音效。使用者切換至新分頁時,我們可以暫停所有音效和音樂。接下來,我們需要播放音效。

在 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 最初推出時,背景音樂是使用 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 也帶來了一些有趣的兩難抉擇和決定。如要重複使用,AudioBufferSourceNodes 是一次性使用物件。建立這些物件、附加 Audio Buffer、將其連結至 Web Audio 圖表,然後使用 noteOn 或 noteGrainOn 播放。是否需要再次播放該聲響?然後建立另一個 AudioBufferSourceNode。