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。