Fieldrunners
Fieldrunners は、2008 年に iPhone 向けにリリースされた、受賞歴のあるタワーディフェンス スタイルのゲームです。それ以来、多くのプラットフォームに移植されています。最新のプラットフォームの一つが、2011 年 10 月の Chrome ブラウザです。Fieldrunners を HTML5 プラットフォームに移植する際の課題の一つは、音声を再生する方法でした。
Fieldrunners では効果音は複雑に使用されていませんが、効果音との連携方法には一定の期待があります。このゲームには 88 個の効果音があり、そのうちの多くの効果音が同時に再生される可能性があります。これらの音のほとんどは非常に短く、グラフィック表示との不整合が生じないように、できるだけタイムリーに再生する必要があります。
いくつかの課題が出現しました
Fieldrunners を HTML5 に移植する際に、Audio タグによる音声再生で問題が発生したため、早い段階で Web Audio API に重点を置くことにしました。WebAudio を使用すると、Fieldrunners で必要となる多数のエフェクトを同時に再生するなどの問題を解決できました。それでも、Fieldrunners HTML5 の音声システムを開発する際に、他のデベロッパーが注意すべき微妙な問題がいくつかありました。
AudioBufferSourceNodes の性質
AudioBufferSourceNode は、WebAudio で音声を再生する主な方法です。これらは使い捨てオブジェクトであることを理解することが重要です。AudioBufferSourceNode を作成し、バッファを割り当ててグラフに接続し、noteOn または noteGrainOn で再生します。その後、noteOff を呼び出して再生を停止できますが、noteOn または noteGrainOn を呼び出してソースを再度再生することはできません。別の AudioBufferSourceNode を作成する必要があります。ただし、重要なのは、基盤となる同じ AudioBuffer オブジェクトを再利用できることです(実際、同じ AudioBuffer インスタンスを参照する複数のアクティブな AudioBufferSourceNode を作成することもできます)。Fieldrunners の再生スニペットは、Give Me a Beat で確認できます。
キャッシュに保存しないコンテンツ
リリース時に、Fieldrunners HTML5 サーバーで音楽ファイルのリクエストが大量に発生しました。この問題は、Chrome 15 でファイルがチャンクでダウンロードされ、キャッシュに保存されなかったことが原因で発生しました。そのため、当時は他の音声ファイルと同様に音楽ファイルを読み込むことにしました。これは最適な方法ではありませんが、他のブラウザの一部バージョンでは引き続きこの方法が使用されています。
ピントが合っていないときの音声のミュート
以前は、ゲームのタブがフォーカスから外れたタイミングを検出するのが困難でした。Fieldrunners は、タブのぼかしを検出するために複雑なコードを必要としない Page Visibility API が導入された Chrome 13 より前に移植を開始しました。すべてのゲームで、Visibility API を使用して、ゲーム全体を一時停止しない場合は、サウンドをミュートまたは一時停止する小さなスニペットを記述する必要があります。Fieldrunners は requestAnimationFrame API を使用していたため、ゲームの一時停止は暗黙的に処理されていましたが、サウンドの一時停止は処理されていませんでした。
サウンドの一時停止
おかしなことに、この記事に対するフィードバックを受け取っている際に、音声の一時停止に使用していた手法が適切ではないという報告を受けました。Web Audio の現在の実装のバグを利用して音声の再生を一時停止していたのです。この問題は今後修正される予定ですが、再生を停止するためにノードまたはサブグラフを切断して音声を一時停止することはできません。
シンプルなウェブ オーディオ ノードのアーキテクチャ
Fieldrunners は非常にシンプルなオーディオ モデルを使用しています。このモデルは、次の特徴セットをサポートできます。
- 効果音の音量を調整する。
- バックグラウンド ミュージック トラックの音量を調整する。
- すべての音声をミュートします。
- ゲームが一時停止しているときに音声を再生しないようにする。
- ゲームを再開したら、同じ音声をオンに戻します。
- ゲームのタブがフォーカスを失ったときに、すべての音声をオフにする。
- 必要に応じて、音声の再生後に再生を再開します。
Web Audio で上記の機能を実現するために、提供されているノードの中から DestinationNode、GainNode、AudioBufferSourceNode の 3 つを使用しました。AudioBufferSourceNodes が音声を再生します。GainNode は、AudioBufferSourceNode を接続します。Web Audio コンテキストによって作成される DestinationNode(デスティネーション)は、プレーヤーの音声を再生します。Web Audio には他にも多くの種類のノードがありますが、これらのノードだけでゲーム内の音声の非常にシンプルなグラフを作成できます。
Web Audio ノードグラフは、リーフノードから宛先ノードにつながっています。Fieldrunners では 6 つの永続的なゲインノードを使用していましたが、3 つあれば音量を簡単に制御し、バッファを再生する一時的なノードを多数接続できます。まず、すべての子ノードをデスティネーションに接続するマスター ゲインノードが作成されます。マスター ゲインノードにすぐに接続されている 2 つのゲインノード(1 つは音楽チャンネル用、もう 1 つはすべての効果音をリンクするためのもの)があります。
Fieldrunners では、バグを機能として誤って使用していたため、3 つの余分なゲインノードがありました。これらのノードを使用して、再生音声のグループをグラフから切り離し、再生を停止しました。これは、音声を一時停止するためです。正しくないため、上記のように合計 3 つのゲインノードのみを使用します。以下のスニペットの多くには、誤ったノードと、Google が行った対応、および短期的に修正する方法が含まれています。ただし、長期的には、coreEffectsGain ノード以降で Google のノードを使用しないことをおすすめします。
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 に設定すると、音声と音楽がミュートされます。AudioBufferSourceNode にはゲイン パラメータもあります。再生中のすべての音声のリストをトラッキングし、全体的な音量に合わせてゲイン値を個別に調整できます。Audio タグで効果音を作成していた場合、これは必須の作業でした。Web Audio のノードグラフでは、無数の音の音量を簡単に変更できます。この方法で音量を調整すると、複雑な操作なしに余分な電力を消費できます。AudioBufferSourceNode をマスターノードに直接接続して音楽を再生し、独自のゲインを制御することもできます。ただし、音楽を再生するために AudioBufferSourceNode を作成するたびに、この値を設定する必要があります。代わりに、プレーヤーが音楽の音量を変更したときと起動時にのみ、1 つのノードを変更します。これで、バッファソースのゲイン値を取得して、他の処理を行うことができます。音楽では、ある音声トラックが終了し、別の音声トラックが開始するときに、ある音声トラックから別の音声トラックへのクロスフェードを作成することがあります。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 の再生が一時停止されないことが判明しました。実際には、グラフ内の Destination ノードに接続されていないノードの再生を停止する 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;
}
}
};
バグを悪用していたことを以前から知っていれば、音声コードの構造は大きく変わっていたでしょう。そのため、この記事のいくつかのセクションが影響を受けています。これは、ここで直接影響しますが、Losing Focus と Give Me a Beat のコード スニペットにも影響します。これが実際にどのように機能するかを把握するには、Fieldrunners のノードグラフ(再生を短縮するためのノードを作成したため)と、Web Audio が独自に行わない一時停止状態を記録して提供する追加コードの両方を変更する必要があります。
フォーカスを失う
この機能ではマスターノードが使用されます。ブラウザ ユーザーが別のタブに切り替えると、ゲームは表示されなくなります。見えない音は聞こえない。ゲームのページの特定の公開状態を判断する方法はいくつかありますが、Visibility API を使用すると、はるかに簡単に判断できるようになりました。
Fieldrunners は、update ループの呼び出しに 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 では、1 つの音声をその時間だけ 1 回だけ再生します。再生が終了した後に音声が必要な場合は、再起動できますが、再生中に再起動することはできません。これは、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 に移行しました。つまり、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 に固有の興味深いジレンマや判断がいくつかあります。繰り返しになりますが、AudioBufferSourceNode は使い捨てオブジェクトです。作成して Audio Buffer を接続し、ウェブ オーディオ グラフに接続して、noteOn または noteGrainOn で再生します。音声をもう一度再生する必要がある場合は、次に、別の AudioBufferSourceNode を作成します。