Étude de cas : Histoire d'un jeu HTML5 avec audio Web

Coureurs de terrain

Capture d'écran de Fieldrunners
Capture d'écran Fieldrunners

Fieldrunners est un jeu de tower defense primé, sorti à l'origine pour iPhone en 2008. Depuis, il a été porté sur de nombreuses autres plates-formes. En octobre 2011, l'une des plates-formes les plus récentes était le navigateur Chrome. L'un des défis liés au portage de Fieldrunner vers une plate-forme HTML5 était de savoir comment jouer du son.

L'application Fieldrunner n'utilise pas les effets sonores de façon compliquée, mais elle s'attend à un certain nombre d'interactions avec ces effets sonores. Le jeu compte 88 effets sonores, dont on peut s'attendre à ce qu'un grand nombre d'entre eux joue en même temps. La plupart de ces sons sont très courts et doivent être joués le plus rapidement possible pour éviter toute déconnexion avec la présentation graphique.

Quelques difficultés se sont produites

Lors du portage de Fieldrunner vers HTML5, nous avons rencontré des problèmes de lecture audio avec la balise audio et avons très vite décidé de nous concentrer sur l'API Web Audio. L'utilisation de WebAudio nous a aidés à résoudre des problèmes tels que le nombre élevé d'effets simultanés nécessaires à Fieldrunner. Toutefois, lors du développement d'un système audio pour le format HTML5 de Fieldrunners, nous avons rencontré quelques problèmes nuancés dont d'autres développeurs voudront peut-être être informés.

Nature des AudioBufferSourceNode

Les AudioBufferSourceNodes constituent votre méthode principale pour lire des sons avec WebAudio. Il est très important de comprendre qu'il s'agit d'un objet à usage unique. Vous allez créer un AudioBufferSourceNode, lui attribuer un tampon, le connecter au graphique et le lire avec noteOn ou noteGrainOn. Vous pourrez ensuite appeler noteOff pour arrêter la lecture, mais vous ne pourrez plus lire la source en appelant noteOn ou noteGrainOn. Vous devez créer un autre AudioBufferSourceNode. Cependant, vous pouvez réutiliser le même objet AudioBuffer sous-jacent (et c'est essentiel), car vous pouvez même avoir plusieurs AudioBufferSourceNode actifs pointant vers la même instance AudioBuffer. Vous trouverez un extrait de lecture de Fieldrunners dans Give Me a Beat.

Contenu sans mise en cache

Dès son lancement, le serveur HTML5 Fieldrunners affichait un grand nombre de requêtes de fichiers musicaux. Ce résultat est imputable à Chrome 15 pour télécharger le fichier en fragments sans le mettre en cache. En réponse à cette demande, nous avons décidé de charger des fichiers musicaux comme nos autres fichiers audio. Cette opération n'est pas optimale, mais certaines versions d'autres navigateurs le font toujours.

Couper le son en cas d'absence de mise au point

Auparavant, il était difficile de détecter lorsque l'onglet de votre jeu était flou. Fieldrunner a commencé le portage avant Chrome 13, où l'API Page Visibility a remplacé la nécessité pour notre code compliqué de détecter le floutage des onglets. Chaque jeu doit utiliser l'API Visibility pour écrire un petit extrait afin de couper le son ou de le mettre en pause s'il ne s'arrête pas pendant toute la durée du jeu. Étant donné que Fieldrunners utilisait l'API requestAnimationFrame, la mise en pause de jeu était implicitement gérée, mais pas la mise en pause du son.

Mise en pause des sons

Étrangement, lorsque nous avons reçu 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 adaptée. Nous utilisions un bug dans l'implémentation actuelle de Web Audio pour mettre en pause la lecture des sons. Ce problème étant résolu à l'avenir, vous ne pouvez pas simplement mettre le son en pause en déconnectant un nœud ou un sous-graphique pour interrompre la lecture.

Architecture simple de nœud audio Web

Fieldrunners dispose d'un modèle audio très simple. Ce modèle est compatible avec l'ensemble de caractéristiques suivant:

  • Contrôler le volume des effets sonores
  • Régler le volume de la musique de fond
  • Coupez tout le son.
  • Désactivez les sons de jeu lorsque le jeu est mis en pause.
  • Réactivez ces sons à la reprise du jeu.
  • Désactivez tout le son lorsque l'onglet du jeu n'est plus au premier plan.
  • Si nécessaire, relancez la lecture après l'émission d'un son.

