SONAR, HTML5-Spieleentwicklung

Sean Middleditch
Sean Middleditch

Letzten Sommer habe ich als technischer Leiter an einem kommerziellen WebGL-Spiel namens SONAR gearbeitet. Das Projekt dauerte etwa drei Monate und wurde komplett in JavaScript entwickelt. Bei der Entwicklung von SONAR mussten wir innovative Lösungen für eine Reihe von Problemen in den neuen und unerforschten HTML5-Gewässern finden. Wir brauchten vor allem eine Lösung für ein scheinbar einfaches Problem: Wie können wir mehr als 70 MB an Spieldaten herunterladen und im Cache speichern, wenn der Spieler das Spiel startet?

Für andere Plattformen gibt es bereits fertige Lösungen für dieses Problem. Die meisten Konsolen- und PC-Spiele laden Ressourcen von einer lokalen CD/DVD oder von einer Festplatte. Flash kann alle Ressourcen als Teil der SWF-Datei verpacken, die das Spiel enthält, und Java kann dasselbe mit JAR-Dateien tun. Digitale Vertriebsplattformen wie Steam oder der App Store sorgen dafür, dass alle Ressourcen heruntergeladen und installiert werden, bevor der Spieler das Spiel überhaupt starten kann.

HTML5 bietet uns diese Mechanismen nicht, aber es bietet uns alle Tools, die wir zum Erstellen eines eigenen Systems zum Herunterladen von Spielressourcen benötigen. Der Vorteil eines eigenen Systems besteht darin, dass wir die volle Kontrolle und Flexibilität haben und ein System entwickeln können, das genau unseren Anforderungen entspricht.

Abruf

Bevor wir überhaupt Ressourcen-Caching hatten, hatten wir einen einfachen verketteten Ressourcen-Loader. Mit diesem System konnten wir einzelne Ressourcen über den relativen Pfad anfordern, wodurch wiederum weitere Ressourcen angefordert werden konnten. Auf unserem Ladebildschirm wurde ein einfacher Fortschrittsbalken angezeigt, der angab, wie viele Daten noch geladen werden mussten. Erst wenn die Warteschlange des Ressourcenladers leer war, wurde zum nächsten Bildschirm gewechselt.

Das Design dieses Systems ermöglichte es uns, problemlos zwischen verpackten und losen (nicht verpackten) Ressourcen zu wechseln, die über einen lokalen HTTP-Server bereitgestellt wurden. Das war sehr hilfreich, um schnell sowohl den Spielcode als auch die Daten zu iterieren.

Der folgende Code veranschaulicht das grundlegende Design unseres verketteten Ressourcen-Loaders. Die Fehlerbehandlung und der erweiterte XHR-/Bildladecode wurden entfernt, um die Lesbarkeit zu verbessern.

function ResourceLoader() {
  this.pending = 0;
  this.baseurl = './';
  this.oncomplete = function() {};
}

ResourceLoader.prototype.request = function(path, callback) {
  var xhr = new XmlHttpRequest();
  xhr.open('GET', this.baseurl + path);
  var self = this;

  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      callback(path, xhr.response, self);

      if (--self.pending == 0) {
        self.oncomplete();
      }
    }
  };

  xhr.send();
};

Die Verwendung dieser Schnittstelle ist recht einfach, aber auch sehr flexibel. Der ursprüngliche Spielcode kann einige Dateien mit Daten anfordern, die das erste Spiellevel und die Spielobjekte beschreiben. Das können beispielsweise einfache JSON-Dateien sein. Der für diese Dateien verwendete Callback prüft dann diese Daten und kann zusätzliche Anfragen (verkettete Anfragen) für Abhängigkeiten stellen. In der Datei mit der Definition der Spielobjekte sind möglicherweise Modelle und Materialien aufgeführt. Über den Callback für Materialien werden dann Texturbilder angefordert.

