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

Kukawki terenowe

Zrzut ekranu z gry Fieldrunners
Zrzut ekranu z aplikacji Fieldruns

Fieldrunners to wielokrotnie nagradzana gra typu obrona wieży, która została wydana na iPhone w 2008 roku. Od tego czasu przenieśliśmy go na wiele innych platform. Jedną z najnowszych platform była przeglądarka Chrome w październiku 2011 roku. Jednym z wyzwań, które wiązały się z przeniesieniem gry Fieldrunners na platformę HTML5, było odtwarzanie dźwięku.

Twórcy Fieldrunners nie wykorzystują skomplikowanych efektów dźwiękowych, ale mają pewne oczekiwania co do tego, jak mogą wchodzić w interakcje z efektami dźwiękowymi. Gra ma 88 efektów dźwiękowych, z których można spodziewać się dużej liczby efektów jednocześnie. Większość z tych dźwięków jest bardzo krótka i należy ją odtwarzać w jak najszybszym czasie, aby uniknąć zakłóceń w działaniu prezentacji graficznej.

Niektóre wyzwania

Podczas przenoszenia Fieldrunners do HTML5 napotkaliśmy problemy z odtwarzaniem dźwięku za pomocą tagu Audio i na początku postanowiliśmy skupić się na interfejsie Web Audio API. Użycie WebAudio pozwoliło nam rozwiązać takie problemy, jak umożliwienie nam jednoczesnego korzystania z dużej liczby równoczesnych efektów dźwiękowych, których wymaga aplikacja Fieldrunner. Podczas opracowywania systemu audio na potrzeby platformy HTML5 Fieldrunner napotkaliśmy jednak kilka niuansowych problemów, o których inni deweloperzy mogą wiedzieć.

Charakter węzłów źródłowych bufora audio

AudioBufferSourceNodes to Twoja główna metoda odtwarzania dźwięków w WebAudio. Trzeba pamiętać, że są one obiektami jednorazowymi. Tworzysz obiekt AudioBufferSourceNode, przypisujesz do niego bufor, podłączasz go do wykresu i odtwarzasz za pomocą parametru NoteOn lub notesGrainOn. Potem możesz użyć wywołania notatnik NoteOff, aby zatrzymać odtwarzanie, ale nie będziesz mieć możliwości ponownego odtworzenia źródła, używając wywołania NoteOn lub NoteGrainOn – musisz utworzyć kolejny element AudioBufferSourceNode. Możesz – i to jest kluczowe – możesz ponownie użyć tego samego bazowego obiektu AudioBuffer (w rzeczywistości możesz mieć nawet wiele aktywnych węzłów AudioBufferSourceNodes, które wskazują tę samą instancję AudioBuffer). Fragment filmu z serii Fieldrunners znajdziesz w grze Get Me a Beat.

Treści niezapisywane w pamięci podręcznej

W momencie premiery serwer HTML5 firmy Fieldrunners odnotował ogromną liczbę żądań dotyczących plików muzycznych. Wynikało to z tego, że Chrome 15 pobierał plik we fragmentach, a potem nie umieszczał go w pamięci podręcznej. W odpowiedzi zdecydowaliśmy się załadować pliki muzyczne, tak jak pozostałe pliki audio. Nie jest to optymalne rozwiązanie, ale w niektórych wersjach innych przeglądarek tak jest.

Wyciszanie, gdy urządzenie jest nieostre

Wcześniej wykrywanie nieostrości karty gry było trudne. Fieldrunners rozpoczęło się przed wersją Chrome 13, gdzie interfejs API widoczności strony zastąpił złożonym kodem wykrywanie rozmycia kart. Każda gra powinna używać interfejsu Widoczność API do napisania małego fragmentu kodu, który wycisza lub wstrzymuje dźwięk, jeśli nie wstrzyma gry w całości. Ponieważ gry Fieldrunners używały interfejsu API requestAnimationFrame, wstrzymywanie gry było obsługiwane domyślnie, ale nie wstrzymywane z wykorzystaniem dźwięku.

Wstrzymuję dźwięki

