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

Fieldrunners

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

Fieldrunners est un jeu de défense de tour primé, initialement publié pour l'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 vers une plate-forme HTML5 était de savoir comment lire le son.

Fieldrunners n'utilise pas d'effets sonores complexes, mais il présente certaines attentes concernant la façon dont il peut interagir avec ses effets sonores. Le jeu comporte 88 effets sonores, dont un grand nombre peut être diffusé en même temps. La plupart de ces sons sont très courts et doivent être diffusés le plus rapidement possible pour éviter tout décalage avec la présentation graphique.

Des défis sont apparus

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 dès le départ. L'utilisation de WebAudio nous a permis de résoudre des problèmes, comme le nombre élevé d'effets simultanés de lecture requis par Fieldrunners. Toutefois, lors du développement d'un système audio pour Fieldrunners HTML5, nous avons rencontré quelques problèmes subtils que d'autres développeurs pourraient vouloir connaître.

Nature des AudioBufferSourceNodes

Les AudioBufferSourceNodes sont la 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 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 la lire à nouveau en appelant noteOn ou noteGrainOn. Vous devez créer un autre AudioBufferSourceNode. Vous pouvez toutefois 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 son lancement, le serveur HTML5 de Fieldrunners a enregistré un nombre massif de requêtes de fichiers musicaux. Ce résultat est dû au fait que Chrome 15 a téléchargé le fichier par morceaux, puis ne l'a pas mis en cache. À l'époque, nous avons décidé de charger les fichiers musicaux comme le reste de nos fichiers audio. Cette méthode n'est pas optimale, mais certaines versions d'autres navigateurs continuent de l'utiliser.

Silencieux en cas de mise au point incorrecte

Il était auparavant difficile de détecter quand l'onglet de votre jeu n'était pas 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 permettant de couper le son ou de le mettre en pause, voire de mettre en pause l'ensemble du jeu. Comme Fieldrunners utilisait l'API requestAnimationFrame, la mise en pause du jeu était gérée implicitement, mais pas la mise en pause du son.

Mettre en pause les sons

Curieusement, 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 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 corrigé à l'avenir, vous ne pouvez pas simplement mettre en pause le son en déconnectant un nœud ou un sous-graphique pour arrêter la lecture.

Une architecture de nœud Web Audio simple

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

  • Contrôler le volume des effets sonores
  • Contrôler le volume de la musique de fond
  • Coupez le son de tous les participants.
  • Désactivez la lecture des sons lorsque le jeu est mis en pause.
  • Réactivez ces mêmes sons lorsque le jeu reprend.
  • Désactivez tous les sons lorsque l'onglet du jeu perd la priorité.
  • Redémarrer la lecture après la lecture d'un son, si nécessaire

Pour obtenir les fonctionnalités ci-dessus avec Web Audio, trois des nœuds possibles ont été utilisés: DestinationNode, GainNode et AudioBufferSourceNode. Les AudioBufferSourceNodes diffusent les sons. Les GainNodes connectent les AudioBufferSourceNodes entre eux. Le DestinationNode, créé par le contexte Web Audio, appelé "destination", diffuse des sons pour le lecteur. Web Audio propose de nombreux autres types de nœuds, mais avec ces nœuds, nous pouvons créer un graphique très simple pour les sons d'un jeu.

Graphique de nœud

Un graphique de nœuds Web Audio mène des nœuds de 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 maître qui associe chaque nœud enfant à la destination. Deux nœuds de gain sont immédiatement associés au nœud de gain principal, l'un pour un canal de musique et l'autre pour associer tous les effets sonores.

Fieldrunners comportait trois nœuds de gain supplémentaires en raison d'une utilisation incorrecte d'un bug comme fonctionnalité. Nous avons utilisé ces nœuds pour couper des groupes de sons en cours de lecture du graphique, ce qui arrête leur progression. Nous avons fait cela pour mettre en pause les sons. Comme ce n'est pas correct, nous n'utiliserons désormais que trois nœuds de gain au total, comme décrit ci-dessus. De nombreux extraits suivants incluront nos nœuds incorrects, montrant ce que nous avons fait et comment nous allons les corriger à court terme. Toutefois, à long terme, vous ne devez pas utiliser nos nœuds après le 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 vous suffit de consulter 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, ce qui permet essentiellement de contrôler le volume. Comme nous souhaitons 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, où nous pouvons 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 fonctionnalité pour contrôler le volume de tout, des effets sonores et de la musique. Le réglage du gain du nœud maître affecte 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 disposent également d'un paramètre de gain. Vous pouvez suivre la liste de tous les sons en cours de lecture et ajuster individuellement leurs valeurs de gain pour le volume global. Si vous créiez des effets sonores avec des balises audio, vous devriez procéder ainsi. Au lieu de cela, le graphique de nœuds de Web Audio permet de modifier beaucoup plus facilement le volume sonore d'innombrables sons. Cette méthode vous permet également de contrôler le volume sans complication. Nous pourrions simplement associer un AudioBufferSourceNode directement au nœud maître 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 dans le but de lire de la musique. Vous ne modifiez un nœud que lorsqu'un lecteur modifie le volume de la musique et au lancement. Nous avons maintenant une valeur de gain sur les sources de tampon pour effectuer une autre action. Pour la musique, vous pouvez utiliser un fondu croisé entre deux pistes audio lorsque l'une se termine et que l'autre commence. Web Audio fournit une méthode pratique pour y parvenir.

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