Der oncomplete-Callback, der an die Hauptinstanz ResourceLoader angehängt ist, wird erst aufgerufen, wenn alle Ressourcen geladen wurden. Der Ladebildschirm des Spiels kann einfach warten, bis das Callback aufgerufen wird, bevor zum nächsten Bildschirm gewechselt wird.

Mit dieser Benutzeroberfläche lässt sich natürlich noch viel mehr anstellen. Als Übungen für den Leser sind einige zusätzliche Funktionen, die es wert sind, untersucht zu werden, das Hinzufügen von Unterstützung für Fortschritt/Prozentsatz, das Hinzufügen des Bildladens (mit dem Typ „Image“), das Hinzufügen des automatischen Parsens von JSON-Dateien und natürlich die Fehlerbehandlung.

Das wichtigste Attribut für diesen Artikel ist das Feld „baseurl“, mit dem wir die Quelle der angeforderten Dateien ganz einfach ändern können. Es ist ganz einfach, die Core-Engine so einzurichten, dass ein ?uselocal-Suchparameter in der URL verwendet werden kann, um Ressourcen von einer URL anzufordern, die vom selben lokalen Webserver (z. B. python -m SimpleHTTPServer) bereitgestellt wird, der auch das Haupt-HTML-Dokument für das Spiel bereitgestellt hat. Das Cachesystem wird verwendet, wenn der Parameter nicht festgelegt ist.

Verpackungsressourcen

Ein Problem beim verketteten Laden von Ressourcen besteht darin, dass es keine Möglichkeit gibt, die vollständige Anzahl der Bytes aller Daten zu ermitteln. Das hat zur Folge, dass es keine Möglichkeit gibt, einen einfachen, zuverlässigen Fortschrittsdialog für Downloads zu erstellen. Da wir alle Inhalte herunterladen und im Cache speichern, was bei größeren Spielen recht lange dauern kann, ist es wichtig, dem Spieler einen ansprechenden Fortschrittsdialog zu präsentieren.

Die einfachste Lösung für dieses Problem (die uns auch einige andere Vorteile bietet) besteht darin, alle Ressourcendateien in einem einzigen Bundle zu verpacken, das wir mit einem einzigen XHR-Aufruf herunterladen. So erhalten wir die Fortschrittsereignisse, die wir für die Anzeige einer ansprechenden Fortschrittsanzeige benötigen.

Das Erstellen eines benutzerdefinierten Bundle-Dateiformats ist nicht besonders schwierig und würde sogar einige Probleme lösen, erfordert aber die Entwicklung eines Tools zum Erstellen des Bundle-Formats. Eine alternative Lösung besteht darin, ein vorhandenes Archivformat zu verwenden, für das bereits Tools vorhanden sind, und dann einen Decoder zu schreiben, der im Browser ausgeführt werden kann. Wir benötigen kein komprimiertes Archivformat, da HTTP Daten bereits mit den Algorithmen „gzip“ oder „deflate“ komprimieren kann. Aus diesen Gründen haben wir uns für das TAR-Dateiformat entschieden.

TAR ist ein relativ einfaches Format. Jeder Datensatz (jede Datei) hat einen 512-Byte-Header, gefolgt vom Dateiinhalt, der auf 512 Byte aufgefüllt wird. Der Header enthält nur wenige relevante oder interessante Felder für unsere Zwecke, hauptsächlich den Dateityp und den Namen, die an festen Positionen im Header gespeichert sind.

Headerfelder im TAR-Format werden an festen Positionen mit festen Größen im Headerblock gespeichert. Der Zeitstempel der letzten Änderung der Datei wird beispielsweise 136 Byte nach dem Beginn des Headers gespeichert und ist 12 Byte lang. Alle numerischen Felder werden als Oktalzahlen codiert, die im ASCII-Format gespeichert sind. Um die Felder zu parsen, extrahieren wir sie aus unserem Array-Puffer. Für numerische Felder rufen wir parseInt() auf und übergeben den zweiten Parameter, um die gewünschte Oktalbasis anzugeben.

