Fallstudie – 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 neuesten Plattformen war der Chrome-Browser im Oktober 2011. Eine der Herausforderungen bei der Portierung von Fieldrunners auf eine HTML5-Plattform war die Audiowiedergabe.

Fieldrunners verwendet keine komplizierten Soundeffekte, aber es gibt einige Erwartungen, wie mit den Soundeffekten interagiert werden kann. Das Spiel hat 88 Soundeffekte, von denen viele gleichzeitig abgespielt werden können. Die meisten dieser Sounds sind sehr kurz und müssen so schnell wie möglich abgespielt werden, um eine Diskrepanz zur grafischen Darstellung zu vermeiden.

Es sind einige Probleme aufgetreten

Bei der Portierung von Fieldrunners auf HTML5 sind Probleme mit der Audiowiedergabe mit dem Audio-Tag aufgetreten. Wir haben uns daher frühzeitig entschieden, uns stattdessen auf die Web Audio API zu konzentrieren. Mit WebAudio konnten wir Probleme wie die hohe Anzahl gleichzeitiger Effekte lösen, 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 möglicherweise kennen sollten.

Art der AudioBufferSourceNodes

AudioBufferSourceNodes sind die primäre Methode, um Sounds mit WebAudio abzuspielen. Es ist sehr wichtig zu wissen, dass sie nur einmal verwendet werden können. Sie erstellen einen AudioBufferSourceNode, weisen ihm einen Zwischenspeicher zu, verbinden ihn mit dem Graphen und spielen ihn mit noteOn oder noteGrainOn ab. Danach können Sie „noteOff“ aufrufen, um die Wiedergabe zu beenden. Sie können die Quelle jedoch nicht noch einmal abspielen, indem Sie „noteOn“ oder „noteGrainOn“ aufrufen. Sie müssen einen weiteren AudioBufferSourceNode erstellen. Sie können jedoch dasselbe zugrunde liegende AudioBuffer-Objekt wiederverwenden. Das ist ein wichtiger Punkt. Tatsächlich können Sie sogar mehrere aktive AudioBufferSourceNodes haben, die auf dieselbe AudioBuffer-Instanz verweisen. In „Give Me a Beat“ findest du ein Wiedergabe-Snippet aus Fieldrunners.

Nicht im Cache gespeicherte Inhalte

Bei der Veröffentlichung des HTML5-Servers von Fieldrunners gab es eine enorme Anzahl von Anfragen für Musikdateien. Dieses Ergebnis ist darauf zurückzuführen, dass Chrome 15 die Datei in Blöcken heruntergeladen und dann nicht im Cache gespeichert hat. Als Reaktion darauf haben wir uns damals entschieden, Musikdateien wie die übrigen Audiodateien zu laden. Das ist zwar nicht optimal, wird aber von einigen Versionen anderer Browser immer noch so gehandhabt.

Stummschalten, wenn nicht im Fokus

Bisher war es schwierig, zu erkennen, wenn der Tab Ihres Spiels nicht mehr im Fokus war. Fieldrunners wurde vor Chrome 13 portiert, als die Page Visibility API den Bedarf an unserem komplizierten Code zur Erkennung von Tab-Unschärfe ersetzte. In jedem Spiel sollte die Visibility API verwendet werden, um ein kleines Snippet zu schreiben, mit dem der Ton stummgeschaltet oder pausiert wird, wenn nicht das gesamte Spiel pausiert wird. Da Fieldrunners die requestAnimationFrame API verwendet, wurde das Pausieren des Spiels implizit gehandhabt, nicht jedoch das Pausieren des Tons.

Geräusche werden pausiert

Als wir Feedback zu diesem Artikel erhielten, wurden wir darauf hingewiesen, dass die von uns verwendete Technik zum Pausieren von Sounds nicht geeignet war. Wir hatten einen Fehler in der aktuellen Implementierung von Web Audio genutzt, um die Wiedergabe von Sounds zu pausieren. Da dies in Zukunft behoben wird, können Sie den Ton nicht einfach pausieren, indem Sie einen Knoten oder Untergraphen trennen, um die Wiedergabe anzuhalten.

