Studium przypadku: A Tale of an HTML5 Game with Web Audio

Fieldrunners

Zrzut ekranu Fieldrunners
Zrzut ekranu z Fieldrunners

Fieldrunners to wielokrotnie nagradzana gra typu „obrona wieży”, która została pierwotnie wydana na iPhone w 2008 roku. Od tego czasu został przeniesiony na wiele innych platform. Jedną z najnowszych platform była przeglądarka Chrome w październiku 2011 r. Jednym z wyzwań podczas przenoszenia Fieldrunners na platformę HTML5 było odtwarzanie dźwięku.

Fieldrunners nie korzysta w skomplikowany sposób z efektów dźwiękowych, ale ma pewne oczekiwania dotyczące interakcji z tymi efektami. Gra zawiera 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 jak najkrótszym czasie, aby uniknąć jakichkolwiek przerw w prezentacji graficznej.

Wystąpiły problemy

Podczas przenoszenia Fieldrunners na HTML5 napotkaliśmy problemy z odtwarzaniem dźwięku za pomocą tagu audio. Dlatego już na samym początku zdecydowaliśmy się skupić na Web Audio API. Dzięki WebAudio udało nam się rozwiązać problemy, np. umożliwić odtwarzanie dużej liczby efektów jednocześnie, co jest wymagane w Fieldrunners. Podczas tworzenia systemu audio dla Fieldrunners HTML5 napotkaliśmy jednak kilka problemów, o których warto wiedzieć innym deweloperom.

Charakter węzłów AudioBufferSourceNodes

AudioBufferSourceNodes to podstawowa metoda odtwarzania dźwięków za pomocą WebAudio. Należy pamiętać, że są to produkty jednorazowego użytku. Utwórz węzeł AudioBufferSourceNode, przypisz mu bufor, połącz go z wykresem i odtwórz go za pomocą funkcji noteOn lub noteGrainOn. Następnie możesz wywołać metodę noteOff, aby zatrzymać odtwarzanie, ale nie będzie można ponownie odtworzyć źródła przez wywołanie metody noteOn lub noteGrainOn – musisz utworzyć inny węzeł AudioBufferSourceNode. Możesz jednak (i to jest kluczowe) ponownie użyć tego samego obiektu AudioBuffer (możesz nawet mieć kilka aktywnych węzłów AudioBufferSourceNodes, które wskazują na to samo wystąpienie AudioBuffer). Fragment odtwarzania z Fieldrunners znajdziesz w Give Me a Beat.

Treści nieprzechowywane w pamięci podręcznej

W momencie wydania serwer Fieldrunners HTML5 wykazał ogromną liczbę żądań dotyczących plików muzycznych. Wynika to z tego, że Chrome 15 pobiera pliki w kawałkach, a potem nie przechowuje ich w pamięci podręcznej. W odpowiedzi na to postanowiliśmy wczytywać pliki muzyczne tak samo jak inne pliki audio. Nie jest to optymalne rozwiązanie, ale niektóre wersje innych przeglądarek nadal to robią.

wyciszanie dźwięku, gdy aplikacja nie jest aktywna;

Wcześniej wykrycie, że karta gry nie jest aktywna, było trudne. Fieldrunners rozpoczęło portowanie przed wersją Chrome 13, w której interfejs Page Visibility API zastąpił nasz zawiły kod do wykrywania rozmywania kart. Każda gra powinna używać interfejsu Visibility API, aby napisać mały fragment kodu, który służy do wyciszenia lub wstrzymania dźwięku, a nie całej gry. Ponieważ Fieldrunners używał interfejsu requestAnimationFrame API, wstrzymywanie gry było obsługiwane pośrednio, ale nie wstrzymywanie dźwięku.

Wstrzymywanie dźwięków