Pour réaliser les fonctionnalités ci-dessus avec Web Audio, l'API a utilisé trois des nœuds fournis: DestinationNode, GainNode, AudioBufferSourceNode. Les AudioBufferSourceNodes diffusent les sons. Les GainNodes connectent les AudioBufferSourceNode. Le composant DestinationNode, créé par le contexte Web Audio, appelé destination, lit les sons du lecteur. Web Audio comporte de nombreux autres types de nœuds, mais avec ceux-ci, nous pouvons créer un graphique très simple pour les sons de jeu.

Graphique du graphique de nœuds

Un graphique sur un nœud Web Audio relie les nœuds feuilles au nœud de destination. Fieldrunner a utilisé six nœuds de gain permanents, mais trois sont suffisants pour permettre un contrôle aisé du volume et connecter un plus grand nombre de nœuds temporaires qui exécuteront des tampons de lecture. Tout d'abord, un nœud de gain maître rattache chaque nœud enfant à la destination. Deux nœuds de gain sont immédiatement reliés au nœud de gain maître, l'un pour une chaîne musicale et l'autre pour associer tous les effets sonores.

Les Fieldrunners disposaient de 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 extraire du graphique les groupes de sons diffusés, ce qui arrête leur progression. Nous l'avons fait pour mettre en pause les sons. Ce n'est pas correct. Nous n'utiliserons donc à présent que trois nœuds de gain au total, comme décrit ci-dessus. La plupart des extraits suivants incluent nos nœuds incorrects, indiquent ce que nous avons fait et comment nous pouvons résoudre ce problème à court terme. Mais à long terme, il est préférable de ne 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. Vous pouvez y parvenir facilement grâce au 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, qui peut être utilisée pour contrôler essentiellement le volume. Comme nous voulons contrôler le volume des chaînes de musique et d'effets sonores séparément, nous avons un nœud de gain pour chacune d'elles, 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 fonctionnalité pour contrôler le volume de tout, des effets sonores et de la musique. Définir le gain du nœud maître aura une incidence sur tous les sons du jeu. Si vous définissez la valeur de gain sur 0, le son et la musique seront coupés. Les AudioBufferSourceNodes possèdent é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éez des effets sonores avec des balises audio, voici ce que vous devez faire. À la place, le graphique en nœuds de Web Audio vous permet de modifier beaucoup plus facilement le volume sonore d'une multitude de sons. En contrôlant le volume de cette façon, vous profitez également de plus d'énergie sans complication. Nous pourrions simplement rattacher un AudioBufferSourceNode directement au nœud maître pour lire de la musique et contrôler son propre gain. En revanche, vous devez définir cette valeur chaque fois que vous créez un AudioBufferSourceNode pour lire de la musique. Au lieu de cela, vous ne modifiez un nœud que lorsqu'un lecteur modifie le volume de la musique, ainsi qu'au lancement. Nous avons maintenant une valeur de gain sur les sources de tampon pour faire autre chose. Pour la musique, il peut s'agir, par exemple, de créer un fondu croisé d'une piste audio à une autre lorsque l'une d'elles quitte l'autre et qu'une autre entre. "Web Audio" constitue une méthode efficace pour effectuer cette opération facilement.

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

Les Fieldrunners n'utilisaient pas spécifiquement le fondu enchaîné. Avons-nous connu la fonctionnalité permettant de définir la valeur de WebAudio lors de notre choix initial du système audio, que nous aurions probablement eue.

Mise en pause des sons

Lorsqu'un joueur met un jeu en pause, il peut s'attendre à ce que des sons continuent à être émis. Le son joue un rôle important dans le retour d'information lors de la pression habituelle sur les éléments de l'interface utilisateur dans les menus de jeu. Étant donné que Fieldrunners dispose d'un certain nombre d'interfaces avec lesquelles les utilisateurs peuvent interagir lorsque le jeu est en pause, nous voulons que ceux qui jouent. Cependant, nous ne souhaitons pas que la lecture continue d'un son long ou en boucle. Il est assez facile d'arrêter ces sons avec Web Audio, du moins ce que nous pensions.

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

Le nœud d'effets suspendus est toujours connecté. Les sons autorisés à ignorer l'état de pause du jeu continuent d'être lus. Lorsque le jeu se réactive, nous pouvons reconnecter ces nœuds et rétablir instantanément le son.

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

Après l'envoi de Fieldrunners, nous avons découvert que la déconnexion d'un nœud ou d'un sous-graphique seul ne met pas en pause la lecture des AudioBufferSourceNodes. Nous avons profité d'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 appris plus tôt que nous utilisions un bug de manière abusive, la structure de notre code audio serait très différente. Cela a donc affecté un certain nombre de sections de cet article. Cela a un effet direct ici, mais aussi dans nos extraits de code dans Losing Focus et Give Me a Beat. Pour comprendre comment cela fonctionne réellement, vous devez modifier à la fois le graphique de nœuds Fieldrunners (puisque nous avons créé des nœuds pour raccourcir la lecture) et le code supplémentaire qui enregistrera et fournira les états mis en pause que Web Audio ne fait pas seul.

