Fieldrunners
Fieldrunners est un jeu de tower defense primé, sorti à l'origine sur iPhone en 2008. Depuis, il a été porté sur de nombreuses autres plates-formes. L'une des plates-formes les plus récentes est le navigateur Chrome, en octobre 2011. L'un des défis du portage de Fieldrunners sur une plate-forme HTML5 était la lecture du son.
Fieldrunners n'utilise pas d'effets sonores complexes, mais il est attendu que l'utilisateur puisse interagir avec ces effets. Le jeu comporte 88 effets sonores, dont un grand nombre peut être joué simultanément. La plupart de ces sons sont très courts et doivent être lus le plus rapidement possible pour éviter tout décalage avec la présentation graphique.
Des problèmes sont survenus
Lors du portage de Fieldrunners vers HTML5, nous avons rencontré des problèmes de lecture audio avec la balise Audio. Nous avons donc décidé de nous concentrer sur l'API Web Audio. L'utilisation de WebAudio nous a permis de résoudre des problèmes tels que le nombre élevé d'effets simultanés requis par Fieldrunners. Cependant, lors du développement d'un système audio pour Fieldrunners HTML5, nous avons rencontré quelques problèmes nuancés dont les autres développeurs pourraient vouloir être informés.
Nature des AudioBufferSourceNodes
Les AudioBufferSourceNodes sont votre principale méthode pour lire des sons avec WebAudio. Il est très important de comprendre qu'il s'agit d'un objet à usage unique. Vous créez un AudioBufferSourceNode, lui attribuez un tampon, le connectez au graphique et le lisez avec noteOn ou noteGrainOn. Vous pouvez ensuite appeler noteOff pour arrêter la lecture, mais vous ne pourrez pas relire la source en appelant noteOn ou noteGrainOn. Vous devrez créer un autre AudioBufferSourceNode. Cependant, vous pouvez (et c'est essentiel) réutiliser le même objet AudioBuffer sous-jacent (en fait, vous pouvez même avoir plusieurs AudioBufferSourceNodes actifs qui pointent vers la même instance AudioBuffer). Vous trouverez un extrait de Fieldrunners dans Give Me a Beat.
Contenu non mis en cache
Lors de la sortie, le serveur HTML5 de Fieldrunners a enregistré un nombre massif de requêtes pour des fichiers musicaux. Ce résultat est dû au fait que Chrome 15 télécharge le fichier par fragments, puis ne le met pas en cache. À l'époque, nous avions décidé de charger les fichiers musicaux comme le reste de nos fichiers audio. Cette approche n'est pas optimale, mais certaines versions d'autres navigateurs l'utilisent encore.
Désactivation du son lorsque l'attention est détournée
Auparavant, il était difficile de détecter quand l'onglet de votre jeu n'était plus actif. Fieldrunners a commencé le portage avant Chrome 13, où l'API Page Visibility a remplacé notre code complexe pour détecter le floutage des onglets. Chaque jeu doit utiliser l'API Visibility pour écrire un petit extrait de code permettant de couper ou de mettre en pause le son, voire de mettre en pause l'intégralité du jeu. Comme Fieldrunners utilisait l'API requestAnimationFrame, la mise en pause du jeu était gérée implicitement, mais pas celle du son.
Mise en pause des sons
Curieusement, en recevant des commentaires sur cet article, nous avons été informés que la technique que nous utilisions pour mettre en pause les sons n'était pas appropriée. Nous utilisions un bug dans l'implémentation actuelle de Web Audio pour mettre en pause la lecture des sons. Comme ce problème sera résolu à l'avenir, vous ne pouvez pas simplement mettre le son en pause en déconnectant un nœud ou un sous-graphe pour arrêter la lecture.
Architecture simple de nœuds Web Audio
Fieldrunners utilise un modèle audio très simple. Ce modèle peut prendre en charge l'ensemble de caractéristiques suivant :
- Contrôlez le volume des effets sonores.
- Contrôlez le volume de la piste de musique de fond.
- Couper tout le son.
- Désactivez la lecture des sons lorsque le jeu est en pause.
- Réactivez ces mêmes sons lorsque le jeu reprend.
- Désactivez tous les sons lorsque l'onglet du jeu n'est plus actif.
- Redémarrez la lecture après la diffusion d'un son, si nécessaire.
Pour obtenir les fonctionnalités ci-dessus avec Web Audio, il a utilisé trois des nœuds possibles fournis : DestinationNode, GainNode et AudioBufferSourceNode. Les AudioBufferSourceNodes lisent les sons. Les GainNodes connectent les AudioBufferSourceNodes entre eux. Le DestinationNode, créé par le contexte Web Audio, appelé "destination", émet des sons pour le joueur. Web Audio comporte de nombreux autres types de nœuds, mais ceux-ci suffisent pour créer un graphique très simple pour les sons d'un jeu.
Un graphique de nœuds Web Audio mène des nœuds feuilles au nœud de destination. Fieldrunners utilisait six nœuds de gain permanents, mais trois suffisent pour contrôler facilement le volume et connecter un plus grand nombre de nœuds temporaires qui liront les tampons. Tout d'abord, un nœud de gain principal qui associe chaque nœud enfant à la destination. Deux nœuds de gain sont directement associés au nœud de gain principal : un pour un canal musical et un autre pour lier tous les effets sonores.
Fieldrunners avait trois nœuds de gain supplémentaires en raison de l'utilisation incorrecte d'un bug en tant que fonctionnalité. Nous avons utilisé ces nœuds pour supprimer des groupes de sons en cours de lecture du graphique, ce qui arrête leur progression. Nous avons fait cela pour mettre les sons en pause. Comme ce n'est pas correct, nous n'utiliserons désormais que trois nœuds de gain total, comme décrit ci-dessus. La plupart des extraits suivants incluront nos nœuds incorrects, montrant ce que nous avons fait et comment nous pourrions résoudre ce problème à court terme. Mais à long terme, vous ne voudrez pas utiliser nos nœuds après notre nœud 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 plupart des jeux permettent de contrôler séparément les effets sonores et la musique. Pour ce faire, il suffit d'utiliser le graphique ci-dessus. Chaque nœud de gain possède un attribut "gain" qui peut être défini sur n'importe quelle valeur décimale comprise entre 0 et 1, et qui peut être utilisé pour contrôler le volume. Comme nous voulons contrôler séparément le volume des canaux de musique et d'effets sonores, nous avons un nœud de gain pour chacun d'eux, ce qui nous permet de contrôler leur volume.
function setArbitraryVolume() {
var musicGainNode = this.nodes.backgroundMusicGain;
// set music volume to 50%
musicGainNode.gain.value = 0.5;
}
Nous pouvons utiliser cette même capacité pour contrôler le volume de tout, des effets sonores et de la musique. La définition du gain du nœud maître affectera tous les sons du jeu. Si vous définissez la valeur de gain sur 0, vous couperez le son et la musique. Les AudioBufferSourceNodes ont également un paramètre de gain. Vous pouvez suivre une liste de tous les sons en cours de lecture et ajuster leurs valeurs de gain individuellement pour le volume global. Si vous créiez des effets sonores avec des balises audio, c'est ce que vous devriez faire. Au lieu de cela, le graphique de nœuds de l'API Web Audio facilite grandement la modification du volume d'un nombre incalculable de sons. Contrôler le volume de cette manière vous donne également plus de puissance sans complication. Nous pourrions simplement associer un AudioBufferSourceNode directement au nœud principal pour lire de la musique et contrôler son propre gain. Toutefois, vous devez définir cette valeur chaque fois que vous créez un AudioBufferSourceNode pour lire de la musique. À la place, vous ne modifiez qu'un seul nœud lorsqu'un joueur modifie le volume de la musique et au lancement. Nous disposons désormais d'une valeur de gain sur les sources de tampon pour effectuer une autre action. Pour la musique, une utilisation courante peut consister à créer un fondu enchaîné d'une piste audio à une autre, lorsqu'une piste se termine et qu'une autre commence. Web Audio fournit une méthode pratique pour effectuer cette opération facilement.
function arbitraryCrossfade( track1, track2 ) {
track1.gain.linearRampToValueAtTime( 0, 1 );
track2.gain.linearRampToValueAtTime( 1, 1 );
}
Fieldrunners n'a pas utilisé de fondu enchaîné spécifique. Si nous avions connu la fonctionnalité de définition de valeur de WebAudio lors de notre première passe du système audio, nous l'aurions probablement fait.
Mise en pause des sons
Lorsqu'un joueur met un jeu en pause, il peut s'attendre à ce que certains sons continuent d'être diffusés. Le son est un élément important du feedback pour l'appui courant sur les éléments de l'interface utilisateur dans les menus du jeu. Comme Fieldrunners propose plusieurs interfaces avec lesquelles l'utilisateur peut interagir lorsque le jeu est en pause, nous voulons que ces sons continuent d'être diffusés. Cependant, nous ne voulons pas que les sons longs ou en boucle continuent d'être lus. Il est assez facile d'arrêter ces sons avec Web Audio, ou du moins, c'est ce que nous pensions.
AudioManager.prototype.pauseEffects = function() {
this.nodes.effectsGain.disconnect();
}
Le nœud des effets mis en pause est toujours connecté. Tous les sons autorisés à ignorer l'état de pause du jeu continueront d'être lus. Lorsque le jeu reprend, nous pouvons reconnecter ces nœuds et tous les sons sont à nouveau lus instantanément.
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}
Après avoir lancé Fieldrunners, nous avons découvert que la déconnexion d'un nœud ou d'un sous-graphe ne suffit pas à mettre en pause la lecture des AudioBufferSourceNodes. Nous avons en fait exploité un bug dans WebAudio qui arrête actuellement la lecture des nœuds non connectés au nœud de destination dans le graphique. Pour nous assurer que nous sommes prêts pour ce correctif futur, nous avons besoin d'un code semblable à celui-ci :
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 nous avions su plus tôt que nous exploitions un bug, la structure de notre code audio serait très différente. Cela a donc eu un impact sur plusieurs sections de cet article. Cela a un effet direct ici, mais aussi dans nos extraits de code dans "Perte de concentration" et "Donne-moi un rythme". Pour comprendre comment cela fonctionne, il faut modifier le graphique de nœuds Fieldrunners (puisque nous avons créé des nœuds pour court-circuiter la lecture) et le code supplémentaire qui enregistrera et fournira les états de pause que Web Audio ne gère pas seul.
Perte d'intérêt
C'est là que notre nœud maître entre en jeu. Lorsqu'un utilisateur de navigateur passe à un autre onglet, le jeu n'est plus visible. Loin des yeux, loin du cœur, et le son devrait disparaître. Il existe des astuces pour déterminer des états de visibilité spécifiques pour la page d'un jeu, mais l'API Visibility a grandement facilité les choses.
Fieldrunners ne s'exécute que dans l'onglet actif, car il utilise requestAnimationFrame pour appeler sa boucle de mise à jour. Toutefois, le contexte Web Audio continuera de lire les effets en boucle et les pistes d'arrière-plan lorsqu'un utilisateur se trouve dans un autre onglet. Mais nous pouvons l'arrêter avec un très petit extrait de code compatible avec 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();
}
});
}
Avant d'écrire cet article, nous pensions qu'il suffisait de déconnecter l'appareil principal pour mettre en pause tous les sons au lieu de les couper. En déconnectant le nœud à ce moment-là, nous avons empêché le nœud et ses enfants de traiter et de lire le son. Une fois la connexion rétablie, tous les sons et la musique reprennent là où ils s'étaient arrêtés, tout comme le gameplay. Mais ce comportement est inattendu. Il ne suffit pas de déconnecter l'appareil pour arrêter la lecture.
L'API Page Visibility permet de savoir très facilement quand votre onglet n'est plus actif. Si vous disposez déjà d'un code efficace pour mettre en pause les sons, il ne vous faudra que quelques lignes pour mettre en pause les sons lorsque l'onglet du jeu est masqué.
Mets-moi de la musique
Nous avons maintenant configuré quelques éléments. Nous avons un graphe de nœuds. Nous pouvons mettre en pause les sons lorsque le joueur met le jeu en pause, et lire de nouveaux sons pour des éléments tels que les menus du jeu. Nous pouvons mettre en pause tous les sons et la musique lorsque l'utilisateur passe à un nouvel onglet. Nous devons maintenant lire un son.
Au lieu de lire plusieurs copies du son pour plusieurs instances d'une entité de jeu (par exemple, un personnage qui meurt), Fieldrunners ne lit qu'un seul son une seule fois pendant sa durée. Si le son est nécessaire après la fin de la lecture, il peut redémarrer, mais pas pendant la lecture. Il s'agit d'une décision pour la conception audio de Fieldrunners, car certains sons doivent être lus rapidement. Ils pourraient saccader s'ils étaient redémarrés ou créer une cacophonie désagréable si plusieurs instances étaient lues. Les AudioBufferSourceNodes sont censés être utilisés une seule fois. Créez un nœud, associez un tampon, définissez la valeur booléenne de la boucle si nécessaire, connectez-vous à un nœud du graphique qui mène à la destination, appelez noteOn ou noteGrainOn, et éventuellement noteOff.
Pour Fieldrunners, cela ressemble à ceci :
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 );
}
}
Trop de streaming
Fieldrunners a été lancé à l'origine avec une musique de fond lue à l'aide d'une balise Audio. Lors de la sortie, nous avons découvert que les fichiers musicaux étaient demandés un nombre de fois disproportionné par rapport au reste du contenu du jeu. Après quelques recherches, nous avons découvert qu'à l'époque, le navigateur Chrome ne mettait pas en cache les fragments de fichiers musicaux diffusés en streaming. Le navigateur demandait donc le titre en cours de lecture toutes les quelques minutes, à mesure qu'il se terminait. Lors de tests plus récents, Chrome a mis en cache les titres diffusés en streaming, mais d'autres navigateurs ne le font peut-être pas encore. La diffusion de fichiers audio volumineux avec la balise Audio pour des fonctionnalités telles que la lecture de musique est optimale, mais pour certaines versions de navigateur, vous pouvez charger votre musique de la même manière que vous chargez des effets sonores.
Comme tous les effets sonores étaient lus via Web Audio, nous avons également déplacé la lecture de la musique de fond vers Web Audio. Cela signifiait que nous chargerions les pistes de la même manière que nous chargions tous les effets avec des requêtes XMLHttpRequests et le type de réponse 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();
}
Résumé
Nous avons adoré porter Fieldrunners sur Chrome et HTML5. En plus de la montagne de travail que représente la conversion de milliers de lignes de code C++ en JavaScript, des dilemmes et des décisions intéressants spécifiques au HTML5 se sont présentés. Pour rappel, les AudioBufferSourceNodes sont des objets à usage unique. Créez-les, associez-y un AudioBuffer, connectez-le au graphique Web Audio et lisez-le avec noteOn ou noteGrainOn. Besoin de réécouter ce son ? Créez ensuite un autre AudioBufferSourceNode.