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

Fieldrunner

Screenshot di Fieldrunner
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 è stato il browser Chrome nell'ottobre 2011. Una delle sfide del trasferimento di Fieldrunner su una piattaforma HTML5 era la modalità di riproduzione dell'audio.

I Fieldrunner non fanno un uso complicato degli effetti sonori, ma suppongono alcune aspettative sul modo in cui possono interagire con gli effetti sonori. Il gioco ha 88 effetti sonori, di cui è probabile che venga riprodotto un gran numero contemporaneamente. La maggior parte di questi suoni è molto breve e deve essere riprodotta nel modo più tempestivo possibile per evitare di creare interruzioni con la presentazione grafica.

Alcune sfide

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

Natura dei nodi AudioBufferSourceNodes

AudioBufferSourceNodes è il metodo principale per riprodurre i suoni con WebAudio. È molto importante capire che si tratta di oggetti da usare una sola volta. Crei un AudioBufferSourceNode, gli assegni un buffer, lo colleghi al grafico e lo riproduci con noteOn o noteGrainOn. Dopodiché puoi chiamare noteOff per interrompere la riproduzione, ma non potrai riprodurre di nuovo la sorgente chiamando noteOn o noteGrainOn: dovrai creare un altro AudioBufferSourceNode. Puoi, e questa è fondamentale, riutilizzare lo stesso oggetto AudioBuffer sottostante, ma puoi persino avere più AudioBufferSourceNodes attivi che puntano alla stessa istanza AudioBuffer. Puoi trovare uno snippet di riproduzione di Fieldrunners in Getting Me a Beat.

Contenuti che non memorizzano la cache

Al momento del rilascio, il server HTML5 di Fieldrunners mostrava un numero elevato di richieste di file musicali. Questo risultato derivava dal fatto che Chrome 15 procedeva al download del file in blocchi e poi non lo memorizzava nella cache. All'epoca, abbiamo deciso di caricare dei file musicali come gli altri file audio. Questo non è ottimale, ma alcune versioni di altri browser continuano a farlo.

Silenziamento quando non attivo

In precedenza era difficile rilevare quando la scheda del gioco è sfocata. I Fieldrunner hanno iniziato il trasferimento prima di Chrome 13, quando l'API Page Visibilità ha sostituito la necessità del nostro codice contorto di rilevare la sfocatura delle schede. Ogni gioco dovrebbe utilizzare l'API visibility per scrivere un piccolo snippet per disattivare o mettere in pausa l'audio, se non mettere in pausa l'intero gioco. Poiché Fieldrunners utilizzava l'API requestAnimationFrame, la pausa dei giochi è stata gestita in modo implicito, ma non sono state messe in pausa.

Messa in pausa dei suoni

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

Un'architettura semplice di nodi audio web

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

  • Regola il volume degli effetti sonori.
  • Regola il volume della traccia di musica di sottofondo.
  • Disattiva tutto l'audio.
  • Disattiva i suoni di riproduzione quando il gioco è in pausa.
  • Riattiva gli stessi suoni quando il gioco riprende.
  • Disattiva completamente l'audio quando la scheda del gioco perde lo stato attivo.
  • Riavvia la riproduzione dopo aver riprodotto un suono, secondo necessità.

Per ottenere le funzionalità sopra descritte con Web Audio, ha utilizzato tre dei possibili nodi forniti: destinationNode, GainNode, AudioBufferSourceNode. Gli AudioBufferSourceNodes riprodurranno i suoni. I GainNodes collegano tra loro gli AudioBufferSourceNodes. Il destinationNode, creato dal contesto dell'audio del web, chiamato destination, riproduce dei suoni per il player. Web Audio ha molti più tipi di nodi, ma solo con questi possiamo creare un grafico molto semplice per i suoni di un gioco.

Grafico dei nodi

Un grafico dei nodi audio web conduce dai nodi foglia al nodo di destinazione. I runner del campo hanno utilizzato sei nodi di guadagno permanente, ma tre nodi sono sufficienti per consentire un facile controllo del volume e connettere un numero maggiore di nodi temporanei in grado di riprodurre i buffer. Innanzitutto, un nodo di guadagno master che collega ogni nodo figlio alla destinazione. Al nodo master guadagno ci sono due nodi guadagno, uno per un canale musicale e un altro per collegare tutti gli effetti sonori.