O dziwo, podczas otrzymywania opinii na temat tego artykułu poinformowano nas, że technika używana przez nas do wstrzymywania odtwarzania dźwięku nie była odpowiednia. Używaliśmy błędu w obecnej implementacji Web Audio, aby wstrzymywać odtwarzanie dźwięku. W przyszłości ten problem zostanie rozwiązany, ale obecnie nie możesz wstrzymać dźwięku przez odłączenie węzła lub podgrafu, aby zatrzymać odtwarzanie.

Prosta architektura węzła dźwięku w przeglądarce

Fieldrunners ma bardzo prosty model audio. Ten model może obsługiwać te zestawy funkcji:

  • sterować głośnością efektów dźwiękowych;
  • sterować głośnością ścieżki muzycznej odtwarzanej 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, gdy wznowisz grę.
  • Wyłącz wszystkie dźwięki, gdy karta gry przestaje być aktywna.
  • W razie potrzeby ponownie uruchom odtwarzanie po odtworzeniu dźwięku.

Aby uzyskać te funkcje w Audio na potrzeby sieci Web, użyto 3 z dostępnych węzłów: DestinationNode, GainNode i AudioBufferSourceNode. AudioBufferSourceNodes odtwarza dźwięki. Elementy GainNode łączą ze sobą elementy AudioBufferSourceNode. Element DestinationNode utworzony przez kontekst Web Audio o nazwie destination odtwarza dźwięki dla odtwarzacza. Web Audio ma wiele innych typów węzłów, ale nawet te 3 wystarczą do stworzenia bardzo prostego grafu dźwięków w grze.

Wykres grafu węzła

Graf węzła Web Audio prowadzi od węzłów liściastych do węzła docelowego. Fieldrunners używał 6 trwałych węzłów wzmocnienia, ale 3 wystarczą do łatwego sterowania głośnością i połączenia większej liczby tymczasowych węzłów, które będą odtwarzać bufory. Najpierw węzeł głównego wzmocnienia, który łączy każdy węzeł podrzędny z miejscem docelowym. Do węzła głównego wzmocnienia są bezpośrednio dołączone 2 węzły wzmocnienia: jeden dla kanału muzycznego, a drugi do łączenia wszystkich efektów dźwiękowych.

Fieldrunners miał 3 dodatkowe węzły wzmocnienia z powodu nieprawidłowego użycia błędu jako funkcji. Użyliśmy tych węzłów, aby odcinać z wykresu grupy odtwarzanych dźwięków, co powoduje zatrzymanie ich odtwarzania. Zrobiliśmy to, aby wstrzymać dźwięki. Ponieważ nie jest to poprawne, użyjemy tylko 3 węzłów zysku całkowitego, jak opisano powyżej. Wiele poniższych fragmentów kodu będzie zawierać nieprawidłowe węzły, pokazujące, co zrobiliśmy i jak można to szybko naprawić. Jednak z czasem nie będziesz już 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 osobne sterowanie efektami dźwiękowymi i muzyką. Możesz to łatwo zrobić, korzystając z wykresu powyżej. Każdy węzeł wzmocnienia ma atrybut „wzmocnienie”, który może mieć dowolną wartość dziesiętną z zakresu od 0 do 1. Można go wykorzystać do regulowania głośności. Ponieważ chcemy sterować głośnością kanałów muzyki i efektów dźwiękowych oddzielnie, mamy dla nich po jednym węźle wzmocnienia, za pomocą którego możemy regulować ich 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 do sterowania głośnością wszystkich efektów dźwiękowych i muzyki. Ustawienie wzmocnienia w węźle głównym będzie miało wpływ na wszystkie dźwięki z gry. Jeśli ustawisz wartość wzmocnienia na 0, wyciszysz dźwięk i muzykę. Źródła AudioBufferSourceNode mają też parametr wzmocnienia. Możesz śledzić listę wszystkich odtwarzanych dźwięków i indywidualnie dostosowywać wartości wzmocnienia dla ogólnej głośności. Jeśli tworzysz efekty dźwiękowe za pomocą tagów audio, musisz to zrobić. Zamiast tego możesz użyć grafu węzłów w Web Audio, aby łatwiej modyfikować głośność niezliczonych dźwięków. Sterowanie głośnością w ten sposób daje Ci dodatkową moc bez komplikacji. Możemy po prostu dołączyć węzeł AudioBufferSourceNode bezpośrednio do węzła głównego, aby odtwarzać muzykę i sterować jej głośnością. Tę wartość trzeba jednak ustawiać za każdym razem, gdy tworzysz węzeł AudioBufferSourceNode w celu odtwarzania muzyki. Zamiast tego zmieniasz tylko 1 węzeł, gdy gracz zmienia głośność muzyki i w momencie uruchomienia. Teraz mamy wartość zysku w źródłach buforowych, aby wykonać inne działanie. W przypadku muzyki często stosuje się go do tworzenia płynnego przejścia z jednej ścieżki dźwiękowej na drugą. Web Audio to wygodna metoda na łatwe przeprowadzenie tej operacji.

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