Eine einfache Web Audio-Knotenarchitektur

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

  • Lautstärke von Soundeffekten regeln
  • Lautstärke des Hintergrundmusiktracks regeln
  • Alle Audioinhalte stummschalten.
  • Deaktivieren Sie die Wiedergabe von Tönen, wenn das Spiel pausiert ist.
  • Diese Sounds werden wieder aktiviert, wenn das Spiel fortgesetzt wird.
  • Alle Audioinhalte werden deaktiviert, wenn der Tab des Spiels nicht mehr im Fokus 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 bereitgestellten möglichen Knoten verwendet: DestinationNode, GainNode, AudioBufferSourceNode. Die AudioBufferSourceNodes spielen die Töne ab. Die GainNodes verbinden die AudioBufferSourceNodes miteinander. Der DestinationNode, der vom Web Audio-Kontext erstellt wird und „destination“ heißt, gibt Töne für den Spieler wieder. Web Audio bietet viele weitere Knotentypen, aber mit diesen können wir ein sehr einfaches Diagramm für Sounds in einem Spiel erstellen.

Knotendiagramm

Ein Web Audio-Knotendiagramm führt von den Blattknoten zum Zielknoten. In Fieldrunners wurden 6 permanente Gain-Knoten verwendet, aber 3 reichen aus, um die Lautstärke einfach zu steuern und eine größere Anzahl temporärer Knoten zu verbinden, die Puffer wiedergeben. Zuerst ein Master-Gain-Knoten, der jeden untergeordneten Knoten mit dem Ziel verbindet. Direkt mit dem Master-Gain-Knoten sind zwei Gain-Knoten verbunden, einer für einen Musikkanal und einer für alle Soundeffekte.

Fieldrunners hatte 3 zusätzliche Gain-Knoten, weil ein Fehler fälschlicherweise als Funktion verwendet wurde. Wir haben diese Knoten verwendet, um Gruppen von wiedergegebenen Sounds aus dem Graphen auszuschneiden, wodurch ihre Wiedergabe gestoppt wird. Wir haben das getan, um Geräusche zu pausieren. Da das nicht korrekt ist, würden wir jetzt nur 3 Knoten für den Gesamtgewinn verwenden, wie oben beschrieben. Viele der folgenden Snippets enthalten unsere fehlerhaften Knoten, zeigen, was wir getan haben, und wie wir das kurzfristig beheben würden. Langfristig sollten Sie jedoch nach dem Knoten „coreEffectsGain“ keine Knoten von uns 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 Soundeffekte und Musik separat gesteuert werden. Das lässt sich mit dem obigen Diagramm ganz einfach bewerkstelligen. Jeder Gain-Knoten hat ein „gain“-Attribut, das auf einen beliebigen Dezimalwert zwischen 0 und 1 festgelegt werden kann. Damit lässt sich im Wesentlichen die Lautstärke steuern. Da wir die Lautstärke der Musik- und Soundeffektkanäle separat steuern möchten, haben wir für jeden einen Gain-Knoten, über den wir die Lautstärke steuern können.

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

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

Diese Funktion können wir auch verwenden, um die Lautstärke von allem zu steuern, von Soundeffekten und Musik. Wenn du die Verstärkung des Master-Knotens festlegst, wirkt sich das auf alle Sounds im Spiel aus. Wenn Sie den Verstärkungswert auf 0 setzen, werden Ton und Musik stummgeschaltet. AudioBufferSourceNodes haben ebenfalls einen Verstärkungsparameter. Sie können eine Liste aller wiedergegebenen Töne verfolgen und ihre Verstärkungswerte einzeln anpassen, um die Gesamtlautstärke zu ändern. Wenn Sie Soundeffekte mit Audio-Tags erstellen würden, müssten Sie so vorgehen. Mit dem Knotendiagramm von Web Audio ist es jedoch viel einfacher, die Lautstärke unzähliger Sounds zu ändern. Wenn du die Lautstärke auf diese Weise steuerst, hast du außerdem mehr Kontrolle, ohne dass es kompliziert wird. Wir könnten einfach eine AudioBufferSourceNode direkt an den Master-Knoten anhängen, um Musik abzuspielen und die eigene Verstärkung zu steuern. Sie müssten diesen Wert jedoch jedes Mal festlegen, wenn Sie einen AudioBufferSourceNode zum Abspielen von Musik erstellen. Stattdessen ändern Sie nur einen Knoten, wenn ein Spieler die Musiklautstärke ändert und beim Start. Jetzt haben wir einen Verstärkungswert für Pufferquellen, um etwas anderes zu tun. Bei Musik kann sie beispielsweise verwendet werden, um eine Überblendung von einem Audiotrack zu einem anderen zu erstellen, wenn ein Track endet und ein anderer beginnt. Web Audio bietet eine gute Methode, um dies einfach zu tun.

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

