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

Fieldrunners

Fieldrunners-Screenshot
Fieldrunners-Screenshot

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 übertragen. 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 Tonwiedergabe.

Fieldrunners macht zwar keinen komplizierten Einsatz von Soundeffekten, weckt aber einige Erwartungen an die Interaktion mit den Soundeffekten. Das Spiel hat 88 Soundeffekte, von denen wahrscheinlich viele gleichzeitig laufen. Die meisten dieser Töne sind sehr kurz und müssen so zeitnah wie möglich abgespielt werden, um eine Unterbrechung der grafischen Darstellung zu vermeiden.

Herausforderungen, die sich stellten

Bei der Portierung von Fieldrunners zu HTML5 sind Probleme bei der Audiowiedergabe mit dem Audio-Tag aufgetreten. Daher haben wir uns frühzeitig dazu entschieden, uns stattdessen auf die Web Audio API zu konzentrieren. Mithilfe von WebAudio konnten wir Probleme lösen, wie z. B. die hohe Anzahl gleichzeitiger Effekte, die Fieldrunners erfordert. Bei der Entwicklung eines Audiosystems für Fieldrunners HTML5 sind wir dennoch mit einigen differenzierten Problemen konfrontiert, die andere Entwickler möglicherweise kennen sollten.

Art von AudioBufferSourceNodes

AudioBufferSourceNodes sind deine primäre Methode zum Abspielen von Klängen mit WebAudio. Es ist sehr wichtig zu verstehen, dass es sich um ein Einmalobjekt handelt. Sie erstellen einen AudioBufferSourceNode, weisen ihm einen Puffer zu, verbinden ihn mit der Grafik und spielen ihn mit noteOn oder noteGrainOn ab. Danach können Sie mit „noteOff“ die Wiedergabe 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 – und das ist entscheidend – dasselbe zugrunde liegende AudioBuffer-Objekt jedoch wiederverwenden. Sie können sogar mehrere aktive AudioBufferSourceNodes haben, die auf dieselbe AudioBuffer-Instanz verweisen. Ein Wiedergabe-Snippet von Fieldrunners findest du in Give Me a Beat.

Inhalte, die nicht im Cache gespeichert werden

Bei der Veröffentlichung zeigte der HTML5-Server von Fieldrunners sehr viele Anfragen für Musikdateien. Dies ergab, dass Chrome 15 die Datei in Teilen herunterlud und nicht im Cache speicherte. Als Reaktion darauf beschlossen wir, Musikdateien wie unsere restlichen Audiodateien zu laden. Das ist nicht optimal, aber einige Versionen anderer Browser tun das immer noch.

Stummschaltung bei Unscharf

Bisher war es schwierig, zu erkennen, wann der Tab deines Spiels unscharf ist. Fieldrunners hat vor Chrome 13 mit der Portierung begonnen. Ab diesem Zeitpunkt musste unser gefalteter Code durch die Page Membership API die Unkenntlichmachung von Tabs erkennen. Jedes Spiel sollte die Sichtbarkeit API verwenden, um einen kleinen Ausschnitt zum Stummschalten oder Pausieren des Tons zu schreiben, wenn nicht das gesamte Spiel pausiert wird. Da Fieldrunners die requestAnimationFrame API verwendet hat, wurde das Pausieren des Spiels implizit übernommen, nicht aber das Pausieren des Tons.

Töne pausieren

Seltsamerweise wurden wir beim Feedback zu diesem Artikel darüber informiert, dass die von uns zum Anhalten von Tönen verwendete Technik ungeeignet ist. Wir nutzten einen Fehler in der aktuellen Implementierung von Web Audio, um die Wiedergabe von Tönen anzuhalten. Da dies in Zukunft behoben werden wird, ist es nicht möglich, den Ton einfach durch Trennen eines Knotens oder einer Teilgrafik anzuhalten, um die Wiedergabe anzuhalten.