Perte de concentration

Notre nœud maître entre en jeu pour cette fonctionnalité. Lorsqu'un utilisateur de navigateur passe à un autre onglet, le jeu n'est plus visible. Très bien, le son devrait disparaître. Il existe des astuces pour déterminer les états de visibilité spécifiques d'une page de jeu, mais c'est beaucoup plus facile avec l'API Visibility.

Fieldrunners jouera uniquement comme onglet actif grâce à l'utilisation de 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 arrêter cela avec un tout petit extrait 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 de rédiger cet article, nous pensions qu'il suffirait de déconnecter le maître pour mettre en pause tous les sons au lieu de couper le son. En déconnectant le nœud à ce moment-là, nous l'avons empêché ainsi que ses enfants de traiter et de jouer. Une fois la connexion rétablie, tous les sons et la musique reprenaient là où ils s'étaient arrêtés, et la partie se poursuivait là où elle s'était arrêtée. Mais ce comportement est inattendu. Il ne suffit pas de se déconnecter pour interrompre la lecture.

L'API Page Visibility permet de savoir facilement quand votre onglet n'est plus au premier plan. Si vous disposez déjà d'un code efficace pour mettre les sons en pause, il suffit de quelques lignes pour les mettre en pause lorsque l'onglet des jeux est masqué.

Écoutez-moi

Quelques éléments sont maintenant configurés. Nous avons un graphique de nœuds. Nous pouvons mettre en pause les sons lorsque le joueur met le jeu en pause et diffuser de nouveaux sons pour des éléments tels que les menus de jeu. Nous pouvons mettre en pause tous les sons et la musique lorsque l'utilisateur passe à un nouvel onglet. À présent, nous devons faire un son.

Au lieu de diffuser plusieurs copies du son pour plusieurs instances d'une entité de jeu, telle qu'un personnage en train de mourir, Fieldrunner ne diffuse qu'un seul son pendant sa durée. Si le son est nécessaire à la fin de la lecture, celui-ci peut redémarrer, mais pas pendant la lecture. C'est une décision pour la conception audio de Fieldrunners, car elle comporte des sons qui doivent être lus rapidement, qui seraient saccadés s'ils étaient autorisés à redémarrer ou s'ils créaient une cacophonie désagréable s'ils étaient autorisés à jouer plusieurs instances. Les AudioBufferSourceNodes doivent être utilisés en mode one-shot. 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 sur le graphe qui mènera à la destination, appeler 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

À l'origine, Fieldrunners comportait une musique de fond lue à l'aide d'un tag audio. Lors de la sortie du jeu, nous avons découvert que le nombre de demandes de fichiers musicaux était disproportionné par rapport au nombre de fois où le reste du contenu du jeu était demandé. Après quelques recherches, nous avons découvert qu'à l'époque, le navigateur Chrome ne mettait pas en cache les fragments des fichiers musicaux diffusés en streaming. Le navigateur a donc demandé le titre en cours de lecture à quelques minutes d'intervalle, le temps qu'il se termine. Lors de tests plus récents, les titres diffusés en streaming dans Chrome ont été mis en cache, mais il est possible que d'autres navigateurs ne le fassent pas encore. Il est préférable de diffuser des fichiers audio volumineux à l'aide de la balise audio pour des fonctionnalités telles que la lecture de musique. Toutefois, pour certaines versions de navigateur, vous pouvez charger votre musique de la même manière que vous chargez des effets sonores.

Étant donné que tous les effets sonores étaient lus via Web Audio, nous avons déplacé la lecture de la musique de fond vers Web Audio également. Autrement dit, nous devions charger les pistes de la même manière que nous avions chargé tous les effets avec 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é

Fieldrunners a été un excellent outil pour Chrome et HTML5. Outre le travail qui a été réalisé grâce à l'intégration de milliers de lignes C++ en JavaScript, il existe des dilemmes et des décisions intéressantes spécifiques à l'excitation du HTML5. Pour réitérer l'un des autres, les AudioBufferSourceNodes sont des objets utilisés une seule fois. Créez-les, associez-les à un tampon audio, connectez-le au graphique Web Audio et lancez la lecture avec noteOn ou noteGrainOn. Besoin de rediffuser ce son ? Créez ensuite un autre AudioBufferSourceNode.