Case study - A Tale of an HTML5 Game with Web Audio

Fieldrunners

Screenshot di Fieldrunners
Screenshot di Fieldrunners

Fieldrunners è un pluripremiato gioco di strategia a torre originariamente rilasciato per iPhone nel 2008. Da allora è stato portato su molte altre piattaforme. Una delle piattaforme più recenti è stata il browser Chrome a ottobre 2011. Una delle difficoltà del porting di Fieldrunners a una piattaforma HTML5 è stata la riproduzione dell'audio.

Fieldrunners non fa un uso complicato degli effetti sonori, ma prevede alcune aspettative su come può interagire con questi effetti. Il gioco ha 88 effetti sonori, di cui è possibile che un numero elevato venga riprodotto contemporaneamente. La maggior parte di questi suoni è molto breve e deve essere riprodotta nel modo più tempestivo possibile per evitare di creare una disconnessione con la presentazione grafica.

Sono state rilevate alcune difficoltà

Durante il porting di Fieldrunners ad HTML5 abbiamo riscontrato problemi con la riproduzione audio con il tag Audio e all'inizio abbiamo deciso di concentrarci sull'API Web Audio. L'utilizzo di WebAudio ci ha aiutato a risolvere problemi come la riproduzione di un numero elevato di effetti simultanei richiesti da Fieldrunners. Tuttavia, durante lo sviluppo di un sistema audio per Fieldrunners HTML5 abbiamo riscontrato alcuni problemi sfumati che altri sviluppatori potrebbero voler conoscere.

Natura di AudioBufferSourceNodes

AudioBufferSourceNodes è il metodo principale per riprodurre i suoni con WebAudio. È molto importante capire che si tratta di un oggetto monouso. Crea un AudioBufferSourceNode, assegnagli un buffer, collegalo al grafico e riproducilo con noteOn o noteGrainOn. Dopodiché puoi chiamare noteOff per interrompere la riproduzione, ma non potrai riprodurre di nuovo la sorgente chiamando noteOn o noteGrainOn. Devi creare un altro AudioBufferSourceNode. Tuttavia, puoi riutilizzare lo stesso oggetto AudioBuffer sottostante (infatti, puoi anche avere più AudioBufferSourceNode attivi che rimandano alla stessa istanza AudioBuffer). Puoi trovare uno snippet di riproduzione di Fieldrunners in Give Me a Beat.

Contenuti non memorizzati nella cache

Al momento del lancio, il server HTML5 di Fieldrunners ha mostrato un numero enorme di richieste di file musicali. Questo risultato è stato ottenuto perché Chrome 15 ha scaricato il file a blocchi e poi non lo ha memorizzato nella cache. In risposta, abbiamo deciso di caricare i file musicali come il resto dei file audio. Questa operazione non è ottimale, ma alcune versioni di altri browser la eseguono ancora.

Silenziamento quando non a fuoco

In precedenza, era difficile rilevare quando la scheda del gioco non era attiva. Il porting di Fieldrunners è iniziato prima di Chrome 13, quando l'API Page Visibility ha sostituito il nostro complicato codice per rilevare l'effetto sfocatura delle schede. Ogni gioco deve utilizzare l'API Visibility per scrivere un piccolo snippet per disattivare l'audio o metterlo in pausa, se non mettere in pausa l'intero gioco. Poiché Fieldrunners utilizzava l'API requestAnimationFrame, la messa in pausa del gioco veniva gestita in modo implicito, ma non la messa in pausa dell'audio.

Mettere in pausa i suoni

Stranamente, mentre ricevevamo feedback su questo articolo, ci è stato comunicato che la tecnica che utilizzavamo per mettere in pausa i suoni non era appropriata: utilizzavamo un bug nell'attuale implementazione di Web Audio per mettere in pausa la riproduzione dei suoni. Poiché questo problema verrà risolto in futuro, non puoi semplicemente mettere in pausa l'audio scollegando un nodo o un sottografo per interrompere la riproduzione.

Un'architettura semplice del nodo Web Audio

Fieldrunners ha un modello audio molto semplice. Questo modello può supportare il seguente insieme di funzionalità:

  • Regolare il volume degli effetti sonori.
  • Regolare il volume della traccia audio di sottofondo.
  • Disattivare l'audio.
  • Disattivare la riproduzione dei suoni quando la partita è in pausa.
  • Riattiva gli stessi suoni quando riprendi la partita.
  • Disattivare tutto l'audio quando la scheda del gioco perde lo stato attivo.
  • Riavviare la riproduzione dopo la riproduzione di un suono, se necessario.

Per ottenere le funzionalità sopra indicate con Web Audio, sono stati utilizzati 3 dei possibili nodi forniti: DestinationNode, GainNode, AudioBufferSourceNode. Gli AudioBufferSourceNodes riproducono i suoni. I GainNode collegano gli AudioBufferSourceNode tra loro. Il DestinationNode, creato dal contesto Web Audio, chiamato destinazione, riproduce i suoni per il player. Web Audio ha molti altri tipi di nodi, ma con solo questi possiamo creare un grafico molto semplice per i suoni in un gioco.

