Fallstudie - Eine Geschichte über ein HTML5-Spiel mit Web-Audio

Fieldrunners

Screenshot von Fieldrunners
Screenshot von Fieldrunners

Fieldrunners ist ein preisgekröntes Tower-Defense-Spiel, das 2008 ursprünglich für das iPhone veröffentlicht wurde. Seitdem wurde es auf viele andere Plattformen portiert. Eine der jüngsten Plattformen war der Chrome-Browser im Oktober 2011. Eine der Herausforderungen bei der Portierung von Fieldrunners auf eine HTML5-Plattform bestand darin, den Ton abzuspielen.

In Fieldrunners werden keine komplizierten Soundeffekte verwendet, aber es gibt einige Erwartungen, wie mit den Soundeffekten interagiert werden kann. Das Spiel hat 88 Soundeffekte, von denen eine große Anzahl gleichzeitig abgespielt werden kann. Die meisten dieser Töne sind sehr kurz und müssen so zeitgenau wie möglich abgespielt werden, um eine Diskrepanz zwischen der grafischen Darstellung zu vermeiden.

Einige Herausforderungen sind aufgetreten

Beim Portieren von Fieldrunners nach HTML5 sind wir auf Probleme mit der Audiowiedergabe mit dem Audio-Tag gestoßen. Deshalb haben wir uns schon früh entschieden, uns stattdessen auf die Web Audio API zu konzentrieren. Mit WebAudio konnten wir Probleme lösen, z. B. die Wiedergabe der großen Anzahl gleichzeitiger Effekte, die für Fieldrunners erforderlich sind. Bei der Entwicklung eines Audiosystems für Fieldrunners HTML5 sind wir jedoch auf einige Probleme gestoßen, die andere Entwickler kennen sollten.

Art von AudioBufferSourceNodes

AudioBufferSourceNodes sind die primäre Methode zum Abspielen von Audioinhalten mit WebAudio. Es ist sehr wichtig zu wissen, dass sie nur einmal verwendet werden können. Sie erstellen einen AudioBufferSourceNode, weisen ihm einen Buffer zu, verbinden ihn mit dem Graphen und spielen ihn mit „noteOn“ oder „noteGrainOn“ ab. Danach kannst du „noteOff“ aufrufen, um die Wiedergabe zu beenden. Du kannst die Quelle aber nicht noch einmal wiedergeben, indem du „noteOn“ oder „noteGrainOn“ aufrufst. Du musst einen weiteren AudioBufferSourceNode erstellen. Das zugrunde liegende AudioBuffer-Objekt kann jedoch wiederverwendet werden. Es ist sogar möglich, mehrere aktive AudioBufferSourceNodes zu haben, die auf dieselbe AudioBuffer-Instanz verweisen. Ein Wiedergabe-Snippet aus Fieldrunners findest du unter „Give Me a Beat“.

Nicht im Cache gespeicherte Inhalte

Bei der Veröffentlichung des Fieldrunners-HTML5-Servers gab es eine enorme Anzahl von Anfragen für Musikdateien. Dieses Ergebnis wurde dadurch verursacht, dass Chrome 15 die Datei in Teilen heruntergeladen und dann nicht im Cache gespeichert hat. Wir haben uns damals dazu entschieden, Musikdateien wie alle anderen Audiodateien hochzuladen. Das ist nicht optimal, aber einige Versionen anderer Browser tun dies immer noch.

Stummschalten, wenn das Motiv unscharf ist

Bisher war es schwierig zu erkennen, wenn der Tab des Spiels nicht im Fokus ist. Fieldrunners wurde vor Chrome 13 portiert, als die Page Visibility API unseren verwickelten Code zum Erkennen der Tabunkenntlichmachung ersetzte. Jedes Spiel sollte die Visibility API verwenden, um ein kleines Snippet zu schreiben, mit dem der Ton stummgeschaltet oder pausiert werden kann, wenn nicht das gesamte Spiel pausiert werden soll. Da in Fieldrunners die requestAnimationFrame API verwendet wurde, wurde die Spielpause implizit verarbeitet, die Tonpause jedoch nicht.

Töne pausieren

Wir haben Feedback zu diesem Artikel erhalten und wurden darauf hingewiesen, dass die von uns verwendete Methode zum Pausieren von Tönen nicht geeignet ist. Wir haben einen Fehler in der aktuellen Implementierung von Web Audio genutzt, um die Wiedergabe von Tönen zu pausieren. Da dieses Problem in Zukunft behoben wird, können Sie den Ton nicht mehr einfach pausieren, indem Sie einen Knoten oder einen untergeordneten Graphen trennen, um die Wiedergabe anzuhalten.