Einfache Web-Audio-Knotenarchitektur

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

  • Lautstärke von Soundeffekten regeln
  • Lautstärke des Hintergrundmusiktracks regeln
  • Alle Audioinhalte stummschalten.
  • Schaltet die Tonwiedergabe aus, wenn das Spiel pausiert wird.
  • Dieselben Töne wieder einschalten, wenn das Spiel fortgesetzt wird.
  • Den Ton ausschalten, wenn der Tab des Spiels den Fokus verliert.
  • Wiedergabe nach Bedarf neu starten, nachdem ein Ton abgespielt wurde

Um die oben genannten Funktionen mit Web Audio zu erreichen, wurden 3 der bereitgestellten möglichen Knoten verwendet: DestinationNode, GainNode und AudioBufferSourceNode. AudioBufferSourceNodes geben die Töne wieder. Die GainNodes verbinden die AudioBufferSourceNodes miteinander. Der DestinationNode, der vom Web Audio-Kontext erstellt und als Ziel bezeichnet wird, spielt Töne für den Player ab. Web Audio hat noch viele weitere Knotentypen, aber nur mit diesen können wir einen sehr einfachen Graphen für Töne in einem Spiel erstellen.

Knotendiagramm

Ein Web Audio-Knotendiagramm führt von den Blattknoten zum Zielknoten. Fieldrunners verwendete 6 permanente GainNodes, aber 3 sind ausreichend, um eine einfache Steuerung der Lautstärke zu ermöglichen und eine größere Anzahl temporärer Knoten anzuschließen, die Puffer wiedergeben. Zuerst einen Master-GainNode, der jeden untergeordneten Knoten an das Ziel anhängt. Direkt mit dem Master GainNode sind zwei GainNodes verbunden, einer für einen Musikkanal und ein weiterer zur Verknüpfung aller Soundeffekte.

Fieldrunners hatte 3 zusätzliche GainNodes aufgrund der falschen Nutzung eines Fehlers als Feature. Wir haben diese Knoten verwendet, um Gruppen von wiedergegebenen Tönen aus dem Diagramm abzugrenzen, wodurch ihr Fortschritt gestoppt wird. Wir haben dies getan, um den Ton zu pausieren. Da dies nicht korrekt ist, würden wir jetzt nur noch 3 GainNodes verwenden, wie oben beschrieben. Viele der folgenden Snippets enthalten unsere falschen Knoten, aus denen hervorgeht, was wir getan haben und wie wir es kurzfristig beheben würden. Langfristig sollten Sie unsere Knoten nach unserem CoreEffectsGain-Knoten jedoch nicht mehr 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 );
}

Bei den meisten Spielen lassen sich Soundeffekte und Musik separat steuern. Mit der obigen Grafik lässt sich dies leicht erreichen. Jeder Verstärkungsknoten hat ein „Verstärkungs“-Attribut, das auf einen beliebigen Dezimalwert zwischen 0 und 1 gesetzt werden kann, um die Lautstärke zu steuern. Da wir die Lautstärke der Musik- und Soundeffektkanäle separat regeln möchten, haben wir für jeden einen Verstärkungsknoten, mit dem 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 Soundeffekten und Musik steuern. Das Festlegen des Pegels des Masterknotens wirkt sich auf den gesamten Ton des Spiels aus. Wenn Sie den Verstärkungswert auf 0 einstellen, werden Ton und Musik stummgeschaltet. AudioBufferSourceNodes haben auch einen Pegelparameter. Sie könnten eine Liste aller abgespielten Töne verfolgen und deren Pegelwerte einzeln für die Gesamtlautstärke anpassen. Wenn du Soundeffekte mit Audio-Tags erstellst, müsstest du Folgendes tun. Das Knotendiagramm von Web Audio macht es stattdessen viel einfacher, die Lautstärke zahlloser Töne zu ändern. Wenn Sie die Lautstärke auf diese Weise regeln, haben Sie zusätzliche Energie ohne Probleme. Wir könnten einfach einen AudioBufferSourceNode direkt an den Master-Knoten anschließen, um Musik abzuspielen und den eigenen Pegel zu steuern. Allerdings müssten Sie diesen Wert jedes Mal festlegen, wenn Sie einen AudioBufferSourceNode zum Abspielen von Musik erstellen. Stattdessen wird ein Knoten nur dann geändert, wenn ein Player die Musiklautstärke ändert und beim Start. Jetzt haben wir einen Verstärkungswert bei Pufferquellen für etwas anderes. Bei Musik wird häufig die Überblendung von einem Audiotrack zum nächsten genutzt, wenn eine Audiospur verlässt und eine andere eingeht. Mit Web Audio lässt sich dies ganz einfach durchführen.

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