I Fieldrunner avevano 3 nodi di guadagno in più a causa dell'utilizzo non corretto di un bug come funzionalità. Abbiamo utilizzato questi nodi per tagliare gruppi di suoni in riproduzione dal grafico, interrompendone l'avanzamento. Abbiamo eseguito questa operazione per mettere in pausa i suoni. Poiché non è corretto, ora utilizzeremo solo 3 nodi di guadagno totali, come descritto sopra. Molti degli snippet seguenti includeranno i nodi errati, per mostrare cosa abbiamo fatto e come risolveremo il problema nel breve termine. Ma alla lunga, non dovresti usare 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. Questa operazione può essere facilmente eseguita con il grafico riportato sopra. Ogni nodo di guadagno ha un attributo "guadagno" che può essere impostato su qualsiasi valore decimale compreso tra 0 e 1, che può essere utilizzato essenzialmente per controllare il volume. Dato che vogliamo controllare separatamente il volume dei canali degli effetti sonori e della musica, abbiamo un nodo di guadagno per ciascuno che consente di controllarne il volume.

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

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

Possiamo usare questa stessa possibilità per controllare il volume di tutto, degli effetti sonori e della musica. L'impostazione del guadagno del nodo master influirà su tutto l'audio del gioco. Se imposti il valore di guadagno su 0, disattivi l'audio e la musica. Anche i AudioBufferSourceNodes hanno un parametro di guadagno. Potevi tenere traccia di un elenco di tutti i suoni in riproduzione e regolare i rispettivi valori di guadagno individualmente per il volume complessivo. Se stessi creando effetti sonori con i tag audio, questo è ciò che dovresti fare. Al contrario, il grafico dei nodi di Web Audio rende molto più facile modificare il volume di innumerevoli suoni. Questo modo di regolare il volume aumenta la potenza del dispositivo senza complicazioni. Potremmo semplicemente collegare un AudioBufferSourceNode direttamente al nodo master per riprodurre musica e controllare il proprio guadagno. Tuttavia, dovrai impostare questo valore ogni volta che crei un AudioBufferSourceNode per poter riprodurre musica. Puoi invece cambiare un nodo solo quando un player modifica il volume della musica e all'avvio. Ora abbiamo un valore aggiunto sulle sorgenti 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 ne esce una e ne entra l'altra. L'audio web rappresenta un metodo utile per eseguire facilmente questa operazione.

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

I Fieldrunner non facevano un uso specifico del crossfading. Se avessimo saputo della funzionalità di impostazione del valore di WebAudio durante il nostro passaggio originale del sistema audio che probabilmente avremmo avuto.

Mettere in pausa l'audio

Quando un giocatore mette in pausa un gioco, può aspettarsi che alcuni suoni continuino a essere riprodotti. L'audio è una parte importante del feedback per la pressione comune degli elementi dell'interfaccia utente nei menu dei giochi. Poiché Fieldrunners ha una serie di interfacce con cui l'utente può interagire mentre il gioco è in pausa, vogliamo che gli utenti giochino comunque. Tuttavia, non vogliamo che i suoni lunghi o in loop continuino a riprodurre. Interrompere quei suoni con Web Audio è abbastanza facile, o almeno pensavamo di sì.

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

Il nodo degli effetti in pausa è ancora connesso. I suoni che possono ignorare lo stato di pausa del gioco continueranno a essere riprodotti. Quando il gioco viene riattivato, possiamo riconnettere questi nodi e riprodurre di nuovo tutto l'audio all'istante.

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

Dopo aver spedito i Fieldrunner, abbiamo scoperto che la sola disconnessione di un nodo o di un sottografico non mette in pausa la riproduzione dei AudioBufferSourceNodes. In realtà abbiamo sfruttato un bug in WebAudio che attualmente interrompe la riproduzione dei nodi non connessi al nodo Destinazione nel grafico. Per consentirci di essere pronti per la correzione futura, abbiamo bisogno di un codice simile al 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 lo sapessimo in precedenza, che stavamo abusando di un bug, la struttura del nostro codice audio sarebbe molto diversa. Di conseguenza, il problema ha interessato diverse sezioni di questo articolo. Ha un effetto diretto qui, ma anche negli snippet di codice di Losing Focus e Date Me a Beat. Sapere come funziona effettivamente questo processo richiede modifiche sia al grafico dei nodi Fieldrunners (dato che abbiamo creato dei nodi per interrompere la riproduzione) e al codice aggiuntivo che registrerà e fornirà gli stati di pausa che l'audio web non fa da solo.