Fieldrunners nie używał w swoim programie efektu przejścia. Gdybyśmy w trakcie pierwotnego testowania systemu dźwiękowego wiedzieli o funkcji ustawiania wartości w WebAudio, prawdopodobnie byśmy ją wykorzystali.

Wstrzymywanie odtwarzania dźwięków

Gdy gracz wstrzyma grę, niektóre dźwięki mogą być nadal odtwarzane. Dźwięk jest ważnym elementem informacji zwrotnej w przypadku częstego naciskania elementów interfejsu w menu gry. Podczas pauzy w grze Fieldrunners użytkownik może korzystać z kilku interfejsów, dlatego chcemy, aby nadal były one dostępne. Nie chcemy jednak, aby dźwięki były długie lub odtwarzane w pętli. W przypadku Web Audio można łatwo zatrzymać te dźwięki, przynajmniej tak nam się wydawało.

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

Wstrzymany węzeł efektów jest nadal połączony. 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 wysłaniu Fieldrunners odkryliśmy, że odłączenie węzła lub podgrafu nie powoduje wstrzymania odtwarzania węzłów AudioBufferSourceNodes. Wykorzystaliśmy błąd w WebAudio, który obecnie zatrzymuje odtwarzanie węzłów nie połączonych z węzłem docelowym na wykresie. Aby mieć pewność, że jesteśmy gotowi na przyszłe poprawki, 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 wcześniej wiedzieli, że wykorzystujemy błąd, struktura naszego kodu audio wyglądałaby zupełnie inaczej. W związku z tym zmieniliśmy kilka sekcji tego artykułu. Ma to bezpośredni wpływ na ten fragment kodu, ale też na fragmenty kodu w scenariuszach utraty koncentracji i Zrób mi przerwę. Aby to działało, trzeba wprowadzić zmiany zarówno w grafu węzła Fieldrunners (ponieważ utworzyliśmy węzły do skracania odtwarzania), jak i w dodatkowym kodzie, który będzie rejestrować i przekazywać stany wstrzymania, których Web Audio nie obsługuje samodzielnie.

Utrata skupienia

W przypadku tej funkcji korzystamy z węzła głównego. Gdy użytkownik przeglądarki przełączy się na inną kartę, gra przestaje być widoczna. Niewidzialny, niesłyszalny. Istnieją pewne sztuczki, które można wykorzystać do określenia konkretnych stanów widoczności strony gry, ale dzięki interfejsowi Visibility API jest to znacznie łatwiejsze.

Fieldrunners będzie odtwarzany tylko jako aktywna karta dzięki wywołaniu pętli aktualizacji za pomocą requestAnimationFrame. Jednak kontekst dźwięku w WWW będzie nadal odtwarzać pętlę efektów i ścieżek w tle, gdy użytkownik będzie na innej karcie. Możemy jednak temu zapobiec, używając bardzo krótkiego 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();
    }
  });
}