Co dziwne, gdy czytaliśmy opinie na temat tego artykułu, dowiedzieliśmy się, że stosowana przez nas technika wstrzymywania dźwięków jest niewłaściwa. Wykorzystaliśmy błąd w obecnej implementacji Web Audio, aby wstrzymać odtwarzanie dźwięków. Problem zostanie naprawiony w przyszłości, więc nie możesz po prostu wstrzymać odtwarzania przez odłączenie węzła lub podgrafu.

Prosta architektura węzłów audio w internecie

Gra Fieldrunners ma bardzo prosty model audio. Ten model może obsługiwać następujący zestaw funkcji:

  • sterować głośnością efektów dźwiękowych,
  • Sterowanie głośnością ścieżki dźwiękowej w tle.
  • Wycisza cały dźwięk.
  • Wyłącza dźwięk, gdy gra jest wstrzymana.
  • Ponownie włącz te same dźwięki, gdy gra zostanie wznowiona.
  • Wyłącz cały dźwięk, gdy karta gry przestała być aktywna.
  • W razie potrzeby uruchom odtwarzanie ponownie po odtworzeniu dźwięku.

Aby osiągnąć te funkcje w Web Audio, wykorzystano 3 z dostępnych węzłów: TargetNode, GainNode oraz AudioBufferSourceNode. Węzły AudioBufferSourceNodes odtwarzają dźwięki. Te węzły łączą elementy AudioBufferSourceNodes. Węzeł docelowy utworzony na podstawie kontekstu Web Audio (tzw. miejsce docelowe) odtwarza dźwięki z odtwarzacza. Web Audio ma znacznie więcej typów węzłów, ale tylko przy nich można utworzyć bardzo prosty wykres dźwięków w grze.

Wykres z węzłami

Wykres węzłów Web Audio prowadzi od węzłów liści do węzła docelowego. Uczestnicy Fieldrunner korzystają z 6 węzłów stałych, ale 3 wystarczająco dużo, aby umożliwić łatwą kontrolę nad głośnością i podłączyć większą liczbę węzłów tymczasowych, które będą odtwarzać bufory. Najpierw węzeł wzmocnienia nadrzędny dołącza do miejsca docelowego każdy węzeł podrzędny. Bezpośrednio do głównego węzła wzmocnienia są dołączone dwa węzły wzmocnienia: jeden dla kanału muzycznego i drugi do łączenia wszystkich efektów dźwiękowych.

Gra Fieldrunner ma 3 dodatkowe węzły zysku z powodu nieprawidłowego użycia błędu jako funkcji. Wykorzystaliśmy te węzły do odcięcia z grafu grup odtwarzanych dźwięków, co zatrzymuje ich postęp. W ten sposób wstrzymywaliśmy dźwięki. Nie jest to prawidłowe działanie, więc w sposób opisany powyżej użyjemy tylko 3 węzłów przyrostowych. Wiele z poniższych fragmentów kodu będzie zawierać nieprawidłowe węzły i pokazać, co zrobiliśmy i jak w najkrótszym czasie możemy to naprawić. Jednak w dłuższej perspektywie nie warto 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 pozwala na oddzielne sterowanie efektami dźwiękowymi i muzyką. Można to łatwo osiągnąć za pomocą powyższego wykresu. Każdy węzeł wzmocnienia ma atrybut „zysk”, który może mieć dowolną wartość dziesiętną z zakresu od 0 do 1, co pozwala zasadniczo sterować głośnością. Chcemy osobno regulować głośność muzyki i kanałów efektów dźwiękowych, więc mamy dla każdego z nich osobny węzeł wzmocnienia.

function setArbitraryVolume() {
  var musicGainNode = this.nodes.backgroundMusicGain;

  // set music volume to 50%
  musicGainNode.gain.value = 0.5;
}