Eines der wichtigsten Felder ist das Typfeld. Dies ist eine einstellige Oktalzahl, die angibt, welchen Dateityp der Datensatz enthält. Die einzigen beiden für unsere Zwecke interessanten Datensatztypen sind reguläre Dateien ('0') und Verzeichnisse ('5'). Wenn wir es mit beliebigen TAR-Dateien zu tun hätten, wären auch symbolische Links ('2') und möglicherweise Hardlinks ('1') von Interesse.

Auf jeden Header folgen unmittelbar die Inhalte der Datei, die durch den Header beschrieben wird (mit Ausnahme von Dateitypen, die keine eigenen Inhalte haben, z. B. Verzeichnisse). Auf den Dateiinhalt folgt dann das Padding, damit jeder Header an einer 512-Byte-Grenze beginnt. Um die Gesamtlänge eines Dateieintrags in einer TAR-Datei zu berechnen, müssen wir zuerst den Header für die Datei lesen. Wir addieren dann die Länge des Headers (512 Byte) mit der Länge des Dateiinhalts, der aus dem Header extrahiert wurde. Schließlich fügen wir alle erforderlichen Padding-Bytes hinzu, damit der Offset auf 512 Bytes ausgerichtet wird. Dazu teilen wir die Dateilänge durch 512, runden die Zahl auf und multiplizieren sie dann mit 512.

// Read a string out of an array buffer with a maximum string length of 'len'.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readString (state, len) {
  var str = '';

  // We read out the characters one by one from the array buffer view.
  // this actually is a lot faster than it looks, at least on Chrome.
  for (var i = state.index, e = state.index + len; i != e; ++i) {
    var c = state.buffer[i];

    if (c == 0) { // at NUL byte, there's no more string
      break;
    }

    str += String.fromCharCode(c);
  }

  state.index += len;

  return str;
}

// Read the next file header out of a tar file stored in an array buffer.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readTarHeader (state) {
  // The offset of the file this header describes is always 512 bytes from
  // the start of the header
  var offset = state.index + 512;

  // The header is made up of several fields at fixed offsets within the
  // 512 byte block allocated for the header.  fields have a fixed length.
  // all numeric fields are stored as octal numbers encoded as ASCII
  // strings.
  var name = readString(state, 100);
  var mode = parseInt(readString(state, 8), 8);
  var uid = parseInt(readString(state, 8), 8);
  var gid = parseInt(readString(state, 8), 8);
  var size = parseInt(readString(state, 12), 8);
  var modified = parseInt(readString(state, 12), 8);
  var crc = parseInt(readString(state, 8), 8);
  var type = parseInt(readString(state, 1), 8);
  var link = readString(state, 100);

  // The header is followed by the file contents, then followed
  // by padding to ensure that the next header is on a 512-byte
  // boundary.  advanced the input state index to the next
  // header.
  state.index = offset + Math.ceil(size / 512) * 512;

  // Return the descriptor with the relevant fields we care about
  return {
    name : name,
    size : size,
    type : type,
    offset : offset
  };
};

Ich habe nach vorhandenen TAR-Readern gesucht und einige gefunden, aber keiner davon hatte keine anderen Abhängigkeiten oder passte problemlos in unsere vorhandene Codebasis. Aus diesem Grund habe ich mich entschieden, selbst einen zu schreiben. Ich habe mir auch die Zeit genommen, das Laden so gut wie möglich zu optimieren und dafür zu sorgen, dass der Decoder sowohl Binär- als auch Stringdaten im Archiv problemlos verarbeiten kann.

Eines der ersten Probleme, die ich lösen musste, war, wie die Daten aus einer XHR-Anfrage geladen werden können. Ich habe ursprünglich mit einem „Binärstring“-Ansatz begonnen. Leider ist die Umwandlung von Binär-Strings in leichter nutzbare Binärformen wie ArrayBuffer nicht einfach und auch nicht besonders schnell. Die Konvertierung in Image-Objekte ist ebenso schwierig.