Fieldrunners hat keine spezifische Überblendung verwendet. Hätten wir die Funktion zur Werteinstellung von WebAudio bei der ursprünglichen Entwicklung des Soundsystems gekannt, hätten wir sie wahrscheinlich verwendet.

Geräusche werden pausiert

Wenn ein Spieler ein Spiel pausiert, werden möglicherweise weiterhin einige Töne wiedergegeben. Sound ist ein wichtiger Bestandteil des Feedbacks beim Drücken von Elementen der Benutzeroberfläche in Spielmenüs. Fieldrunners hat eine Reihe von Oberflächen, mit denen der Nutzer interagieren kann, während das Spiel pausiert ist. Diese sollen weiterhin abgespielt werden. Wir möchten jedoch nicht, dass lange oder sich wiederholende Töne weiter abgespielt werden. Es ist ziemlich einfach, diese Töne mit Web Audio zu stoppen, oder zumindest dachten wir das.

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

Der pausierte Effektknoten ist weiterhin verbunden. Alle Sounds, die den pausierten Status des Spiels ignorieren dürfen, werden weiterhin abgespielt. Wenn das Spiel fortgesetzt wird, können wir diese Knoten wieder verbinden und alle Sounds sofort wieder abspielen.

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

Nach der Veröffentlichung von Fieldrunners haben wir festgestellt, dass das Trennen eines Knotens oder Untergraphen allein die Wiedergabe der AudioBufferSourceNodes nicht pausiert. Wir haben einen Fehler in WebAudio ausgenutzt, der derzeit die Wiedergabe von Knoten stoppt, die nicht mit dem Zielknoten im Diagramm verbunden sind. Damit wir für diese zukünftige Korrektur bereit 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 früher gewusst hätten, dass wir einen Fehler ausnutzen, wäre die Struktur unseres Audiocodes ganz anders gewesen. Dies hat sich auf mehrere Abschnitte dieses Artikels ausgewirkt. Das hat hier eine direkte Auswirkung, aber auch in unseren Code-Snippets in „Losing Focus“ und „Give Me a Beat“. Um zu verstehen, wie das tatsächlich funktioniert, sind Änderungen sowohl im Fieldrunners-Knotendiagramm (da wir Knoten zum Abbrechen der Wiedergabe erstellt haben) als auch im zusätzlichen Code erforderlich, der die pausierten Zustände aufzeichnet und bereitstellt, was Web Audio nicht von sich aus tut.

Konzentrationsverlust

Für diese Funktion kommt unser Masterknoten ins Spiel. Wenn ein Browsernutzer zu einem anderen Tab wechselt, ist das Spiel nicht mehr sichtbar. Aus den Augen, aus dem Sinn – und auch der Ton sollte weg sein. Es gibt Tricks, mit denen sich bestimmte Sichtbarkeitsstatus für die Seite eines Spiels ermitteln lassen, aber mit der Visibility API ist das viel einfacher geworden.

Fieldrunners wird nur als aktiver Tab wiedergegeben, da requestAnimationFrame zum Aufrufen der Aktualisierungsschleife verwendet wird. Der Web Audio-Kontext spielt jedoch weiterhin wiederholte Effekte und Hintergrundtracks ab, während sich ein Nutzer in einem anderen Tab befindet. Das können wir aber mit einem sehr kleinen Snippet verhindern, das die Visibility API nutzt.

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 es ausreicht, den Master zu trennen, um den gesamten Ton zu pausieren, anstatt ihn stummzuschalten. Durch das Trennen des Knotens haben wir ihn und seine untergeordneten Elemente daran gehindert, zu verarbeiten und abzuspielen. Nach der Wiederherstellung der Verbindung wurden alle Töne und die Musik an der Stelle fortgesetzt, an der sie unterbrochen wurden, und auch das Spiel wurde an der Stelle fortgesetzt, an der es unterbrochen wurde. Das ist jedoch ein unerwartetes Verhalten. Es reicht nicht aus, nur die Verbindung zu trennen, um die Wiedergabe zu beenden.