Z tego samego poziomu możemy sterować głośnością wszystkiego, efektów dźwiękowych i muzyki. Ustawienie wzmocnienia węzła głównego będzie miało wpływ na cały dźwięk z gry. Jeśli ustawisz wartość wzmocnienia na 0, wyciszysz dźwięk i muzykę. AudioBufferSourceNodes też ma parametr wzmocnienia. Możesz śledzić listę wszystkich odtwarzanych dźwięków i dostosowywać poszczególne wartości wzmocnienia, aby określić ogólną głośność. Oto, co należy zrobić przy tworzeniu efektów dźwiękowych za pomocą tagów audio. Wykres węzłów Web Audio znacznie ułatwia modyfikację głośności niezliczonych dźwięków. Sterowanie głośnością w ten sposób zapewnia też dodatkową moc bez komplikacji. Mogliśmy po prostu podłączyć AudioBufferSourceNode bezpośrednio do węzła głównego, aby odtwarzać muzykę i kontrolować własne wzmocnienie. Na potrzeby odtwarzania muzyki należałoby jednak ustawiać tę wartość za każdym razem, gdy tworzysz obiekt AudioBufferSourceNode. Zamiast tego zmienia się jeden węzeł tylko wtedy, gdy odtwarzacz zmienia głośność muzyki podczas uruchamiania. Teraz mamy dodatkową wartość dotyczącą źródeł bufora, aby zrobić coś innego. W przypadku muzyki często można zastosować przenikanie jednej ścieżki dźwiękowej do drugiej. Dobrze sprawdza się w tym celu Web Audio.

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

Gracze nie korzystali w specjalny sposób z przenikania. Gdybyśmy dowiedzieli się o funkcji ustawiania wartości WebAudio, już przy pierwszym przekazywaniu systemu nagłośnieniowego prawdopodobnie mielibyśmy taką możliwość.

Wstrzymuję dźwięki

Gdy gracz wstrzyma grę, może oczekiwać, że wciąż będą odtwarzane niektóre dźwięki. Dźwięk odgrywa ważną rolę w częstotliwości używania elementów interfejsu w menu gry. Fieldrunners ma kilka interfejsów, z których użytkownik może korzystać w czasie, gdy gra jest wstrzymana, zależy nam na tym, by użytkownicy grali w gry. Nie chcemy jednak odtwarzać długich ani zapętlanych dźwięków. Za pomocą Web Audio można łatwo zatrzymać te dźwięki, a przynajmniej tak nam się wydawało.

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

Wstrzymany węzeł efektów jest nadal połączony. Wszystkie dźwięki, które mogą ignorować stan wstrzymania gry, będą nadal odtwarzane w tym trybie. Po wznowieniu gry możemy połączyć ponownie te węzły, aby od razu wznowić odtwarzanie dźwięku.

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}

Po wysyłce platformy Fieldrunners odkryliśmy, że odłączenie samego węzła lub podgrafu nie spowoduje wstrzymania odtwarzania węzłów AudioBufferSourceNodes. Skorzystaliśmy z błędu w WebAudio, który obecnie zatrzymuje odtwarzanie węzłów niepołączonych z węzłem docelowym na wykresie. Aby przygotować się na tę przyszłą poprawkę, potrzebujemy kodu takiego jak poniżej:

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, że to pomyłka, struktura naszego kodu audio byłaby bardzo inna. W związku z tym wpłynęło to na wiele sekcji tego artykułu. Ma to bezpośredni efekt tutaj, ale także w naszych fragmentach kodu w filmach „Utrata koncentracji” i „Daj mi beat”. Aby sprawdzić, jak to działa, należy wprowadzić zmiany zarówno w wykresie 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 nie robi Web Audio.

Utrata skupienie

W przypadku tej funkcji włączasz nasz węzeł główny. Gdy użytkownik przeglądarki przejdzie na inną kartę, gra przestanie być widoczna. Dźwięk powinien zniknąć z oczu, to wszystko z głowy. Istnieją sztuczki, które można wykonać, aby określić określone stany widoczności strony gry, ale dzięki interfejsowi Widoczność jest to znacznie łatwiejsze.

Gra Fieldrunners jest odtwarzana tylko jako aktywna karta dzięki użyciu metody requestAnimationFrame do wywołania pętli aktualizacji. Jednak kontekst Web Audio nadal będzie odtwarzać zapętlone efekty i ścieżki w tle, gdy użytkownik otworzy inną kartę. Możemy jednak temu zapobiec za pomocą małego fragmentu kodu Widoczność 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 pomyśleliśmy, że odłączenie urządzenia głównego pozwoli wstrzymać cały dźwięk, zamiast go wyciszyć. Gdy odłączyliśmy ten węzeł, zatrzymaliśmy jego przetwarzanie i granie oraz jego elementy podrzędne. Po ponownym połączeniu wszystkie dźwięki i muzyka zaczęły być odtwarzane od momentu, w którym zostały przerwane. Rozgrywka odbywała się od tego samego miejsca. Jest to jednak nieoczekiwane zachowanie. Samo odłączenie nie wystarczy, aby zatrzymać odtwarzanie.