Eine einfache Web Audio Node-Architektur

Fieldrunners hat ein sehr einfaches Audiomodell. Dieses Modell unterstützt die folgenden Funktionen:

  • Lautstärke der Soundeffekte regeln
  • Lautstärke des Hintergrundmusik-Tracks regeln
  • Schalten Sie alle Audioinhalte stumm.
  • Deaktivieren Sie die Wiedergabe von Tönen, wenn das Spiel pausiert ist.
  • Aktivieren Sie diese Geräusche wieder, wenn das Spiel fortgesetzt wird.
  • Deaktivieren Sie alle Audioinhalte, wenn der Tab des Spiels nicht mehr aktiv ist.
  • Starten Sie die Wiedergabe nach Bedarf neu, nachdem ein Ton abgespielt wurde.

Um die oben genannten Funktionen mit Web Audio zu erreichen, wurden drei der verfügbaren Knoten verwendet: DestinationNode, GainNode und AudioBufferSourceNode. Die AudioBufferSourceNodes geben die Töne wieder. Die GainNodes verbinden die AudioBufferSourceNodes miteinander. Der vom Web Audio-Kontext erstellte DestinationNode, genannt „destination“, spielt Töne für den Player ab. Web Audio bietet viele weitere Arten von Knoten, aber mit diesen können wir bereits ein sehr einfaches Diagramm für Töne in einem Spiel erstellen.

Knotendiagramm

Ein Web Audio-Knotengraph führt von den Blättern zum Zielknoten. Fieldrunners verwendete sechs permanente Verstärkungsknoten, aber drei reichen aus, um die Lautstärke einfach zu regeln und eine größere Anzahl von temporären Knoten zu verbinden, die Buffers wiedergeben. Zuerst wird ein Master-Gain-Knoten erstellt, der alle untergeordneten Knoten mit dem Ziel verbindet. Direkt am Master-Gain-Knoten sind zwei Gain-Knoten angeschlossen, einer für einen Musikkanal und einer, um alle Soundeffekte zu verknüpfen.

Fieldrunners hatte drei zusätzliche Gewinnknoten, da ein Fehler fälschlicherweise als Funktion verwendet wurde. Mit diesen Knoten haben wir Gruppen von abgespielten Tönen aus dem Diagramm herausgeschnitten, wodurch ihr Fortschritt gestoppt wird. Das war notwendig, um Töne pausieren zu können. Da dies nicht korrekt ist, würden wir jetzt nur noch drei Gain-Knoten verwenden, wie oben beschrieben. Viele der folgenden Snippets enthalten unsere fehlerhaften Knoten. Sie zeigen, was wir getan haben und wie wir das Problem kurzfristig beheben würden. Langfristig solltest du aber keine Knoten nach dem coreEffectsGain-Knoten verwenden.

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

In den meisten Spielen können die Soundeffekte und die Musik getrennt voneinander gesteuert werden. Das ist mit der obigen Grafik ganz einfach möglich. Jeder Verstärkungsknoten hat ein Attribut „gain“, das auf einen beliebigen Dezimalwert zwischen 0 und 1 festgelegt werden kann. Damit lässt sich die Lautstärke steuern. Da wir die Lautstärke der Musik- und der Soundeffektkanäle separat steuern möchten, haben wir für jeden einen Gain-Knoten, über den wir die Lautstärke regeln können.

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

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

Mit dieser Funktion können wir die Lautstärke von allem steuern, von Soundeffekten bis hin zu Musik. Die Einstellung der Verstärkung des Masterknotens wirkt sich auf den gesamten Ton des Spiels aus. Wenn Sie den Wert für die Verstärkung auf „0“ setzen, werden Ton und Musik stummgeschaltet. AudioBufferSourceNodes haben auch einen Verstärkungsparameter. Sie können eine Liste aller wiedergegebenen Töne erfassen und die Verstärkungswerte für die Gesamtlautstärke individuell anpassen. Wenn Sie mit Audio-Tags Soundeffekte erstellen, müssten Sie das tun. Mit dem Knotendiagramm von Web Audio können Sie die Lautstärke unzähliger Töne jedoch viel einfacher ändern. Außerdem erhalten Sie so mehr Leistung, ohne dass es zu Komplikationen kommt. Wir könnten einfach einen AudioBufferSourceNode direkt an den Master-Knoten anhängen, um Musik abzuspielen und die eigene Verstärkung zu steuern. Sie müssen diesen Wert jedoch jedes Mal festlegen, wenn Sie einen AudioBufferSourceNode zum Abspielen von Musik erstellen. Stattdessen ändern Sie nur einen Knoten, wenn ein Nutzer die Musiklautstärke ändert und beim Starten. Jetzt haben wir einen Gewinnwert für Pufferquellen, mit dem wir etwas anderes tun können. Bei Musik kann ein Cross-Fade von einer Audiospur zu einer anderen erstellt werden, wenn eine Spur aus- und eine andere einblendet wird. Web Audio bietet eine gute Methode, dies ganz einfach zu tun.

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

