Caso de éxito: A Tale of an HTML5 Game with Web Audio

Fieldrunners

Captura de pantalla de Fieldrunners
Captura de pantalla de Fieldrunners

Fieldrunners es un galardonado juego de estilo de defensa de torres que se lanzó originalmente para iPhone en 2008. Desde entonces, se ha portabilizado a muchas otras plataformas. Una de las plataformas más recientes fue el navegador Chrome en octubre de 2011. Uno de los desafíos de portar Fieldrunners a una plataforma HTML5 fue cómo reproducir sonido.

Fieldrunners no usa efectos de sonido complicados, pero sí tiene algunas expectativas sobre cómo puede interactuar con ellos. El juego tiene 88 efectos de sonido, de los cuales se espera que se reproduzca una gran cantidad a la vez. La mayoría de estos sonidos son muy cortos y deben reproducirse de la manera más oportuna posible para evitar que se cree una desconexión con la presentación gráfica.

Apareceron algunos desafíos

Mientras portábamos Fieldrunners a HTML5, encontramos problemas con la reproducción de audio con la etiqueta Audio y, al principio, decidimos enfocarnos en la API de Web Audio. El uso de WebAudio nos ayudó a resolver problemas, como brindarnos la gran cantidad de efectos simultáneos que reproduce Fieldrunners. Sin embargo, mientras desarrollamos un sistema de audio para Fieldrunners HTML5, encontramos algunos problemas con matices que otros desarrolladores podrían tener en cuenta.

Naturaleza de AudioBufferSourceNodes

AudioBufferSourceNodes es tu método principal para reproducir sonidos con WebAudio. Es muy importante comprender que son objetos de un solo uso. Creas un AudioBufferSourceNode, le asignas un búfer, lo conectas al grafo y lo reproduces con noteOn o noteGrainOn. Después de eso, puedes llamar a noteOff para detener la reproducción, pero no podrás volver a reproducir la fuente llamando a noteOn o noteGrainOn. Debes crear otro AudioBufferSourceNode. Sin embargo, puedes reutilizar el mismo objeto AudioBuffer subyacente (esto es clave). De hecho, incluso puedes tener varios AudioBufferSourceNodes activos que apunten a la misma instancia de AudioBuffer. Puedes encontrar un fragmento de reproducción de Fieldrunners en Give Me a Beat.

Contenido que no se puede almacenar en caché

En el lanzamiento, el servidor HTML5 de Fieldrunners mostró una gran cantidad de solicitudes de archivos de música. Este resultado se produjo porque Chrome 15 descargaba el archivo en fragmentos y, luego, no lo almacenaba en caché. En respuesta, decidimos cargar archivos de música como el resto de nuestros archivos de audio. Hacerlo no es lo más adecuado, pero algunas versiones de otros navegadores siguen haciéndolo.

Silenciar cuando está fuera de foco

Antes, era difícil detectar cuándo la pestaña del juego estaba fuera de foco. Fieldrunners comenzó la portabilidad antes de Chrome 13, en el que la API de Page Visibility reemplazó la necesidad de nuestro código complicado para detectar el desenfoque de la pestaña. Todos los juegos deben usar la API de Visibility para escribir un pequeño fragmento que silencie o pause el sonido, o bien pause todo el juego. Como Fieldrunners usaba la API de requestAnimationFrame, la pausa del juego se controlaba de forma implícita, pero no la pausa del sonido.

Cómo pausar sonidos

Curiosamente, mientras recibíamos comentarios sobre este artículo, nos informaron que la técnica que usábamos para pausar los sonidos no era adecuada. Usábamos un error en la implementación actual de Web Audio para pausar la reproducción de sonidos. Como se solucionará en el futuro, no puedes simplemente desconectar un nodo o subgrafo para pausar el sonido y detener la reproducción.

Una arquitectura simple de nodos de audio web

Fieldrunners tiene un modelo de audio muy simple. Ese modelo puede admitir el siguiente conjunto de atributos:

  • Controla el volumen de los efectos de sonido.
  • Controla el volumen de la pista de música de fondo.
  • Silenciar todo el audio
  • Desactiva la reproducción de sonidos cuando el juego esté en pausa.
  • Vuelve a activar esos mismos sonidos cuando se reanude el juego.
  • Desactiva todo el audio cuando la pestaña del juego pierda el enfoque.
  • Reinicia la reproducción después de que se reproduzca un sonido según sea necesario.

Para lograr las funciones anteriores con Web Audio, se usaron 3 de los nodos posibles proporcionados: DestinationNode, GainNode y AudioBufferSourceNode. AudioBufferSourceNodes reproduce los sonidos. Los GainNodes conectan los AudioBufferSourceNodes. El objeto DestinationNode, creado por el contexto de Web Audio, llamado destino, reproduce sonidos para el reproductor. Web Audio tiene muchos más tipos de nodos, pero solo con estos podemos crear un gráfico muy simple para los sonidos de un juego.