Zanim napisaliśmy ten artykuł, myśleliśmy, że odłączenie głównego dźwięku wystarczy, aby wstrzymać cały dźwięk zamiast go wyciszyć. Odłączenie węzła w tym momencie spowodowało, że przestał on przetwarzać i odtwarzać dane. Po ponownym połączeniu wszystkie dźwięki i muzyka będą odtwarzane od miejsca, w którym zostały przerwane, a gra będzie kontynuowana od miejsca, w którym została przerwana. Jest to jednak nieoczekiwane zachowanie. Nie wystarczy odłączyć się od urządzenia, aby zatrzymać odtwarzanie.

Dzięki interfejsowi Page Visibility API możesz łatwo sprawdzić, czy karta nie jest już aktywna. Jeśli masz już działający kod do wstrzymywania dźwięku, wystarczy dodać kilka linii kodu, aby dźwięk był wstrzymywany, gdy karta gier jest ukryta.

Give Me a Beat

Mamy już kilka rzeczy skonfigurowanych. Mamy wykres węzłów. Możemy wstrzymać dźwięki, gdy gracz wstrzyma grę, oraz odtwarzać nowe dźwięki dla 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ń danego elementu gry, np. śmierci postaci, Fieldrunners odtwarza dźwięk tylko raz. Jeśli dźwięk jest potrzebny po zakończeniu odtwarzania, można go ponownie uruchomić, ale nie w trakcie odtwarzania. Ta decyzja dotyczy projektu dźwiękowego Fieldrunners, ponieważ zawiera on dźwięki, które mają być odtwarzane szybko. W przeciwnym razie odtwarzanie z początku mogłoby być niepłynne, a odtwarzanie wielu instancji mogłoby spowodować nieprzyjemną kakofonię. AudioBufferSourceNodes 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 diagramie, który prowadzi do miejsca docelowego, wywołaj noteOn lub noteGrainOn, a następnie opcjonalnie wywołaj noteOff.

W przypadku Fieldrunners wygląda to 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 );
  }
}

Za dużo transmisji strumieniowych

Pierwotnie w Fieldrunners była odtwarzana muzyka w tle za pomocą tagu audio. Po premierze odkryliśmy, że pliki muzyczne były żądane w nieproporcjonalnie dużej liczbie w porównaniu do pozostałych treści gry. Po przeprowadzeniu badań stwierdziliśmy, że w tym czasie przeglądarka Chrome nie przechowywała w pamięci podręcznej fragmentów plików muzycznych przesyłanych strumieniowo. W rezultacie przeglądarka prosiła o odtwarzanie utworu co kilka minut, gdy kończył się on odtwarzać. Podczas niedawnych testów Chrome buforował strumieniowane utwory, ale inne przeglądarki mogą tego jeszcze nie robić. Strumieniowe przesyłanie dużych plików audio za pomocą tagu Audio jest optymalnym rozwiązaniem, ale w przypadku niektórych wersji przeglądarki może być konieczne wczytywanie muzyki w taki sam sposób jak efektów dźwiękowych.

Ponieważ wszystkie efekty dźwiękowe były odtwarzane przez Web Audio, przenieśliśmy też odtwarzanie muzyki w tle do Web Audio. Oznacza to, że ścieżki były wczytywane w taki sam sposób, w jaki wczytywaliśmy wszystkie efekty za pomocą XMLHttpRequest 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

Wprowadzenie Fieldrunners do Chrome i HTML5 było świetną zabawą. Oprócz ogromu pracy związanej z przekształcaniem tysięcy linii kodu C++ w JavaScript, pojawiają się też pewne interesujące dylematy i wyzwania związane z HTML5. Ponowne przypomnienie: jeśli żaden z tych elementów nie jest odpowiedni, AudioBufferSourceNodes to obiekty jednorazowego użytku. Utwórz je, dołącz bufor audio, połącz go z grafem Web Audio i odtwórz za pomocą noteOn lub noteGrainOn. Czy chcesz odtworzyć ten dźwięk jeszcze raz? Następnie utwórz kolejny węzeł AudioBufferSourceNode.