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。