Fieldrunners hat Überblendung nicht speziell genutzt. Wäre uns die Funktion zum Festlegen von Werten von WebAudio während der ursprünglichen Einführung des Soundsystems wahrscheinlich bekannt gewesen.

Töne pausieren

Wenn ein Spieler ein Spiel anhält, können einige Töne weiterhin abgespielt werden. Der Ton ist ein wichtiger Teil des Feedbacks zum häufigen Drücken von Benutzeroberflächenelementen in Spielmenüs. Da Fieldrunners eine Reihe von Benutzeroberflächen hat, mit denen der Nutzer interagieren kann, während das Spiel pausiert ist, sollen diese weiterhin gespielt werden. Wir möchten jedoch nicht, dass lange Töne oder sich wiederholende Töne ununterbrochen 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 immer noch verbunden. Alle Töne, die den pausierten Zustand des Spiels ignorieren dürfen, werden in diesem Fall weiter abgespielt. Wenn das Spiel fortgesetzt wird, können wir die Knoten wieder verbinden und der Ton wird sofort wieder abgespielt.

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

Nach der Auslieferung von Fieldrunners haben wir festgestellt, dass die Wiedergabe von AudioBufferSourceNodes nicht angehalten wird, wenn man nur einen Knoten oder eine Teilgrafik abtrennt. Tatsächlich machten wir uns einen Fehler in WebAudio zunutze, bei dem derzeit die Wiedergabe von Knoten gestoppt wird, die nicht mit dem Zielknoten im Diagramm verbunden sind. Um sicherzustellen, dass wir für diese zukünftige Lösung 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;
    }
  }
};

Hätten wir zuvor gewusst, dass wir einen Fehler missbrauchen, würde die Struktur unseres Audiocodes ganz anders aussehen. Dies hat sich auf einige Abschnitte dieses Artikels ausgewirkt. Eine direkte Wirkung ist hier, aber auch in unseren Code-Snippets in „Fokus verlieren“ und „Give Me a Beat“. Um zu wissen, wie dies funktioniert, sind Änderungen an der Knotengrafik von Fieldrunners (da wir Knoten zum Kürzen der Wiedergabe erstellt haben) und am zusätzlichen Code erforderlich, der die pausierten Zustände aufzeichnet und bereitstellt, was Web Audio selbst nicht tut.

Fokus verlieren

Für diese Funktion kommt unser Master-Knoten ins Spiel. Wechselt ein Browsernutzer zu einem anderen Tab, ist das Spiel nicht mehr sichtbar. Aus den Augen, aus dem Sinn, und auch der Ton sollte verschwinden. Es gibt Tricks, um bestimmte Sichtbarkeitsstatus für eine Spieleseite zu ermitteln, doch dank der Sichtbarkeits API ist das viel einfacher geworden.