In Fieldrunners wurde kein Crossfading verwendet. Wären wir bei der ursprünglichen Prüfung des Audiosystems von der Funktion zur Festlegung von Werten in WebAudio gewusst, hätten wir sie wahrscheinlich verwendet.

Geräusche pausieren

Wenn ein Spieler ein Spiel pausiert, werden einige Töne möglicherweise weiterhin wiedergegeben. Töne sind ein wichtiger Teil des Feedbacks für das häufige Drücken von Benutzeroberflächenelementen in Spielmenüs. Da Fieldrunners eine Reihe von Oberflächen hat, mit denen Nutzer auch während der Pause interagieren können, möchten wir, dass sie weiterspielen. Lange oder sich wiederholende Töne sollten jedoch nicht abgespielt werden. Es ist ziemlich einfach, diese Töne mit Web Audio zu stoppen, zumindest dachten wir das.

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

Der pausierte Effektknoten ist weiterhin verbunden. Alle Töne, die den pausierten Status des Spiels ignorieren dürfen, werden weiter abgespielt. Wenn das Spiel fortgesetzt wird, können wir diese Knoten wieder verbinden und der gesamte Ton wird sofort wiedergegeben.

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

Nach der Veröffentlichung von Fieldrunners haben wir festgestellt, dass die Wiedergabe der AudioBufferSourceNodes nicht durch das Trennen eines Knotens oder Untergraphen pausiert wird. Wir haben einen Fehler in WebAudio ausgenutzt, durch den derzeit die Wiedergabe von Knoten angehalten wird, die nicht mit dem Zielknoten im Graphen verbunden sind. Damit wir für diese zukünftige Fehlerbehebung gerüstet sind, benötigen wir Code wie den folgenden:

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

Wenn wir das früher gewusst hätten, dass wir einen Fehler ausnutzen, wäre die Struktur unseres Audiocodes ganz anders. Dies hat sich auf einige Abschnitte dieses Artikels ausgewirkt. Das hat hier einen direkten Effekt, aber auch in unseren Code-Snippets in „Losing Focus“ und „Give Me a Beat“. Um zu verstehen, wie das funktioniert, sind Änderungen sowohl am Knotengraphen von Fieldrunners (da wir Knoten zum Unterbrechen der Wiedergabe erstellt haben) als auch am zusätzlichen Code erforderlich, der die pausierten Status aufzeichnet und bereitstellt, was Web Audio nicht von selbst tut.

Konzentrationsverlust

Für diese Funktion kommt unser Masterknoten zum Einsatz. Wenn ein Browsernutzer zu einem anderen Tab wechselt, ist das Spiel nicht mehr sichtbar. Aus den Augen, aus dem Sinn – und so sollte es auch mit dem Geräusch sein. Es gibt Tricks, mit denen sich bestimmte Sichtbarkeitsstatus für die Seite eines Spiels bestimmen lassen. Mit der Visibility API ist das jedoch viel einfacher geworden.

Fieldrunners wird nur auf dem aktiven Tab wiedergegeben, da für den Aufruf der Update-Schleife requestAnimationFrame verwendet wird. Im Web Audio-Kontext werden jedoch weiterhin Loop-Effekte und Hintergrundtracks abgespielt, während sich ein Nutzer auf einem anderen Tab befindet. Mit einem sehr kleinen Snippet, das die Visibility API nutzt, können wir das jedoch verhindern.

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

