Fieldrunners
Fieldrunners to nagradzana gra typu obrona wieży, która pierwotnie została wydana na iPhone w 2008 roku. Od tego czasu gra została przeniesiona na wiele innych platform. Jedną z najnowszych platform była przeglądarka Chrome w październiku 2011 r. Jednym z wyzwań związanych z przenoszeniem gry Fieldrunners na platformę HTML5 było odtwarzanie dźwięku.
Fieldrunners nie wykorzystuje skomplikowanych efektów dźwiękowych, ale ma pewne oczekiwania co do sposobu interakcji z nimi. Gra ma 88 efektów dźwiękowych, z których wiele może być odtwarzanych jednocześnie. Większość tych dźwięków jest bardzo krótka i musi być odtwarzana w odpowiednim momencie, aby nie było rozbieżności z prezentacją graficzną.
Pojawiły się pewne wyzwania
Podczas przenoszenia gry Fieldrunners do HTML5 napotkaliśmy problemy z odtwarzaniem dźwięku za pomocą tagu Audio i szybko zdecydowaliśmy się skupić na interfejsie Web Audio API. Dzięki WebAudio udało nam się rozwiązać problemy takie jak duża liczba jednocześnie odtwarzanych efektów, których wymaga gra Fieldrunners. Podczas tworzenia systemu audio dla gry Fieldrunners HTML5 napotkaliśmy jednak kilka drobnych problemów, o których warto wiedzieć.
Charakter węzłów AudioBufferSourceNode
AudioBufferSourceNodes to podstawowa metoda odtwarzania dźwięków za pomocą WebAudio. Pamiętaj, że są to obiekty jednorazowego użytku. Tworzysz AudioBufferSourceNode, przypisujesz mu bufor, łączysz go z wykresem i odtwarzasz za pomocą noteOn lub noteGrainOn. Po tym czasie możesz wywołać noteOff, aby zatrzymać odtwarzanie, ale nie będziesz mieć możliwości ponownego odtworzenia źródła przez wywołanie noteOn lub noteGrainOn – musisz utworzyć kolejny obiekt AudioBufferSourceNode. Możesz jednak – i to jest kluczowe – ponownie użyć tego samego obiektu AudioBuffer (możesz nawet mieć wiele aktywnych węzłów AudioBufferSourceNode, które wskazują na tę samą instancję AudioBuffer). Fragment rozgrywki z Fieldrunners znajdziesz w Give Me a Beat.
Treści, które nie są przechowywane w pamięci podręcznej
W momencie premiery serwer HTML5 gry Fieldrunners odnotował ogromną liczbę żądań dotyczących plików muzycznych. Wynika to z tego, że Chrome 15 pobiera plik we fragmentach, a następnie nie zapisuje go w pamięci podręcznej. W odpowiedzi na te prośby zdecydowaliśmy się wczytywać pliki muzyczne tak samo jak pozostałe pliki audio. Nie jest to optymalne rozwiązanie, ale niektóre wersje innych przeglądarek nadal tak robią.
Wyciszanie, gdy nie jest na pierwszym planie
Wcześniej trudno było wykryć, kiedy karta gry nie była aktywna. Przenoszenie gry Fieldrunners rozpoczęło się przed wprowadzeniem Chrome 13, w którym interfejs Page Visibility API zastąpił nasz skomplikowany kod do wykrywania rozmycia karty. Każda gra powinna używać interfejsu Visibility API do zapisywania krótkiego fragmentu kodu, który wycisza lub wstrzymuje dźwięk, jeśli nie wstrzymuje całej gry. Ponieważ Fieldrunners używa interfejsu API requestAnimationFrame, wstrzymywanie gry było obsługiwane domyślnie, ale nie dotyczyło to dźwięku.
Wstrzymywanie dźwięków
Co ciekawe, podczas zbierania opinii na temat tego artykułu dowiedzieliśmy się, że technika, której używaliśmy do wstrzymywania dźwięków, nie była odpowiednia – wykorzystywaliśmy błąd w bieżącej implementacji Web Audio do wstrzymywania odtwarzania dźwięków. Ponieważ w przyszłości zostanie to naprawione, nie możesz po prostu wstrzymać dźwięku przez odłączenie węzła lub podgrafu, aby zatrzymać odtwarzanie.
Prosta architektura węzłów Web Audio
Fieldrunners ma bardzo prosty model dźwięku. Ten model obsługuje te funkcje:
- sterować głośnością efektów dźwiękowych;
- sterować głośnością utworu muzycznego w tle;
- wyciszyć cały dźwięk,
- Wyłącz odtwarzanie dźwięków, gdy gra jest wstrzymana.
- Włącz te same dźwięki po wznowieniu gry.
- Wyłącz wszystkie dźwięki, gdy karta gry straci zaznaczenie.
- W razie potrzeby wznów odtwarzanie po zakończeniu odtwarzania dźwięku.
Aby uzyskać powyższe funkcje za pomocą Web Audio, wykorzystano 3 z dostępnych węzłów: DestinationNode, GainNode i AudioBufferSourceNode. Węzły AudioBufferSourceNode odtwarzają dźwięki. Węzły GainNode łączą ze sobą węzły AudioBufferSourceNode. Węzeł DestinationNode, utworzony przez kontekst Web Audio, o nazwie destination, odtwarza dźwięki dla gracza. Web Audio ma o wiele więcej typów węzłów, ale tylko z tymi możemy utworzyć bardzo prosty wykres dźwięków w grze.
Graf węzłów Web Audio prowadzi od węzłów liści do węzła docelowego. W grze Fieldrunners użyto 6 stałych węzłów wzmocnienia, ale 3 wystarczą, aby łatwo kontrolować głośność i połączyć większą liczbę węzłów tymczasowych, które będą odtwarzać bufory. Najpierw główny węzeł wzmocnienia, który łączy każdy węzeł podrzędny z miejscem docelowym. Bezpośrednio do głównego węzła wzmocnienia są podłączone 2 węzły wzmocnienia: jeden dla kanału muzycznego, a drugi do połączenia wszystkich efektów dźwiękowych.
Gra Fieldrunners miała 3 dodatkowe węzły wzmocnienia, ponieważ błąd był nieprawidłowo używany jako funkcja. Użyliśmy tych węzłów, aby odciąć grupy odtwarzanych dźwięków od wykresu, co zatrzymuje ich postęp. Zrobiliśmy to, aby wstrzymać dźwięki. Ponieważ jest to nieprawidłowe, użyjemy teraz tylko 3 węzłów wzmocnienia, jak opisano powyżej. Wiele z poniższych fragmentów kodu będzie zawierać nieprawidłowe węzły, pokazując, co zrobiliśmy i jak możemy to naprawić w krótkim czasie. ale w dłuższej perspektywie nie należy używać naszych węzłów po węźle 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 );
}
Większość gier umożliwia oddzielne sterowanie efektami dźwiękowymi i muzyką. Można to łatwo zrobić, korzystając z powyższego wykresu. Każdy węzeł wzmocnienia ma atrybut „gain”, który można ustawić na dowolną wartość dziesiętną z zakresu od 0 do 1. Można go używać do sterowania głośnością. Ponieważ chcemy osobno kontrolować głośność kanałów muzyki i efektów dźwiękowych, mamy dla każdego z nich węzeł wzmocnienia, w którym możemy kontrolować głośność.
function setArbitraryVolume() {
var musicGainNode = this.nodes.backgroundMusicGain;
// set music volume to 50%
musicGainNode.gain.value = 0.5;
}
Możemy użyć tej samej funkcji, aby kontrolować głośność wszystkiego, efektów dźwiękowych i muzyki. Ustawienie wzmocnienia węzła głównego wpłynie na wszystkie dźwięki z gry. Jeśli ustawisz wartość wzmocnienia na 0, wyciszysz dźwięk i muzykę. Węzły AudioBufferSourceNode również mają parametr wzmocnienia. Możesz śledzić listę wszystkich odtwarzanych dźwięków i dostosowywać ich wartości wzmocnienia, aby uzyskać odpowiednią głośność. Jeśli tworzysz efekty dźwiękowe za pomocą tagów audio, musisz to zrobić. Zamiast tego wykres węzłów Web Audio znacznie ułatwia modyfikowanie głośności niezliczonych dźwięków. Kontrolowanie głośności w ten sposób zapewnia też dodatkową moc bez komplikacji. Możemy po prostu podłączyć AudioBufferSourceNode bezpośrednio do węzła głównego, aby odtwarzać muzykę i sterować jej wzmocnieniem. Musisz jednak ustawiać tę wartość za każdym razem, gdy tworzysz AudioBufferSourceNode do odtwarzania muzyki. Zamiast tego zmieniasz tylko jeden węzeł, gdy gracz zmienia głośność muzyki i podczas uruchamiania. Teraz mamy wartość wzmocnienia w źródłach bufora, która pozwala na coś innego. W przypadku muzyki często używa się go do tworzenia płynnego przejścia między ścieżkami audio, gdy jedna się kończy, a druga zaczyna. Web Audio zapewnia łatwy sposób na wykonanie tej czynności.
function arbitraryCrossfade( track1, track2 ) {
track1.gain.linearRampToValueAtTime( 0, 1 );
track2.gain.linearRampToValueAtTime( 1, 1 );
}
W grze Fieldrunners nie zastosowano efektu przenikania. Gdybyśmy wiedzieli o funkcji ustawiania wartości WebAudio podczas pierwszego przejścia systemu dźwiękowego, prawdopodobnie byśmy to zrobili.
Wstrzymywanie dźwięków
Gdy gracz wstrzyma grę, może oczekiwać, że niektóre dźwięki będą nadal odtwarzane. Dźwięk jest świetnym sposobem na przekazywanie informacji zwrotnych podczas naciskania elementów interfejsu w menu gry. Fieldrunners ma wiele interfejsów, z którymi użytkownik może wchodzić w interakcje, gdy gra jest wstrzymana, więc chcemy, aby nadal były odtwarzane. Nie chcemy jednak, aby długie lub zapętlone dźwięki były odtwarzane w nieskończoność. Zatrzymanie tych dźwięków za pomocą Web Audio jest dość łatwe, a przynajmniej tak nam się wydawało.
AudioManager.prototype.pauseEffects = function() {
this.nodes.effectsGain.disconnect();
}
Węzeł wstrzymanych efektów jest nadal połączony. Wszystkie dźwięki, które mogą ignorować stan wstrzymania gry, będą nadal odtwarzane. Gdy gra zostanie wznowiona, możemy ponownie połączyć te węzły i natychmiast odtworzyć wszystkie dźwięki.
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}
Po opublikowaniu Fieldrunners odkryliśmy, że odłączenie węzła lub podgrafu nie wstrzymuje odtwarzania węzłów AudioBufferSourceNode. Wykorzystaliśmy błąd w WebAudio, który obecnie zatrzymuje odtwarzanie węzłów niepołączonych z węzłem docelowym na wykresie. Aby upewnić się, że jesteśmy gotowi na tę przyszłą poprawkę, potrzebujemy kodu takiego jak ten:
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;
}
}
};
Gdybyśmy wiedzieli wcześniej, że wykorzystujemy błąd, struktura naszego kodu audio byłaby zupełnie inna. W związku z tym wprowadziliśmy zmiany w kilku sekcjach tego artykułu. Ma to bezpośredni wpływ na ten fragment, ale także na fragmenty kodu w „Losing Focus” i „Give Me a Beat”. Aby dowiedzieć się, jak to działa, musisz wprowadzić zmiany zarówno w grafie węzłów Fieldrunners (ponieważ utworzyliśmy węzły do skracania odtwarzania), jak i w dodatkowym kodzie, który będzie rejestrować i udostępniać stany wstrzymania, których Web Audio nie obsługuje samodzielnie.
Utrata koncentracji
W przypadku tej funkcji do gry wkracza węzeł główny. Gdy użytkownik przeglądarki przełączy się na inną kartę, gra nie będzie już widoczna. Co z oczu, to z serca, więc dźwięk też powinien zniknąć. Istnieją sposoby na określenie konkretnych stanów widoczności strony gry, ale dzięki interfejsowi Visibility API stało się to znacznie łatwiejsze.
Gra Fieldrunners będzie działać tylko na aktywnej karcie, ponieważ do wywoływania pętli aktualizacji używa metody requestAnimationFrame. Jednak kontekst Web Audio będzie nadal odtwarzać zapętlone efekty i ścieżki w tle, gdy użytkownik będzie na innej karcie. Możemy jednak temu zapobiec, używając bardzo małego fragmentu kodu, który korzysta z interfejsu 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();
}
});
}
Przed napisaniem tego artykułu myśleliśmy, że odłączenie urządzenia głównego wystarczy, aby wstrzymać cały dźwięk, a nie tylko go wyciszyć. Odłączając węzeł w odpowiednim momencie, zatrzymaliśmy przetwarzanie i odtwarzanie jego i jego węzłów podrzędnych. Po ponownym połączeniu wszystkie dźwięki i muzyka będą odtwarzane od miejsca, w którym zostały przerwane, tak samo jak rozgrywka. To nieoczekiwane zachowanie. Samo odłączenie nie wystarczy, aby zatrzymać odtwarzanie.
Interfejs Page Visibility API ułatwia określenie, kiedy karta nie jest już aktywna. Jeśli masz już skuteczny kod do wstrzymywania dźwięków, wystarczy kilka linijek, aby napisać kod wstrzymujący dźwięk, gdy karta gry jest ukryta.
Zapodaj jakiś bit
Mamy już skonfigurowane kilka rzeczy. Mamy graf węzłów. Możemy wstrzymywać dźwięki, gdy gracz wstrzymuje grę, i odtwarzać nowe dźwięki w przypadku elementów takich jak menu gry. Możemy wstrzymać wszystkie dźwięki i muzykę, gdy użytkownik przełączy się na nową kartę. Teraz musimy odtworzyć dźwięk.
Zamiast odtwarzać wiele kopii dźwięku dla wielu wystąpień elementu gry, np. śmierci postaci, Fieldrunners odtwarza jeden dźwięk tylko raz przez cały czas jego trwania. Jeśli dźwięk jest potrzebny po zakończeniu odtwarzania, można go ponownie uruchomić, ale nie podczas odtwarzania. To decyzja dotycząca projektu dźwiękowego Fieldrunners, ponieważ zawiera on dźwięki, które mają być odtwarzane szybko, co w przeciwnym razie powodowałoby zacinanie się, gdyby można było je ponownie uruchomić, lub nieprzyjemną kakofonię, gdyby można było odtwarzać wiele instancji. Węzły AudioBufferSourceNode powinny być używane jako jednorazowe. Utwórz węzeł, dołącz bufor, w razie potrzeby ustaw wartość logiczną pętli, połącz się z węzłem na wykresie, który prowadzi do miejsca docelowego, wywołaj noteOn lub noteGrainOn i opcjonalnie wywołaj noteOff.
W przypadku Fieldrunners wygląda to mniej więcej tak:
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 );
}
}
Zbyt dużo strumieniowania
Gra Fieldrunners została pierwotnie uruchomiona z muzyką w tle odtwarzaną za pomocą tagu Audio. Po premierze odkryliśmy, że pliki muzyczne były żądane nieproporcjonalnie wiele razy w porównaniu z pozostałą zawartością gry. Po przeprowadzeniu analizy odkryliśmy, że w tym czasie przeglądarka Chrome nie zapisywała w pamięci podręcznej strumieniowanych fragmentów plików muzycznych. W rezultacie przeglądarka co kilka minut prosiła o odtwarzanie utworu, gdy tylko się kończył. W nowszych testach Chrome zapisuje strumieniowane utwory w pamięci podręcznej, ale inne przeglądarki mogą jeszcze tego nie robić. Strumieniowe przesyłanie dużych plików audio za pomocą tagu Audio w celu odtwarzania muzyki jest optymalne, ale w przypadku niektórych wersji przeglądarek możesz chcieć wczytywać muzykę w taki sam sposób jak efekty dźwiękowe.
Ponieważ wszystkie efekty dźwiękowe były odtwarzane przez Web Audio, przenieśliśmy odtwarzanie muzyki w tle również do Web Audio. Oznaczało to, że ścieżki będą wczytywane w taki sam sposób jak wszystkie efekty – za pomocą żądań XMLHttpRequests i typu odpowiedzi 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();
}
Podsumowanie
Przeniesienie gry Fieldrunners do Chrome i HTML5 było świetną zabawą. Oprócz ogromu pracy związanej z przeniesieniem tysięcy wierszy kodu C++ do JavaScriptu pojawiły się pewne interesujące dylematy i decyzje dotyczące HTML5. Warto powtórzyć, że AudioBufferSourceNodes są obiektami jednorazowego użytku. Utwórz je, dołącz bufor audio, połącz go z wykresem Web Audio i odtwarzaj za pomocą noteOn lub noteGrainOn. Chcesz jeszcze raz odtworzyć ten dźwięk? Następnie utwórz kolejny obiekt AudioBufferSourceNode.