Fieldrunners wird nur als aktiver Tab wiedergegeben, da die Update-Schleife mithilfe von requestAnimationFrame aufgerufen wird. Im Web Audio-Kontext werden jedoch weiterhin Schleifeneffekte und Hintergrundspuren wiedergegeben, während sich der Nutzer auf einem anderen Tab befindet. Das lässt sich mit einem sehr kleinen Snippet mit Sichtbarkeits-API-Erkennung 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 es ausreicht, den Master zu trennen, um alle Töne zu pausieren, anstatt sie stummzuschalten. Durch Trennen des Knotens wurde die Verarbeitung und Wiedergabe des Knotens und der untergeordneten Elemente gestoppt. Nachdem das Gerät wieder verbunden war, wurden alle Töne und die Musik an der Stelle fortgesetzt, an der sie aufgehört haben, und das Spiel wird dort fortgesetzt, wo es unterbrochen wurde. Aber dieses Verhalten ist unerwartet. Es reicht nicht aus, die Verbindung einfach zu trennen, um die Wiedergabe zu stoppen.

Mit der Page Transparency API können Sie ganz einfach feststellen, wann Ihr Tab nicht mehr im Fokus ist. Wenn Sie bereits über wirksamen Code zum Anhalten von Ton verfügen, brauchen Sie nur wenige Zeilen, um beim Pausieren des Tons einzuschreiben, wenn der Tab „Spiele“ ausgeblendet ist.

Musik hören

Wir haben jetzt einige Einstellungen eingerichtet. Wir haben ein Diagramm mit Knoten. Wir können den Ton anhalten, wenn der Spieler das Spiel anhält, und neue Töne für Elemente wie Spielemenüs abspielen. Wir können den Ton und die 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 den sterbenden Charakter abzuspielen, spielt Fieldrunners während seiner Dauer nur einmal einen Ton ab. Wird der Ton nach Ende der Wiedergabe benötigt, kann er neu gestartet werden, jedoch nicht während der Wiedergabe. Dies ist eine Entscheidung für das Audiodesign von Fieldrunners, da es Töne gibt, die schnell abgespielt werden sollen, die andernfalls stotteren, wenn sie neu gestartet werden, oder eine unangenehm zu nutzende Sprache erzeugen, wenn mehrere Instanzen abgespielt werden dürfen. AudioBufferSourceNodes dürfen nur einmalig verwendet werden. Erstellen Sie einen Knoten, hängen Sie einen Puffer an, legen Sie bei Bedarf einen booleschen Wert für die Schleife fest, stellen Sie eine Verbindung zu einem Knoten in der Grafik her, der zum Ziel führt, rufen Sie noteOn oder noteGrainOn auf und optional noteOff.

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 gestartet, die über ein Audio-Tag abgespielt wurde. Bei der Veröffentlichung stellten wir fest, dass Musikdateien unverhältnismäßig oft angefordert wurden als für den Rest des Spiels. Nach einigen Recherchen stellten wir fest, dass damals der Chrome-Browser die Stream-Teile der Musikdateien nicht im Cache speicherte. Dies führte dazu, dass der Browser den wiedergegebenen Titel alle paar Minuten nach dem Ende anforderte. In neueren Tests wurden in Chrome gestreamte Tracks 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, aber bei einigen Browserversionen sollten Sie Ihre Musik auf dieselbe Weise laden wie Soundeffekte.

Da alle Soundeffekte über Web Audio abgespielt wurden, haben wir auch die Wiedergabe der Hintergrundmusik zu Web Audio verschoben. Das bedeutete, dass wir die Tracks auf dieselbe Weise laden würden, wie wir alle Effekte mit XMLHttpRequests und dem Antworttyp Arraybuffer 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

Die Einführung von Fieldrunners für Chrome und HTML5 war ein Riesenerfolg. Abgesehen von dem eigenen Arbeitsaufwand, tausende von C++-Zeilen in JavaScript zu integrieren, erregen einige interessante Dilemmas und Entscheidungen, die sich speziell auf HTML5 beziehen. AudioBufferSourceNodes sind Einmalobjekte. Erstellen Sie sie, hängen Sie einen Audiozwischenspeicher an, verbinden Sie ihn mit der Web Audio-Grafik und spielen Sie ihn mit „noteOn“ oder „noteGrainOn“ ab. Möchtest du diesen Ton noch einmal abspielen? Erstellen Sie dann einen weiteren AudioBufferSourceNode.