Grafico grafico dei nodi

Un grafico dei nodi Web Audio conduce dai nodi foglia al nodo di destinazione. Fieldrunners utilizzava 6 nodi di guadagno permanenti, ma 3 sono sufficienti per consentire un facile controllo del volume e collegare un numero maggiore di nodi temporanei che riprodurranno i buffer. Innanzitutto, un nodo di guadagno principale che collega ogni nodo secondario alla destinazione. Al nodo di guadagno principale sono collegati immediatamente due nodi di guadagno, uno per un canale musicale e un altro per collegare tutti gli effetti sonori.

Fieldrunners aveva 3 nodi di guadagno extra a causa dell'utilizzo errato di un bug come funzionalità. Abbiamo utilizzato questi nodi per tagliare dal grafico gruppi di suoni in riproduzione, interrompendone l'avanzamento. Lo abbiamo fatto per mettere in pausa i suoni. Poiché non è corretto, ora utilizzeremo solo 3 nodi di utile totale come descritto sopra. Molti degli snippet che seguono includeranno i nostri nodi errati, mostrando cosa abbiamo fatto e come intendiamo risolvere il problema nel breve termine. Tuttavia, a lungo termine ti consigliamo di non utilizzare i nostri nodi dopo il nodo 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 );
}

La maggior parte dei giochi consente di controllare separatamente gli effetti sonori e la musica. Questo può essere facilmente ottenuto con il grafico riportato sopra. Ogni nodo di guadagno ha un attributo "gain" che può essere impostato su qualsiasi valore decimale compreso tra 0 e 1, che può essere utilizzato per controllare essenzialmente il volume. Poiché vogliamo controllare separatamente il volume dei canali di musica ed effetti sonori, abbiamo un nodo di guadagno per ciascuno in cui possiamo controllarne il volume.

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

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

Possiamo usare la stessa funzionalità per controllare il volume di tutto, degli effetti sonori e della musica. L'impostazione del guadagno del nodo principale influisce su tutti i suoni del gioco. Se imposti il valore del guadagno su 0, il suono e la musica verranno disattivati. Anche AudioBufferSourceNodes ha un parametro di guadagno. Potresti monitorare un elenco di tutti i suoni riprodotti e regolare i relativi valori di guadagno singolarmente per il volume complessivo. Se creavi effetti sonori con i tag audio, è quello che dovevi fare. Invece, il grafo dei nodi di Web Audio semplifica notevolmente la modifica del volume di innumerevoli suoni. Controllare il volume in questo modo ti offre anche una maggiore potenza senza complicazioni. Potremmo semplicemente collegare un AudioBufferSourceNode direttamente al nodo principale per riprodurre la musica e controllare il relativo guadagno. Tuttavia, dovrai impostare questo valore ogni volta che crei un AudioBufferSourceNode per riprodurre musica. Modifica un nodo solo quando un giocatore regola il volume della musica e al momento del lancio. Ora abbiamo un valore di guadagno per le sorgenti di buffer per fare qualcos'altro. Per la musica, un uso comune può essere la creazione di un crossfade da una traccia audio all'altra quando una scompare e ne viene inserita un'altra. Web Audio fornisce un ottimo metodo per eseguire facilmente questa operazione.

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

Fieldrunners non ha fatto un uso specifico del crossfading. Se avessimo conosciuto la funzionalità di impostazione del valore di WebAudio durante il passaggio originale del sistema audio, probabilmente lo avremmo fatto.

Mettere in pausa i suoni

Quando un giocatore mette in pausa una partita, può aspettarsi che alcuni suoni continuino a essere riprodotti. L'audio è un elemento fondamentale del feedback per le pressioni comuni degli elementi dell'interfaccia utente nei menu di gioco. Poiché Fieldrunners ha una serie di interfacce con cui l'utente può interagire mentre il gioco è in pausa, vogliamo che continui a giocare. Tuttavia, non vogliamo che i suoni lunghi o in loop continuino a essere riprodotti. È abbastanza facile interrompere questi suoni con Web Audio, o almeno così pensavamo.

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

Il nodo degli effetti in pausa è ancora connesso. Tutti i suoni che possono ignorare lo stato di pausa del gioco continueranno a essere riprodotti. Quando la partita viene riattivata, possiamo ricollegare questi nodi e far riprodurre di nuovo tutto l'audio all'istante.

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

Dopo aver rilasciato Fieldrunners, abbiamo scoperto che la disconnessione di un nodo o di un sottografo non mette in pausa la riproduzione degli AudioBufferSourceNode. In realtà abbiamo sfruttato un bug in WebAudio che attualmente interrompe la riproduzione dei nodi non collegati al nodo Destinazione nel grafico. Per essere pronti a questa correzione futura, abbiamo bisogno di un codice come il seguente:

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;
    }
  }
};

