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

Z Goddard
Z Goddard

跑者

FieldRunners 螢幕截圖
Fieldrunners 螢幕截圖

FieldRunners 是榮獲獎項肯定的塔防風格遊戲,最初是在 2008 年於 iPhone 平台上推出。至今已遷移至其他許多平台。最新推出的平台之一,就是 2011 年 10 月的 Chrome 瀏覽器。將 FieldRunners 移植到 HTML5 平台的其中一項挑戰,就是如何播放音效。

現場工作人員並未巧妙運用音效,但實際做法有一定程度,知道如何與音效互動。這款遊戲有 88 種音效,可預期一次可播放大量音訊。這些音效大多都很短,而且必須盡可能及時播放,以免和圖像的簡報產生混淆。

出現一些挑戰

將 FieldRunner 移植至 HTML5 時,我們在使用音訊標記播放音訊時遇到問題,因此及早決定改為專注在 Web Audio API。使用 WebAudio 協助我們解決問題,例如提供 FieldRunner 所需的大量並行特效。然而,在為 Fieldrunners HTML5 開發音訊系統時,我們遇到了一些其他開發人員需要注意的細微問題。

AudioBufferSourceNodes 的性質

AudioBufferSourceNodes 是您透過 WebAudio 播放音訊的主要方法。請留意,這些物件是一次性的物件。建立 AudioBufferSourceNode、為其指派緩衝區,將其連結至圖表,然後使用 noteOn 或 noteGrainOn 播放。呼叫上述後,您可以呼叫 noteOff 來停止播放,但無法透過呼叫 noteOn 或 noteGrainOn 再次播放原始碼,必須建立另一個 AudioBufferSourceNode。您可以 - 也是鍵,重複使用相同的基礎 AudioBuffer 物件 (事實上,您甚至可以有多個指向同一個 AudioBuffer 執行個體的 AudioBufferSourceNodes!)。如需 FieldRunners 的播放程式碼片段,請前往 Give Me a Beat 。

非快取內容

發布 FieldRunners HTML5 伺服器後,顯示大量音樂檔案要求。因此 Chrome 15 繼續將檔案分段下載,然後並未進行快取。當時,我們決定載入其他音樂檔案,例如其他的音訊檔案。此做法無法達到最佳效果,但其他瀏覽器的版本仍支援此功能。

失焦時斷線

之前偵測遊戲分頁失焦的時機真的很困難。FieldRunner 已於 Chrome 13 推出之前開始移植,Page Visibility API 取代了易變的程式碼偵測分頁模糊問題,如果遊戲沒有暫停,每個遊戲都應使用 Visibility API 撰寫一小段程式碼,以便靜音或暫停整個遊戲的聲音。由於 FieldRunners 使用 requestAnimationFrame API,可以隱含處理遊戲暫停,但不會發出聲音暫停。

暫停音效

我們在獲得本文的意見回饋時,認為用來暫停音效的技術並不適合,因為我們使用了網路音訊目前實作中的錯誤來暫停播放音效。日後我們會修正這個問題,因此您無法透過取消連結節點或子圖表來暫停播放音效。

簡易網路音訊節點架構

FieldRunner 有一個相當簡單的音訊模型該模型支援以下特徵集:

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

為了透過 Web Audio 實現上述功能,該程式使用了 3 個可用的節點:DestinationNode、GainNode、AudioBufferSourceNode。AudioBufferSourceNodes 會播放音效。GainNodes 會將 AudioBufferSourceNode 連結至。由網路音訊環境建立的 DestinationNode (稱為「目的地」) 會播放播放器的聲音。網路音訊的節點雖然更多,但只有這些節點能建立簡單的圖形,用來呈現遊戲中的音效。

節點圖

Web Audio 節點圖表從分葉節點導向目的地節點。FieldRunner 使用 6 個永久取得節點,但 3 個可用節點方便您輕鬆控制磁碟區,並連結更多會播放緩衝區的臨時節點。首先,一個主要節點獲得節點,並將每個子節點連接至目的地。立即連接至主要收益節點是兩個增益節點,一個用於音樂頻道,另一個用於連結所有音效。

由於錯誤做為功能使用錯誤,FieldRunner 則有 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 時,都必須設定這個值。您只需在玩家變更音樂音量及啟動時變更一個節點即可。現在,我們在緩衝區來源上獲得了其他需要執行其他操作的值。就音樂而言,一種常見用途是建立交互淡出的音軌,然後換到另一首音軌。網路音訊提供絕佳的執行方法。

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