Dzięki interfejsowi Page Widoczność API łatwo zorientujesz się, że karta nie jest już aktywna. Jeśli masz już skuteczny kod do wstrzymywania dźwięków, wystarczy kilka wierszy, by zapisać dźwięk, gdy karta Gry jest ukryta.

Daj mi beat

Skonfigurowaliśmy teraz kilka rzeczy. Mamy tu wykres węzłów. Gdy gracz wstrzyma grę, możemy wstrzymać dźwięki, a także włączyć nowe dźwięki elementów, takich jak menu gry. Możemy wstrzymać odtwarzanie dźwięku i muzyki, gdy użytkownik przejdzie na nową kartę. Teraz musimy rzeczywiście zagrać dźwięk.

Zamiast odtwarzać kilka kopii dźwięku w przypadku wielu wystąpień gry, np. śmierci postaci, Fieldrunners odtwarza jeden dźwięk tylko raz na czas jego trwania. Jeśli dźwięk jest potrzebny po zakończeniu odtwarzania, odtwarzanie może zostać wznowione, ale nie w trakcie odtwarzania. Ta decyzja dotyczy projektu audio Fieldrunners, ponieważ zawiera on dźwięki, które są żądane do szybkiego odtwarzania, które w przeciwnym razie zacinałyby się po ponownym uruchomieniu lub utworzenie kakofonii, która nie przychodzi Ci do gustu, jeśli będzie mogła odtworzyć kilka wystąpień. Węzły AudioBufferSourceNodes powinny być używane jako jedno ujęcie. 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 będzie prowadzić do miejsca docelowego, wywołaj parametr NoteOn lub notesGrainOn i opcjonalnie wywołaj notatnik 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 );
  }
}

Zbyt częste przesyłanie strumieniowe

Aplikacja Fieldrunners została pierwotnie uruchomiona z muzyką w tle odtwarzaną z tagiem audio. Podczas premiery odkryliśmy, że żądania dotyczące plików muzycznych były wysyłane nieproporcjonalnie dużo razy niż w przypadku pozostałych treści gry. Po przeprowadzeniu badań odkryliśmy, że w tym czasie przeglądarka Chrome nie umieszczała w pamięci podręcznej strumieniowych fragmentów plików muzycznych. W rezultacie przeglądarka co kilka minut wysyłała żądanie odtwarzania utworu. Podczas nowszych testów ścieżki strumieniowane w Chrome były przechowywane w pamięci podręcznej, ale w innych przeglądarkach może to jeszcze nie działać. Strumieniowe przesyłanie dużych plików audio za pomocą tagu audio jest optymalne (np. odtwarzanie muzyki), ale w niektórych wersjach przeglądarek być może zechcesz ładować muzykę w taki sam sposób, w jaki ładujesz efekty dźwiękowe.

Ponieważ wszystkie efekty dźwiękowe były odtwarzane przez Web Audio, przenieśliśmy również odtwarzanie muzyki w tle do Web Audio. Oznaczało to, że wczytywaliśmy ścieżki w ten sam sposób, w jaki wczytywaliśmy wszystkie efekty za pomocą żądań XMLHttpRequests i typu odpowiedzi bufor bufora.

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

Gra Fieldrunners w Chrome i HTML5 to nie lada wyzwanie. Poza pracą w języku JavaScript trzeba pamiętać o wprowadzaniu tysięcy wierszy C++ w JavaScript, a to z powodu wielu ciekawych dylematów i decyzji związanych z HTML5. Aby powtarzać jedną z nich w sytuacji, gdy nie ma żadnej z nich, AudioBufferSourceNodes to jednorazowe obiekty. Utwórz je, dołącz bufor audio, połącz go z wykresem Web Audio i pobaw się przy użyciu notatnika NoteOn lub notesGrainOn. Chcesz jeszcze raz odtworzyć ten dźwięk? Następnie utwórz kolejny element AudioBufferSourceNode.