Mit der Page Visibility API lässt sich ganz einfach feststellen, ob der Tab nicht mehr im Fokus ist. Wenn Sie bereits effektiven Code zum Pausieren von Sounds haben, sind nur wenige Zeilen erforderlich, um das Pausieren von Sounds zu implementieren, wenn der Tab mit dem Spiel ausgeblendet ist.

Ich möchte Musik hören

Wir haben jetzt ein paar Dinge eingerichtet. Wir haben einen Graphen mit Knoten. Wir können Sounds pausieren, wenn der Spieler das Spiel pausiert, und neue Sounds 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 Sounds für mehrere Instanzen einer Spieleinheit abzuspielen, z. B. wenn ein Charakter stirbt, wird in Fieldrunners ein Sound nur einmal für die Dauer des Ereignisses abgespielt. Wenn der Ton nach dem Abspielen noch einmal benötigt wird, kann er neu gestartet werden, aber nicht, während er bereits abgespielt wird. Dies ist eine Entscheidung für das Audiodesign von Fieldrunners, da es Sounds enthält, die schnell abgespielt werden sollen. Diese würden sonst stottern, wenn sie neu gestartet werden, oder eine unangenehme Kakofonie erzeugen, wenn mehrere Instanzen abgespielt werden. AudioBufferSourceNodes werden voraussichtlich als One-Shots verwendet. Erstellen Sie einen Knoten, hängen Sie einen Zwischenspeicher an, legen Sie bei Bedarf einen booleschen Wert für die Schleife fest, stellen Sie eine Verbindung zu einem Knoten im Diagramm her, der zum Ziel führt, rufen Sie „noteOn“ oder „noteGrainOn“ auf und rufen Sie optional „noteOff“ auf.

Für Fieldrunners sieht das in etwa 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 mit einem Audio-Tag abgespielt wurde. Bei der Veröffentlichung haben wir festgestellt, dass Musikdateien im Vergleich zu den restlichen Spielinhalten unverhältnismäßig oft angefordert wurden. Nach einigen Recherchen haben wir festgestellt, dass der Chrome-Browser die gestreamten Teile der Musikdateien zu diesem Zeitpunkt nicht im Cache gespeichert hat. Das führte dazu, dass der Browser den abgespielten Titel alle paar Minuten anforderte, wenn er zu Ende war. In neueren Tests hat Chrome gestreamte Titel im Cache gespeichert, andere Browser tun dies jedoch möglicherweise noch nicht. Das Streamen großer Audiodateien mit dem Audio-Tag für Funktionen wie die Musikwiedergabe ist optimal. Bei einigen Browserversionen kann es jedoch sinnvoll sein, Musik auf dieselbe Weise wie Soundeffekte zu laden.

Da alle Soundeffekte über Web Audio wiedergegeben wurden, haben wir auch die Wiedergabe der Hintergrundmusik auf Web Audio umgestellt. Das bedeutete, dass wir die Tracks genauso laden würden wie alle Effekte mit XMLHttpRequests und dem Antworttyp „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();
}

Zusammenfassung

Fieldrunners auf Chrome und HTML5 zu bringen, war ein Riesenspaß. Neben der enormen Arbeit, Tausende von C++-Zeilen in JavaScript zu übertragen, gab es auch einige interessante Dilemmas und Entscheidungen, die speziell mit HTML5 zu tun hatten. Noch einmal zur Erinnerung: AudioBufferSourceNodes sind Einmalobjekte. Erstelle sie, hänge einen Audio-Zwischenspeicher an, verbinde ihn mit dem Web Audio-Diagramm und spiele sie mit noteOn oder noteGrainOn ab. Möchtest du den Ton noch einmal abspielen? Erstellen Sie dann eine weitere AudioBufferSourceNode.