Se avessimo saputo prima che stavamo abusando di un bug, la struttura del nostro codice audio sarebbe molto diversa. Di conseguenza, sono state interessate diverse sezioni di questo articolo. Ha un effetto diretto qui, ma anche nei nostri snippet di codice in Losing Focus e Give Me a Beat. Per capire come funzionano realmente queste modifiche, è necessario modificare sia il grafo dei nodi di Fieldrunners (poiché abbiamo creato nodi per interrompere la riproduzione) sia il codice aggiuntivo che registrerà e fornirà gli stati in pausa che Web Audio non esegue autonomamente.

Perdere il focus

Il nostro nodo principale entra in gioco per questa funzionalità. Quando un utente del browser passa a un'altra scheda, il gioco non è più visibile. Se non lo vedi, non lo senti. Esistono alcuni trucchi per determinare stati di visibilità specifici per la pagina di un gioco, ma con l'API Visibility è diventato molto più facile.

Fieldrunners verrà riprodotto solo come scheda attiva grazie all'utilizzo di requestAnimationFrame per chiamare il relativo loop di aggiornamento. Tuttavia, il contesto Web Audio continuerà a riprodurre gli effetti in loop e le tracce di sottofondo mentre l'utente è in un'altra scheda. Tuttavia, possiamo impedirlo con uno snippet molto piccolo consapevole dell'API Visibility.

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();
    }
  });
}

Prima di scrivere questo articolo, pensavamo che fosse sufficiente scollegare il master per mettere in pausa tutto l'audio anziché disattivarlo. Scollegando il nodo in quel momento, abbiamo interrotto l'elaborazione e la riproduzione del nodo e dei suoi nodi secondari. Quando si ricollega, tutti i suoni e la musica iniziano a essere riprodotti da dove erano stati interrotti, così come la partita riprende da dove era stata interrotta. Ma questo è un comportamento imprevisto. Non è sufficiente disconnettersi per interrompere la riproduzione.

L'API Page Visibility ti consente di sapere molto facilmente quando la scheda non è più attiva. Se hai già un codice efficace per mettere in pausa i suoni, bastano poche righe per scrivere la messa in pausa dell'audio quando la scheda Giochi è nascosta.

Give Me a Beat

Abbiamo configurato alcune cose. Abbiamo un grafo di nodi. Possiamo mettere in pausa i suoni quando il giocatore mette in pausa il gioco e riprodurre nuovi suoni per elementi come i menu di gioco. Possiamo mettere in pausa tutti i suoni e la musica quando l'utente passa a una nuova scheda. Ora dobbiamo riprodurre un suono.

Invece di riprodurre più copie dell'audio per più istanze di un'entità di gioco, come la morte di un personaggio, Fieldrunners riproduce un audio una sola volta per tutta la sua durata. Se l'audio è necessario dopo la fine della riproduzione, può essere riavviato, ma non durante la riproduzione. Si tratta di una decisione presa per il design audio di Fieldrunners, in quanto i suoni devono essere riprodotti rapidamente e, se fosse consentito il riavvio, si verificherebbero delle interruzioni o, se fosse consentita la riproduzione di più istanze, si creerebbe una cacofonia sgradevole. È previsto che gli AudioBufferSourceNode vengano utilizzati come one-shot. Crea un nodo, collega un buffer, imposta il valore booleano loop se necessario, connettiti a un nodo del grafico che porterà alla destinazione, chiama noteOn o noteGrainOn e, facoltativamente, chiama noteOff.

Per Fieldrunners, l'aspetto è simile al seguente:

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 );
  }
}

Troppi streaming

Fieldrunners è stato lanciato inizialmente con la musica di sottofondo riprodotta con un tag Audio. Al momento del lancio, abbiamo scoperto che i file musicali venivano richiesti un numero di volte sproporzionato rispetto al resto dei contenuti del gioco. Da alcune ricerche è emerso che al momento il browser Chrome non memorizzava nella cache i chunk in streaming dei file musicali. Di conseguenza, il browser richiedeva la traccia in riproduzione ogni pochi minuti al termine. In test più recenti, Chrome ha memorizzato nella cache i canali in streaming, ma altri browser potrebbero non farlo ancora. Lo streaming di file audio di grandi dimensioni con il tag Audio per funzionalità come la riproduzione di musica è ottimale, ma per alcune versioni del browser potrebbe essere preferibile caricare la musica nello stesso modo in cui carichi gli effetti sonori.

Poiché tutti gli effetti sonori venivano riprodotti tramite Web Audio, abbiamo spostato anche la riproduzione della musica di sottofondo su Web Audio. Ciò significava che avremmo caricato le tracce nello stesso modo in cui caricavamo tutti gli effetti con XMLHttpRequest e il tipo di risposta 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();
}

Riepilogo

È stato fantastico portare Fieldrunners su Chrome e HTML5. Oltre alla mole di lavoro necessaria per portare migliaia di righe di C++ in JavaScript, si presentano alcuni dilemmi e decisioni interessanti specifici di HTML5. Per ripetere uno se nessuno degli altri, gli AudioBufferSourceNodes sono oggetti monouso. Creali, collega un Audio Buffer, connettilo al grafico Web Audio e riproduci con noteOn o noteGrainOn. Vuoi riascoltare l\'audio? Poi crea un altro AudioBufferSourceNode.