Gráfico de grafo de nodos

Un gráfico de nodos de Web Audio conduce desde los nodos finales hasta el nodo de destino. Fieldrunners usaba 6 nodos de ganancia permanente, pero 3 son suficientes para permitir un control sencillo del volumen y conectar una mayor cantidad de nodos temporales que reproducirán búferes. Primero, un nodo de ganancia principal conecta cada nodo secundario al destino. Inmediatamente conectados al nodo de ganancia principal, hay dos nodos de ganancia, uno para un canal de música y otro para vincular todos los efectos de sonido.

Fieldrunners tenía 3 nodos de ganancia adicionales debido al uso incorrecto de un error como una función. Usamos esos nodos para cortar grupos de sonidos de reproducción del gráfico, lo que detiene su progreso. Hicimos esto para pausar los sonidos. Como no es correcto, ahora solo usaremos 3 nodos de ganancia total, como se describió anteriormente. Muchos de los siguientes fragmentos incluirán nuestros nodos incorrectos, lo que mostrará lo que hicimos y cómo lo corregiríamos a corto plazo. Sin embargo, a largo plazo, te recomendamos que no uses nuestros nodos después de nuestro 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 mayoría de los juegos permiten controlar los efectos de sonido y la música por separado. Esto se puede lograr fácilmente con nuestro gráfico anterior. Cada nodo de ganancia tiene un atributo "ganancia" que se puede establecer en cualquier valor decimal entre 0 y 1, que se puede usar para controlar el volumen. Como queremos controlar el volumen de los canales de música y efectos de sonido por separado, tenemos un nodo de ganancia para cada uno en el que podemos controlar su volumen.

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

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

Podemos usar esta misma capacidad para controlar el volumen de todo, desde los efectos de sonido hasta la música. Si configuras la ganancia del nodo principal, se afectará todo el sonido del juego. Si estableces el valor de ganancia en 0, silenciarás el sonido y la música. AudioBufferSourceNodes también tiene un parámetro de ganancia. Puedes hacer un seguimiento de una lista de todos los sonidos que se están reproduciendo y ajustar sus valores de ganancia de forma individual para el volumen general. Si estuvieras creando efectos de sonido con etiquetas de audio, esto es lo que tendrías que hacer. En cambio, el gráfico de nodos de Web Audio facilita mucho la modificación del volumen de sonido de innumerables sonidos. Controlar el volumen de esta manera también te brinda potencia adicional sin complicaciones. Podríamos conectar un AudioBufferSourceNode directamente al nodo principal para reproducir música y controlar su propia ganancia. Sin embargo, tendrías que establecer este valor cada vez que crees un AudioBufferSourceNode para reproducir música. En su lugar, cambias un nodo solo cuando un reproductor cambia el volumen de la música y en el inicio. Ahora tenemos un valor de ganancia en las fuentes de búfer para hacer otra cosa. En el caso de la música, un uso común puede ser crear una atenuación gradual de una pista de audio a otra a medida que una desaparece y otra aparece. Web Audio proporciona un método excelente para hacerlo fácilmente.

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

Fieldrunners no hizo un uso específico de la compaginación. Si hubiéramos conocido la funcionalidad de configuración de valores de WebAudio durante nuestro pase original del sistema de sonido, probablemente lo habríamos hecho.

Pausa de sonidos

Cuando un jugador pausa un juego, es posible que algunos sonidos sigan reproduciéndose. El sonido es una parte importante de los comentarios sobre la presión común de los elementos de la interfaz de usuario en los menús de juegos. Como Fieldrunners tiene varias interfaces con las que el usuario puede interactuar mientras el juego está en pausa, queremos que siga funcionando. Sin embargo, no queremos que se sigan reproduciendo sonidos largos o en bucle. Es bastante fácil detener esos sonidos con Web Audio, o al menos eso creíamos.

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

El nodo de efectos en pausa sigue conectado. Los sonidos que puedan ignorar el estado de pausa del juego se seguirán reproduciendo. Cuando se reanuda el juego, podemos volver a conectar esos nodos y hacer que todo el sonido vuelva a reproducirse de inmediato.

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

Después de enviar Fieldrunners, descubrimos que desconectar un nodo o subgrafo por sí solo no detendrá la reproducción de AudioBufferSourceNodes. En realidad, aprovechamos un error en WebAudio que actualmente detiene la reproducción de nodos que no están conectados al nodo de destino en el gráfico. Para asegurarnos de que todo esté listo para esa corrección futura, necesitamos un código como el siguiente:

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

Si hubiéramos sabido antes que estábamos abusando de un error, la estructura de nuestro código de audio sería muy diferente. Por lo tanto, esto afectó a varias secciones de este artículo. Tiene un efecto directo aquí, pero también en nuestros fragmentos de código en Losing Focus y Give Me a Beat. Para saber cómo funciona esto, es necesario realizar cambios en el gráfico de nodos de Fieldrunners (ya que creamos nodos para cortocircuitar la reproducción) y en el código adicional que grabará y proporcionará los estados de pausa que Web Audio no hace por sí solo.