FieldRunner 並未特別使用交叉漸變效果。以前我們可能擁有的音響系統,在原始音訊系統傳遞期間得知 WebAudio 的價值設定功能。

暫停音效

玩家暫停遊戲時,可能會發現一些音效仍會繼續播放。音效是遊戲選單中的使用者介面元素的絕佳回應之一。FieldRunners 提供多個介面,可讓使用者在遊戲暫停時互動。我們仍然希望這些介面能夠進行。不過,我們不希望任何長或循環的聲音持續播放。使用網路音訊或至少我們心想的力量可以輕易阻止這些聲音。

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

已暫停的效果節點仍保持連結狀態。凡是可忽略遊戲暫停狀態的音效,都會繼續播放該語音。遊戲取消暫停後,我們可以重新連結節點,並立即再次播放所有音效。

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

我們在運送 FieldRunner 後發現,只中斷節點或子圖表的連線並不會暫停播放 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;
    }
  }
};

如果我們先前知道這是濫用錯誤,音訊程式碼的結構會大不相同。因此,本文中的許多章節受到影響。這個做法會直接影響到「失敗焦點」,也應用在我們的程式碼片段中。要瞭解實際的運作方式,就必須變更 FieldRunners 節點圖 (因為我們建立用來縮短播放的節點),其他程式碼則會記錄並提供網路音訊不會自行執行的暫停狀態。

失去專注力

我們的主要節點可以支援這項功能。一旦瀏覽器使用者切換至其他分頁,就不會再顯示遊戲。離開視線範圍,人聲應該消失。您可以運用一些技巧,判斷遊戲頁面的顯示設定狀態。不過,使用 Visibility API 變得輕鬆許多。

由於使用 requestAnimationFrame 呼叫更新迴圈,因此 FieldRunner 只會以使用中的分頁播放。不過,當使用者瀏覽其他分頁時,網路音訊內容會繼續播放循環效果和背景音軌。但是,我們可以利用極小的 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,您可以輕鬆瞭解何時不再聚焦分頁。如果已有可暫停音效的有效程式碼,只要在遊戲分頁隱藏時,只需幾行就能讀出暫停音效。

給我節奏

我們現在要設定了一些內容。這張圖顯示了節點圖表我們可以在玩家暫停遊戲時暫停音效,以及播放遊戲選單等元素的新音效。當使用者切換至新分頁時,系統會暫停所有音效和音樂。現在我們需要實際播放音效

FieldRunner 不會在遊戲實體的多個執行個體 (例如角色死亡) 中重複播放多個音效,而是在執行期間只播放一次音效。播放完畢後,如果需要聲音,則音訊可以重新開始,但在播放期間不能。這也是 FieldRunner 的音訊設計決策依據,因為這類音訊要求能快速播放音訊,如果允許重新啟動,或造成不愉快的煤炭如果允許播放多個執行個體,就會發生延遲情形。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 );
  }
}

串流內容過多

FieldRunner 最初是以「音訊」標記播放的背景音樂來啟動。發行時,我們發現使用者要求音樂檔案的次數遠低於系統要求其他遊戲內容的次數。經過調查後,我們發現 Chrome 瀏覽器當時並未快取串流音樂檔案區塊。因此瀏覽器在播放曲目結束時,每隔幾分鐘就要求播放曲目。最近一項測試是 Chrome 快取串流測試群組,但其他瀏覽器可能尚未進行這項作業。使用音訊標記串流播放大型音訊檔案以提供音樂播放等功能是最理想的做法,但在某些瀏覽器版本中,建議您載入音樂的方式與載入音效相同。

由於所有音效都是透過網路音訊播放,因此我們也將背景音樂播放也轉移至 Web Audio。也就是說,我們會以使用 XMLHttpRequests 和陣列緩衝區回應類型載入所有效果的方式,載入追蹤軌跡。

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

摘要

FieldRunner 很熱衷於遷移至 Chrome 和 HTML5。工作範圍外,將數以千計的 C++ 程式碼帶入 JavaScript 中,有一些有趣的難題,以及與 HTML5 主題相關的決定。如要在沒有其他項目的情況下重整一次,AudioBufferSourceNodes 是一次性物件。你可以建立音訊緩衝區,附加音訊緩衝區,將其連接至網路音訊圖表,然後使用筆記開啟或 noteGrainOn 等動作。需要再播放一次該音效嗎?然後建立另一個 AudioBufferSourceNode。