Case study - A tale of an HTML5 game with Web Audio

Fieldrunners

Screenshot di Fieldrunners
Screenshot di Fieldrunners

Fieldrunners è un pluripremiato gioco in stile tower defense originariamente rilasciato per iPhone nel 2008. Da allora è stato trasferito su molte altre piattaforme. Una delle piattaforme più recenti è stata il browser Chrome nell'ottobre 2011. Una delle sfide del porting di Fieldrunners su una piattaforma HTML5 è stata la riproduzione dell'audio.

Fieldrunners non fa un uso complicato degli effetti sonori, ma ha alcune aspettative su come può interagire con questi effetti. Il gioco ha 88 effetti sonori, molti dei quali possono essere riprodotti 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 visualizzate alcune sfide

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

Natura degli AudioBufferSourceNode

Gli AudioBufferSourceNode sono il metodo principale per riprodurre suoni con WebAudio. È molto importante capire che si tratta di un oggetto monouso. Crei un AudioBufferSourceNode, gli assegni un buffer, lo connetti al grafico e lo riproduci con noteOn o noteGrainOn. Successivamente, 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 (questo è un aspetto fondamentale; infatti, puoi anche avere più AudioBufferSourceNode attivi che puntano 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 è dovuto al fatto che Chrome 15 scarica il file in blocchi e non lo memorizza nella cache. In risposta, all'epoca abbiamo deciso di caricare i file musicali come il resto dei nostri file audio. Questa operazione non è ottimale, ma alcune versioni di altri browser la eseguono ancora.

Silenzia quando non è attivo

In precedenza, era difficile rilevare quando la scheda del gioco non era attiva. Il porting di Fieldrunners è iniziato prima di Chrome 13, dove l'API Page Visibility ha sostituito la necessità del nostro codice complesso per rilevare la 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 per mettere in pausa l'intero gioco. Poiché Fieldrunners utilizzava l'API requestAnimationFrame, la pausa del gioco veniva gestita in modo implicito, ma non la pausa dell'audio.

Messa in pausa dei suoni

Curiosamente, mentre ricevevamo feedback su questo articolo, siamo stati informati che la tecnica che stavamo utilizzando per mettere in pausa i suoni non era appropriata: stavamo sfruttando un bug nell'implementazione attuale 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.

Una semplice architettura di nodi Web Audio

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

  • Controlla il volume degli effetti sonori.
  • Controlla il volume della traccia musicale di sottofondo.
  • Disattiva tutto l'audio.
  • Disattiva la riproduzione dei suoni quando il gioco è in pausa.
  • Riattivare gli stessi suoni quando il gioco viene ripreso.
  • Disattiva tutto l'audio quando la scheda del gioco perde lo stato attivo.
  • Se necessario, riavvia la riproduzione dopo la riproduzione di un suono.

Per ottenere le funzionalità di cui sopra con l'API Web Audio, ha utilizzato tre dei possibili nodi forniti: DestinationNode, GainNode, AudioBufferSourceNode. Gli AudioBufferSourceNode riproducono i suoni. I GainNode collegano gli AudioBufferSourceNode tra loro. Il nodo DestinationNode, creato dal contesto Web Audio, chiamato destination, riproduce i suoni per il player. Web Audio ha molti altri tipi di nodi, ma con questi soli possiamo creare un grafico molto semplice per i suoni di un gioco.

Grafico a nodi

Un grafico dei nodi Web Audio va dai nodi foglia al nodo di destinazione. Fieldrunners utilizzava sei nodi di guadagno permanenti, ma tre 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 direttamente due nodi di guadagno, uno per un canale musicale e un altro per collegare tutti gli effetti sonori.

Fieldrunners aveva 3 nodi di guadagno aggiuntivi a causa dell'utilizzo errato di un bug come funzionalità. Abbiamo utilizzato questi nodi per tagliare i gruppi di suoni in riproduzione dal grafico, interrompendo il loro avanzamento. Abbiamo fatto questo per mettere in pausa i suoni. Poiché non è corretto, ora utilizzeremo solo 3 nodi di guadagno totale, come descritto sopra. Molti degli snippet seguenti includeranno i nostri nodi errati, mostrando cosa abbiamo fatto e come lo correggeremmo nel breve termine. Tuttavia, a lungo termine, è preferibile 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. Puoi farlo facilmente con il nostro grafico qui 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 il volume dei canali della musica e degli effetti sonori separatamente, abbiamo un nodo di guadagno per ciascuno di questi canali, in cui possiamo controllare il volume.

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

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

Possiamo usare questa stessa funzionalità per controllare il volume di tutto, degli effetti sonori e della musica. L'impostazione del gain del nodo master influirà su tutto l'audio del gioco. Se imposti il valore di guadagno su 0, l'audio e la musica verranno disattivati. Anche gli AudioBufferSourceNode hanno un parametro di guadagno. Puoi monitorare un elenco di tutti i suoni in riproduzione e regolare i relativi valori di guadagno singolarmente per il volume complessivo. Se dovessi creare effetti sonori con i tag Audio, questo è ciò che dovresti fare. Invece, il grafico dei nodi di Web Audio semplifica notevolmente la modifica del volume di innumerevoli suoni. Controllare il volume in questo modo ti offre anche una potenza extra senza complicazioni. Potremmo semplicemente collegare un AudioBufferSourceNode direttamente al nodo principale per riprodurre la musica e controllare il suo gain. Tuttavia, dovresti impostare questo valore ogni volta che crei un AudioBufferSourceNode per riprodurre musica. Invece, modifichi un nodo solo quando un giocatore cambia il volume della musica e all'avvio. Ora abbiamo un valore di guadagno sulle sorgenti del buffer per fare qualcos'altro. Per la musica, un uso comune può essere la creazione di una dissolvenza incrociata da una traccia audio all'altra, quando una finisce e l'altra inizia. Web Audio fornisce un metodo efficace per eseguire questa operazione facilmente.

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

Fieldrunners non ha fatto un uso specifico della dissolvenza incrociata. Se avessimo saputo della funzionalità di impostazione del valore di WebAudio durante la nostra prima fase di sviluppo del sistema audio, probabilmente l'avremmo utilizzata.

Messa in pausa dei suoni

Quando un giocatore mette in pausa un gioco, può aspettarsi che alcuni suoni vengano comunque riprodotti. Il suono è una parte importante del feedback per la pressione comune 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 comunque che vengano riprodotte. Tuttavia, non vogliamo che i suoni lunghi o in loop continuino a essere riprodotti. È piuttosto facile interrompere questi suoni con l'API 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 il gioco viene ripreso, possiamo ricollegare i nodi e riprodurre di nuovo tutti i suoni all'istante.

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

Dopo aver lanciato Fieldrunners, abbiamo scoperto che scollegare un nodo o un sottografo da solo non mette in pausa la riproduzione degli AudioBufferSourceNode. Abbiamo sfruttato un bug di WebAudio che attualmente interrompe la riproduzione dei nodi non collegati al nodo di destinazione nel grafico. Quindi, per assicurarci di essere pronti per 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 sfruttando un bug, la struttura del nostro codice audio sarebbe stata molto diversa. Di conseguenza, ciò ha interessato 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 funziona, è necessario apportare modifiche sia al grafico dei nodi di Fieldrunners (poiché abbiamo creato nodi per interrompere la riproduzione) sia al codice aggiuntivo che registrerà e fornirà gli stati di pausa che l'API Web Audio non esegue autonomamente.

Perdita di attenzione

Il nostro nodo master entra in gioco per questa funzionalità. Quando un utente del browser passa a un'altra scheda, il gioco non è più visibile. Lontano dagli occhi, lontano dal cuore e anche il suono dovrebbe sparire. Esistono dei trucchi che possono essere utilizzati per determinare stati di visibilità specifici per la pagina di un gioco, ma con l'API Visibility è diventato molto più semplice.

Fieldrunners verrà riprodotto solo come scheda attiva grazie all'utilizzo di requestAnimationFrame per chiamare il relativo ciclo di aggiornamento. Tuttavia, il contesto Web Audio continuerà a riprodurre effetti in loop e tracce di sottofondo mentre un utente si trova in un'altra scheda. Tuttavia, possiamo impedirlo con uno snippet molto piccolo che supporta l'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 scollegare il dispositivo principale sarebbe stato sufficiente 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 relativi elementi secondari. Quando veniva ricollegato, tutti i suoni e la musica riprendevano dal punto in cui erano stati interrotti, così come il gameplay. Ma questo è un comportamento imprevisto. Non basta scollegare il dispositivo per interrompere la riproduzione.

L'API Page Visibility consente di sapere 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 pausa dei suoni quando la scheda dei giochi è nascosta.

Dammi un ritmo

Ora abbiamo configurato alcuni elementi. Abbiamo un grafico 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 del suono per più istanze di un'entità di gioco, ad esempio la morte di un personaggio, Fieldrunners riproduce un suono una sola volta per la sua durata. Se il suono è necessario dopo che è terminata la riproduzione, può essere riavviato, ma non mentre è già in riproduzione. Questa è una decisione per il design audio di Fieldrunners, in quanto contiene suoni che devono essere riprodotti rapidamente e che altrimenti balbetterebbero se venissero riavviati o creerebbero una cacofonia sgradevole se venissero riprodotte più istanze. Gli AudioBufferSourceNode devono essere utilizzati come one-shot. Crea un nodo, collega un buffer, imposta il valore booleano del loop se necessario, connettiti a un nodo sul 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 );
  }
}

Troppo streaming

Fieldrunners è stato originariamente lanciato con 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. Dopo alcune ricerche, abbiamo scoperto che all'epoca il browser Chrome non memorizzava nella cache i blocchi dei file musicali riprodotti in streaming. Di conseguenza, il browser richiedeva la traccia in riproduzione ogni pochi minuti, non appena terminava. In test più recenti, Chrome ha memorizzato nella cache le tracce riprodotte 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 potresti voler 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 abbiamo caricato tutti gli effetti con XMLHttpRequests 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

Portare Fieldrunners su Chrome e HTML5 è stato un vero piacere. Oltre alla mole di lavoro necessaria per convertire migliaia di righe di C++ in JavaScript, sono emersi alcuni dilemmi e decisioni interessanti specifici per HTML5. Per ribadire un concetto, se non tutti gli altri, gli AudioBufferSourceNode sono oggetti monouso. Creali, collega un buffer audio, connettilo al grafico Web Audio e riproduci con noteOn o noteGrainOn. Vuoi riprodurre di nuovo il suono? Quindi crea un altro AudioBufferSourceNode.