Fieldrunners
Fieldrunners es un juego galardonado de defensa de torres que se lanzó originalmente para iPhone en 2008. Desde entonces, se ha portado 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 hace un uso complicado de los efectos de sonido, pero sí tiene algunas expectativas sobre cómo puede interactuar con ellos. El juego tiene 88 efectos de sonido, de los cuales se puede esperar que se reproduzca una gran cantidad al mismo tiempo. La mayoría de estos sonidos son muy cortos y deben reproducirse de la manera más oportuna posible para evitar que se produzca una desconexión con la presentación gráfica.
Surgieron algunos desafíos
Mientras portábamos Fieldrunners a HTML5, tuvimos problemas con la reproducción de audio con la etiqueta Audio y, desde el principio, decidimos enfocarnos en la API de Web Audio. El uso de WebAudio nos ayudó a resolver problemas, como darnos la gran cantidad de efectos simultáneos que requiere Fieldrunners. Sin embargo, mientras desarrollábamos un sistema de audio para Fieldrunners HTML5, nos encontramos con algunos problemas sutiles que otros desarrolladores tal vez quieran conocer.
Naturaleza de los AudioBufferSourceNodes
Los AudioBufferSourceNodes son el método principal para reproducir sonidos con WebAudio. Es muy importante comprender que son un objeto de un solo uso. Creas un AudioBufferSourceNode, le asignas un búfer, lo conectas al gráfico 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; tienes que crear otro AudioBufferSourceNode. Sin embargo, puedes reutilizar el mismo objeto AudioBuffer subyacente (de hecho, incluso puedes tener varios AudioBufferSourceNodes activos que apunten a la misma instancia de AudioBuffer). Puedes encontrar un fragmento de la reproducción de Fieldrunners en Give Me a Beat.
Contenido que no se almacena en caché
En el lanzamiento, el servidor de HTML5 de Fieldrunners mostró una gran cantidad de solicitudes de archivos de música. Este resultado se produjo porque Chrome 15 descargó el archivo en fragmentos y, luego, no lo almacenó en caché. En respuesta, en ese momento decidimos cargar los archivos de música como el resto de nuestros archivos de audio. Hacerlo no es lo ideal, pero algunas versiones de otros navegadores aún lo hacen.
Silenciar cuando no está en foco
Antes, era difícil detectar cuándo la pestaña del juego no estaba enfocada. Fieldrunners comenzó la portabilidad antes de Chrome 13, donde la API de Page Visibility reemplazó la necesidad de nuestro código complejo para detectar el desenfoque de pestañas. Todos los juegos deberían usar la API de Visibility para escribir un pequeño fragmento que silencie o pause el sonido, si no se pausa 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.
Pausando 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 la adecuada: estábamos utilizando un error en la implementación actual de Web Audio para pausar la reproducción de sonidos. Como esto se corregirá en el futuro, no puedes simplemente pausar el sonido desconectando un nodo o un subgráfico para detener la reproducción.
Una arquitectura de nodos de Web Audio simple
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é pausado.
- Vuelve a activar esos mismos sonidos cuando se reanude el juego.
- Desactivar todo el audio cuando la pestaña del juego pierde el foco
- 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 posibles nodos proporcionados: DestinationNode, GainNode y AudioBufferSourceNode. Los AudioBufferSourceNodes reproducen los sonidos. Los GainNodes conectan los AudioBufferSourceNodes entre sí. El DestinationNode, creado por el contexto de Web Audio, llamado destination, 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.
Un gráfico de nodos de Web Audio va desde los nodos de hoja hasta el nodo de destino. Fieldrunners usó 6 nodos de ganancia permanentes, 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 maestro que 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 función. Usamos esos nodos para recortar grupos de sonidos que se reproducen del gráfico, lo que detiene su progreso. Hicimos esto para pausar los sonidos. Como no es correcto, ahora solo usaríamos 3 nodos de ganancia total, como se describió anteriormente. Muchos de los fragmentos que siguen incluirán nuestros nodos incorrectos, lo que muestra lo que hicimos y cómo lo corregiríamos a corto plazo. Sin embargo, a largo plazo, no querrás usar 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 por separado los efectos de sonido y la música. Esto se puede lograr fácilmente con nuestro gráfico anterior. Cada nodo de ganancia tiene un atributo "gain" 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, de los efectos de sonido y de la música. Establecer la ganancia del nodo principal afectará todo el sonido del juego. Si estableces el valor de ganancia en 0, silenciarás el sonido y la música. Los AudioBufferSourceNodes también tienen un parámetro de ganancia. Podrías hacer un seguimiento de una lista de todos los sonidos que se reproducen 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 innumerables sonidos. Controlar el volumen de esta manera también te brinda más potencia 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 con el propósito de reproducir música. En su lugar, cambia un solo nodo cuando un jugador cambie el volumen de la música y en el lanzamiento. Ahora tenemos un valor de ganancia en las fuentes de búfer para hacer algo más. En el caso de la música, un uso común puede ser crear un fundido cruzado de una pista de audio a otra a medida que una sale y otra entra. Web Audio proporciona un método agradable para realizar esto con facilidad.
function arbitraryCrossfade( track1, track2 ) {
track1.gain.linearRampToValueAtTime( 0, 1 );
track2.gain.linearRampToValueAtTime( 1, 1 );
}
Fieldrunners no hizo un uso específico del fundido cruzado. Si hubiéramos conocido la funcionalidad de configuración de valores de WebAudio durante nuestra primera pasada por el sistema de sonido, probablemente lo habríamos hecho.
Pausando sonidos
Cuando un jugador pausa un juego, es posible que se sigan reproduciendo algunos sonidos. El sonido es una parte importante de los comentarios para la presión común de los elementos de la interfaz de usuario en los menús de los juegos. Como Fieldrunners tiene varias interfaces con las que el usuario puede interactuar mientras el juego está en pausa, queremos que se sigan reproduciendo. 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 pausados 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 se reproduzca de nuevo al instante.
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}
Después de lanzar Fieldrunners, descubrimos que desconectar un nodo o un subgráfico por sí solo no detendrá la reproducción de los AudioBufferSourceNodes. De hecho, aprovechamos un error en WebAudio que actualmente detiene la reproducción de los nodos que no están conectados al nodo de destino en el gráfico. Para asegurarnos de que estamos listos 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ó 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, se deben realizar cambios en el gráfico de nodos de Fieldrunners (ya que creamos nodos para acortar 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.
Pérdida de enfoque
Nuestro nodo principal entra en juego para esta función. Cuando un usuario del navegador cambia a otra pestaña, el juego deja de ser visible. Si no lo ves, no te afecta, y el sonido también debería desaparecer. Se pueden usar trucos para determinar estados de visibilidad específicos de 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 que tenga en cuenta 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 nodo maestro sería suficiente para pausar todo el sonido en lugar de silenciarlo. Al desconectar el nodo en ese momento, impedimos que este y sus nodos secundarios procesen y reproduzcan audio. Cuando se volvía a conectar, todos los sonidos y la música comenzaban a reproducirse desde donde se habían detenido, al igual que el juego continuaba desde donde se había detenido. Pero este es un comportamiento inesperado. No basta con desconectarse para detener la reproducción.
La API de Page Visibility facilita mucho saber cuándo la pestaña ya no está enfocada. Si ya tienes un código eficaz para pausar los sonidos, solo se necesitan unas pocas líneas para escribir la pausa de sonido cuando se oculta la pestaña de juegos.
Pon una canción
Ya configuramos algunas cosas. Tenemos un grafo 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 haya terminado 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 deben reproducir rápidamente y que, de lo contrario, se entrecortarían si se permitiera reiniciarlos o crearían una cacofonía desagradable si se permitiera reproducir varias instancias. Se espera que los AudioBufferSourceNodes se usen como eventos únicos. Crea un nodo, adjunta un búfer, establece el valor booleano del bucle si es necesario, conéctate a un nodo del grafo que 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 );
}
}
Demasiada transmisión
Originalmente, Fieldrunners se lanzó con música de fondo que se reproducía con una etiqueta de audio. En el momento del 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 un poco, descubrimos que, en ese momento, el navegador Chrome no almacenaba en caché los fragmentos transmitidos de los archivos de música. Esto provocaba que el navegador solicitara la pista que se estaba reproduciendo cada pocos minutos, a medida que terminaba. En pruebas más recientes, Chrome almacenó en caché las pistas transmitidas, pero es posible que otros navegadores aún no lo hagan. La transmisión de archivos de audio grandes con la etiqueta Audio para funciones como la reproducción de música es óptima, pero, en algunas versiones de navegadores, es posible que quieras cargar la 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 las pistas de la misma manera que cargamos todos los efectos con XMLHttpRequests y el tipo de respuesta 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 genial llevar Fieldrunners a Chrome y HTML5. Además de la enorme cantidad de trabajo que implicó llevar miles de líneas de C++ a JavaScript, surgieron algunos dilemas y decisiones interesantes específicos de HTML5. Para reiterar uno si no hay ninguno de los otros, los AudioBufferSourceNodes son objetos de un solo uso. Créalos, adjunta un AudioBuffer, conéctalo al gráfico de Web Audio y reprodúcelo con noteOn o noteGrainOn. ¿Necesitas volver a reproducir ese sonido? Luego, crea otro AudioBufferSourceNode.