Bevor wir diesen Artikel geschrieben haben, dachten wir, dass das Trennen der Verbindung zum Master ausreichen würde, um den gesamten Ton anzuhalten, anstatt ihn stummzuschalten. Durch die Trennung des Knotens haben wir die Verarbeitung und Wiedergabe für ihn und seine untergeordneten Knoten verhindert. Wenn die Verbindung wiederhergestellt wurde, werden alle Töne und Musik an der Stelle fortgesetzt, an der sie unterbrochen wurden, genau wie das Spiel an der Stelle fortgesetzt wird, an der es unterbrochen wurde. Das ist aber unerwartet. Es reicht nicht aus, die Verbindung zu trennen, um die Wiedergabe zu beenden.

Mit der Page Visibility API können Sie ganz einfach feststellen, wann Ihr Tab nicht mehr im Fokus ist. Wenn Sie bereits Code zum Pausieren von Tönen haben, können Sie die Pausierung der Töne auch ein paar Zeilen lang einfügen, wenn der Tab „Spiele“ ausgeblendet ist.

Give Me a Beat

Wir haben jetzt einige Dinge eingerichtet. Wir haben ein Knotendiagramm. Wir können Töne pausieren, wenn der Spieler das Spiel pausiert, und neue Töne für Elemente wie Spielmenüs abspielen. Wir können alle Töne und Musik pausieren, wenn der Nutzer zu einem neuen Tab wechselt. Jetzt müssen wir einen Ton abspielen.

Anstatt mehrere Kopien des Tons für mehrere Instanzen einer Spielentität wie das Sterben eines Charakters abzuspielen, wird in Fieldrunners ein Ton nur einmal für seine Dauer abgespielt. Wenn der Ton nach der Wiedergabe benötigt wird, kann er neu gestartet werden, aber nicht während der Wiedergabe. Diese Entscheidung ist für das Audiodesign von Fieldrunners wichtig, da es Töne gibt, die schnell abgespielt werden sollen. Diese würden sonst stottern, wenn sie neu gestartet werden könnten, oder eine unangenehme Kakophonie erzeugen, wenn mehrere Instanzen abgespielt werden könnten. AudioBufferSourceNodes werden in der Regel als One-Shots verwendet. Erstelle einen Knoten, füge einen Puffer hinzu, setze bei Bedarf den booleschen Wert für die Schleife, verbinde ihn mit einem Knoten im Graphen, der zum Ziel führt, rufe „noteOn“ oder „noteGrainOn“ auf und rufe optional „noteOff“ auf.

Für Fieldrunners sieht das ungefähr so aus:

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

Zu viel Streaming

Fieldrunners wurde ursprünglich mit Hintergrundmusik veröffentlicht, die über ein Audio-Tag abgespielt wurde. Bei der Veröffentlichung haben wir festgestellt, dass Musikdateien unverhältnismäßig häufiger angefordert wurden als der Rest der Spielinhalte. Nach einigen Recherchen haben wir festgestellt, dass der Chrome-Browser zu diesem Zeitpunkt die gestreamten Chunks der Musikdateien nicht im Cache gespeichert hat. Dadurch wurde der gerade abgespielte Titel alle paar Minuten vom Browser angefordert. Bei neueren Tests hat Chrome gestreamte Titel im Cache gespeichert. Andere Browser tun dies möglicherweise noch nicht. Das Streamen großer Audiodateien mit dem Audio-Tag für Funktionen wie die Musikwiedergabe ist optimal. Bei einigen Browserversionen sollten Sie Ihre Musik jedoch auf die gleiche Weise laden wie Soundeffekte.

Da alle Soundeffekte über Web Audio wiedergegeben wurden, haben wir auch die Hintergrundmusik auf Web Audio umgestellt. Das bedeutete, dass wir die Titel auf die gleiche Weise wie alle Effekte mit XMLHttpRequests und dem Arraybuffer-Antworttyp geladen haben.

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

Zusammenfassung

Es hat Spaß gemacht, Fieldrunners für Chrome und HTML5 zu entwickeln. Abgesehen von der Arbeit, die es erfordert, Tausende von C++-Zeilen in JavaScript zu übertragen, gibt es einige interessante Dilemmata und Entscheidungen, die speziell für HTML5 gelten. Zur Wiederholung: AudioBufferSourceNodes sind Einwegobjekte. Erstellen Sie sie, hängen Sie einen Audio-Puffer an, verbinden Sie ihn mit dem Web Audio-Diagramm und spielen Sie mit „noteOn“ oder „noteGrainOn“. Möchten Sie den Ton noch einmal abspielen? Erstellen Sie dann einen weiteren AudioBufferSourceNode.