Fieldrunners n'a pas utilisé de fondu croisé spécifique. Si nous avions connu la fonctionnalité de paramétrage des valeurs de WebAudio lors de notre première analyse du système audio, nous l'aurions probablement fait.

Mettre en pause les sons

Lorsqu'un joueur met un jeu en pause, certains sons peuvent continuer à être diffusés. Le son est un élément important du retour pour les pressions courantes sur les éléments de l'interface utilisateur dans les menus de jeu. Comme Fieldrunners propose plusieurs interfaces avec lesquelles l'utilisateur peut interagir lorsque le jeu est mis en pause, nous souhaitons que les joueurs continuent à jouer. Cependant, nous ne voulons pas que des sons longs ou en boucle continuent de jouer. Il est assez facile d'arrêter ces sons avec Web Audio, ou du moins nous le pensions.

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

Le nœud d'effets mis en veille est toujours connecté. Tous les sons autorisés à ignorer l'état "mis en veille" du jeu continueront de s'y lire. Lorsque le jeu reprend, nous pouvons reconnecter ces nœuds et rétablir instantanément tous les sons.

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

Après avoir publié Fieldrunners, nous avons découvert que la déconnexion d'un nœud ou d'un sous-graphique ne mettait pas en pause la lecture des AudioBufferSourceNodes. Nous avons exploité un bug de 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 cette future correction, nous avons besoin d'un code semblable au suivant:

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 abusions d'un bug, la structure de notre code audio aurait été très différente. Par conséquent, un certain nombre de sections de cet article ont été affectées. Cela a un effet direct ici, mais aussi dans nos extraits de code dans "Perdre la concentration" et "Donnez-moi un rythme". Pour comprendre comment cela fonctionne, vous devez modifier à la fois le graphique des 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 mise en pause que Web Audio ne fait pas tout seul.

Perte de concentration

Notre nœud maître intervient pour cette fonctionnalité. Lorsqu'un utilisateur de navigateur passe à un autre onglet, le jeu n'est plus visible. "Hors de vue, hors de l'esprit", et le son devrait disparaître. Il existe des astuces permettant de déterminer des états de visibilité spécifiques pour la page d'un jeu, mais cela est devenu beaucoup plus facile avec l'API Visibility.

Fieldrunners ne s'exécute que dans l'onglet actif grâce à l'utilisation de requestAnimationFrame pour appeler sa boucle de mise à jour. Toutefois, le contexte Web Audio continue de lire les effets en boucle et les pistes de fond lorsque l'utilisateur se trouve dans un autre onglet. Mais nous pouvons y remédier avec un très 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 d'écrire cet article, nous pensions qu'il suffisait de déconnecter le maître pour suspendre tous les sons au lieu de les couper. En le déconnectant à ce moment-là, nous avons empêché le nœud et ses enfants de traiter et de lire les données. Lorsque la connexion était rétablie, tous les sons et la musique recommençaient à être diffusés là où ils s'étaient arrêtés, tout comme le jeu continuait là où il s'était arrêté. Ce comportement est inattendu. Il ne suffit pas de se déconnecter pour arrêter la lecture.

L'API Page Visibility vous 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 vous suffit de quelques lignes pour ajouter la mise en pause des sons lorsque l'onglet "Jeux" est masqué.

Give Me a Beat

Nous avons déjà configuré quelques éléments. 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 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, comme la mort d'un personnage, Fieldrunners ne lit qu'un seul son une seule fois pendant toute 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 prise pour la conception audio de Fieldrunners, car des sons sont demandés à être lus rapidement, ce qui entraînerait des à-coups s'ils étaient autorisés à redémarrer ou une cacophonie désagréable s'ils étaient autorisés à lire plusieurs instances. Les AudioBufferSourceNodes doivent être utilisés comme des one-shots. 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 appelez é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 );
  }
}

Streaming trop important

Fieldrunners a été lancé à l'origine avec une musique de fond diffusée à l'aide d'une balise audio. Lors de la sortie, nous avons découvert que les fichiers musicaux étaient demandés un nombre disproportionné de fois 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 segments de fichiers musicaux en streaming. Le navigateur demandait donc le titre en cours de lecture toutes les quelques minutes à la fin. Lors de tests plus récents, Chrome a mis en cache les titres diffusés en streaming, mais il est possible que d'autres navigateurs ne le fassent pas encore. Le streaming de fichiers audio volumineux avec la balise Audio pour des fonctionnalités telles que la lecture de musique est optimal, mais pour certaines versions de navigateur, vous pouvez charger votre musique de la même manière que les effets sonores.

Étant donné que tous les effets sonores étaient lus via Web Audio, nous avons également transféré la lecture de la musique de fond vers Web Audio. Cela signifie que nous chargeons les pistes de la même manière que nous chargeons 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é

C'était un plaisir de porter Fieldrunners sur Chrome et HTML5. En dehors de la montagne de travail que représente l'intégration de milliers de lignes de code C++ dans JavaScript, des dilemmes et des décisions intéressants spécifiques au HTML5 se posent. Pour rappel, les AudioBufferSourceNodes sont des objets à usage unique. Créez-les, joignez un tampon audio, associez-le au graphique Web Audio, puis jouez avec noteOn ou noteGrainOn. Vous voulez réécouter ce son ? Créez ensuite un autre AudioBufferSourceNode.