Perdita di concentrazione

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. Fuori vista, fuori mente, e così il suono dovrebbe sparire. Esistono dei trucchi per determinare stati di visibilità specifici per la pagina di un gioco, ma è diventato molto più semplice con l'API Visibilità.

I Fieldrunner verranno riprodotti solo come scheda attiva grazie all'utilizzo di requestAnimationFrame per la chiamata del loop di aggiornamento. Tuttavia, il contesto dell'audio web continuerà a riprodurre gli effetti in loop e le tracce di sottofondo mentre un utente si trova in un'altra scheda. Ma possiamo fermarlo con un piccolo snippet sensibile all'API Visibilità.

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, abbiamo pensato che scollegare l'audio principale sarebbe stato sufficiente per mettere in pausa tutto l'audio anziché disattivarlo. Disconnettendo il nodo in quel momento, abbiamo interrotto l'elaborazione e la riproduzione del nodo e dei relativi figli. Una volta riconnesso, tutti i suoni e la musica iniziavano a essere riprodotti dal punto in cui l'avevano lasciata, così come il gioco continuava dal punto in cui era stato interrotto. Ma si tratta di un comportamento inaspettato. Non basta disconnettersi per interrompere la riproduzione.

L'API Page Visibilità ti consente di sapere molto facilmente quando la tua scheda non è più attiva. Se disponi già di un codice efficace per mettere in pausa i suoni, sono necessarie solo poche righe per inserire la pausa audio quando la scheda dei giochi è nascosta.

Scatenati in pista

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

Invece di riprodurre più copie del suono per più istanze di un'entità di gioco come un personaggio che muore, Fieldrunners riproduce un suono solo una volta per tutta la sua durata. Se il suono è necessario al termine della riproduzione, può riavviarsi, ma non durante la riproduzione. Questa è una decisione per il design audio dei Fieldrunner, in quanto presenta suoni che devono essere riprodotti rapidamente che altrimenti interromperebbero se il dispositivo venisse riavviato o creerebbe una cacofonia non piacevole se si consentisse di riprodurre più istanze. AudioBufferSourceNodes dovrebbe essere usato come one-shot. Crea un nodo, collega un buffer, imposta un valore booleano del loop se necessario, collegati a un nodo sul grafico che porterà alla destinazione, chiama noteOn o noteGrainOn e, facoltativamente, chiama noteOff.

Per i Fieldrunner, 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 lanciato inizialmente con musica di sottofondo riprodotta con un tag audio. Al momento del rilascio, abbiamo scoperto che i file musicali venivano richiesti un numero di volte sproporzionato rispetto a quello richiesto dal resto dei contenuti del gioco. Dopo alcune ricerche abbiamo scoperto che all'epoca il browser Chrome non memorizzava nella cache i blocchi trasmessi in streaming dei file musicali. Di conseguenza, al termine del browser il browser richiedeva la traccia di riproduzione a intervalli di pochi minuti. Nei test più recenti, Chrome ha memorizzato i brani in streaming nella cache, ma è possibile che altri browser non lo facciano 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 di 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ò significa 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

Fieldrunners è stato un vero spasso da implementare su Chrome e HTML5. Al di fuori della sua montagna di lavoro che introduce migliaia di righe di C++ in JavaScript, suscitano alcuni dilemmi interessanti e decisioni specifiche sull'HTML5. Per ripeterne uno se nessuno degli altri, AudioBufferSourceNodes è un oggetto da usare una sola volta. Creale, collega un buffer audio, collegalo al grafico dell'audio web e riproducilo con noteOn o noteGrainOn. Devi riprodurre di nuovo il suono? Quindi crea un altro AudioBufferSourceNode.