Cómo perder el enfoque

Nuestro nodo principal entra en juego para esta función. Cuando un usuario de navegador cambia a otra pestaña, el juego ya no es visible. Si no se ve, no se recuerda, y lo mismo debería suceder con el sonido. Existen trucos que se pueden hacer para determinar estados de visibilidad específicos para la página de un juego, pero se volvió mucho más fácil con la API de Visibility.

Fieldrunners solo se reproducirá como la pestaña activa gracias al uso de requestAnimationFrame para llamar a su bucle de actualización. Sin embargo, el contexto de Web Audio seguirá reproduciendo efectos en bucle y pistas de fondo mientras el usuario esté en otra pestaña. Sin embargo, podemos detenerlo con un fragmento muy pequeño de la API de 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();
    }
  });
}

Antes de escribir este artículo, pensamos que desconectar el dispositivo principal sería suficiente para pausar todo el sonido en lugar de silenciarlo. Cuando desconectamos el nodo en ese momento, lo detuvimos a él y a sus elementos secundarios para que no se procesaran ni se reprodujeran. Cuando se volvía a conectar, todos los sonidos y la música comenzaban a reproducirse desde donde se habían dejado, al igual que el juego continuaba desde donde se había detenido. Sin embargo, este es un comportamiento inesperado. No basta con desconectarse para detener la reproducción.

La API de Page Visibility te permite saber con facilidad cuándo tu pestaña ya no está en foco. Si ya tienes un código eficaz para pausar los sonidos, solo necesitas escribir unas pocas líneas para pausar los sonidos cuando la pestaña de juegos está oculta.

Give Me a Beat

Ya configuramos algunos aspectos. Tenemos un gráfico de nodos. Podemos pausar los sonidos cuando el jugador pausa el juego y reproducir sonidos nuevos para elementos como los menús del juego. Podemos pausar todo el sonido y la música cuando el usuario cambia a una pestaña nueva. Ahora debemos reproducir un sonido.

En lugar de reproducir varias copias del sonido para varias instancias de una entidad del juego, como la muerte de un personaje, Fieldrunners reproduce un sonido solo una vez durante su duración. Si se necesita el sonido después de que terminó de reproducirse, se puede reiniciar, pero no mientras se está reproduciendo. Esta es una decisión para el diseño de audio de Fieldrunners, ya que tiene sonidos que se solicitan para que se reproduzcan rápidamente, que de otro modo tartamudearían si se les permitiera reiniciar o crear una cacofonía poco agradable si se les permitiera reproducir varias instancias. Se espera que AudioBufferSourceNodes se use como una sola vez. Crea un nodo, adjunta un búfer, establece el valor booleano del bucle si es necesario, conéctate a un nodo en el gráfico que te lleve al destino, llama a noteOn o noteGrainOn y, de manera opcional, llama a noteOff.

En el caso de Fieldrunners, se ve de la siguiente manera:

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

Demasiado contenido de transmisión

Fieldrunners se lanzó originalmente con música de fondo que se reproducía con una etiqueta de audio. En el lanzamiento, descubrimos que los archivos de música se solicitaban una cantidad desproporcionada de veces en comparación con el resto del contenido del juego. Después de investigar, descubrimos que, en ese momento, el navegador Chrome no almacenaba en caché los fragmentos transmitidos de los archivos de música. Esto provocó que el navegador solicitara la pista de reproducción cada pocos minutos a medida que terminaba. En pruebas más recientes, Chrome almacenó en caché pistas reproducidas, pero es posible que otros navegadores aún no lo hagan. Transmitir archivos de audio grandes con la etiqueta Audio para funciones como la reproducción de música es lo más óptimo, pero en algunas versiones de navegadores, es posible que desees cargar tu música de la misma manera que cargas los efectos de sonido.

Como todos los efectos de sonido se reproducían a través de Web Audio, también trasladamos la reproducción de la música de fondo a Web Audio. Esto significaba que cargaríamos los segmentos de la misma manera que cargamos todos los efectos con XMLHttpRequests y el tipo de respuesta de 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();
}

Resumen

Fue un placer llevar Fieldrunners a Chrome y HTML5. Además de su propia montaña de trabajo para llevar miles de líneas de C++ a JavaScript, surgen algunos dilemas y decisiones interesantes específicos de HTML5. Para reiterar uno si no hay ninguno de los otros, AudioBufferSourceNodes son objetos de uso único. Créalos, adjúntalos a un búfer de audio, conéctalos al gráfico de Web Audio y reprodúcelos con noteOn o noteGrainOn. ¿Necesitas volver a reproducir ese sonido? Luego, crea otro AudioBufferSourceNode.