Ich habe mich dafür entschieden, die TAR-Dateien als ArrayBuffer direkt aus der XHR-Anfrage zu laden und eine kleine Hilfsfunktion zum Konvertieren von Chunks aus dem ArrayBuffer in einen String hinzuzufügen. Derzeit werden in meinem Code nur grundlegende ANSI-/8-Bit-Zeichen verarbeitet. Das kann jedoch behoben werden, sobald in Browsern eine bequemere Conversion API verfügbar ist.

Der Code durchsucht einfach ArrayBuffer und analysiert Datensatzheader, die alle relevanten TAR-Headerfelder (und einige weniger relevante) sowie den Speicherort und die Größe der Dateidaten in ArrayBuffer enthalten. Der Code kann die Daten optional auch als ArrayBuffer-Ansicht extrahieren und in der zurückgegebenen Liste der Datensatzheadern speichern.

Der Code ist unter einer freundlichen, permissiven Open-Source-Lizenz unter https://github.com/subsonicllc/TarReader.js frei verfügbar.

FileSystem API

Zum Speichern von Dateiinhalten und zum späteren Zugriff darauf haben wir die FileSystem API verwendet. Die API ist noch recht neu, aber es gibt bereits eine gute Dokumentation, darunter den ausgezeichneten HTML5 Rocks-Artikel zum FileSystem.

Die FileSystem API hat jedoch auch Nachteile. Zum einen ist es eine ereignisgesteuerte Schnittstelle. Dadurch wird die API nicht blockiert, was für die Benutzeroberfläche von Vorteil ist, aber auch die Verwendung erschwert. Die FileSystem API in einem WebWorker verwenden kann dieses Problem beheben, aber dazu müsste das gesamte Download- und Entpacksystem in einen WebWorker aufgeteilt werden. Das wäre vielleicht sogar der beste Ansatz gewesen, aber aus Zeitgründen habe ich mich dagegen entschieden, da ich noch nicht mit WorkWorkers vertraut war. Daher musste ich mich mit der asynchronen, ereignisgesteuerten Natur der API auseinandersetzen.

Wir müssen hauptsächlich Dateien in eine Verzeichnisstruktur schreiben. Dazu sind für jede Datei mehrere Schritte erforderlich. Zuerst müssen wir den Dateipfad in eine Liste umwandeln. Das geht ganz einfach, indem wir den Pfadstring am Pfadtrennzeichen (das wie bei URLs immer der Schrägstrich ist) aufteilen. Anschließend müssen wir jedes Element in der resultierenden Liste mit Ausnahme des letzten durchlaufen und rekursiv ein Verzeichnis im lokalen Dateisystem erstellen (falls erforderlich). Dann können wir die Datei erstellen, anschließend ein FileWriter erstellen und schließlich den Dateiinhalt schreiben.

Ein weiterer wichtiger Punkt ist die Dateigrößenbeschränkung für den PERSISTENT-Speicher der FileSystem API. Wir wollten einen persistenten Speicher, da der temporäre Speicher jederzeit geleert werden kann, auch wenn der Nutzer gerade unser Spiel spielt und die ausgelagerte Datei geladen werden soll.

Für Apps, die auf den Chrome Web Store ausgerichtet sind, gibt es keine Speicherlimits, wenn die Berechtigung unlimitedStorage in der Manifestdatei der Anwendung verwendet wird. Reguläre Web-Apps können jedoch weiterhin Speicherplatz über die experimentelle Schnittstelle für Kontingentanfragen anfordern.

function allocateStorage(space_in_bytes, success, error) {
  webkitStorageInfo.requestQuota(
    webkitStorageInfo.PERSISTENT,
    space_in_bytes,
    function() {
      webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);      
    },
    error
  );
}