Étude de cas : SONAR, développement de jeux HTML5

Sean Midditch
Sean Midditch

Introduction

L'été dernier, j'ai travaillé comme responsable technique sur un jeu commercial WebGL appelé SONAR. Le projet a duré environ trois mois et a été réalisé de A à Z en JavaScript. Lors du développement de SONAR, nous avons dû trouver des solutions innovantes à un certain nombre de problèmes dans le monde du développement HTML5, qui n'a pas encore été testé. En particulier, nous avions besoin d'une solution à un problème en apparence simple: comment télécharger et mettre en cache plus de 70 Mo de données de jeu lorsque le joueur démarre ?

D'autres plateformes ont des solutions prêtes à l'emploi à ce problème. La plupart des consoles et des jeux PC chargent les ressources d'un CD/DVD local ou d'un disque dur. Flash peut empaqueter toutes les ressources dans le fichier CSV contenant le jeu, et Java peut faire de même avec des fichiers JAR. Les plates-formes de distribution numérique telles que Steam ou l'App Store garantissent que toutes les ressources sont téléchargées et installées avant même que le joueur puisse lancer le jeu.

En revanche, le HTML5 ne nous offre pas ces mécanismes, mais il nous fournit tous les outils dont nous avons besoin pour créer notre propre système de téléchargement de ressources de jeu. L'avantage de créer notre propre système est que nous bénéficions du contrôle et de la flexibilité dont nous avons besoin, et que nous pouvons créer un système qui répond exactement à nos besoins.

Récupération

Avant la mise en cache des ressources, nous disposions d'un simple chargeur de ressources en chaîne. Ce système nous a permis de demander des ressources individuelles par chemin relatif, ce qui nous a permis de demander des ressources supplémentaires. Notre écran de chargement présentait un simple outil de mesure de la progression, qui évaluait la quantité de données supplémentaires à charger. Il était passé à l'écran suivant uniquement une fois que la file d'attente du chargeur de ressources était vide.

La conception de ce système nous a permis de basculer facilement entre les ressources empaquetées et les ressources non empaquetées (non empaquetées) diffusées sur un serveur HTTP local, ce qui nous a vraiment permis de pouvoir itérer rapidement sur le code et les données du jeu.

Le code suivant illustre la conception de base de notre chargeur de ressources en chaîne, sans traitement des erreurs et suppression du code de chargement d'image/XHR pour une meilleure lisibilité.

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

L'utilisation de cette interface est assez simple, mais également assez flexible. Le code initial du jeu peut demander des fichiers de données décrivant le niveau initial du jeu et les objets associés. Il peut s'agir de simples fichiers JSON, par exemple. Le rappel utilisé pour ces fichiers inspecte ensuite ces données et peut effectuer des requêtes supplémentaires (requêtes enchaînées) pour les dépendances. Le fichier de définition des objets du jeu peut lister les modèles et les matériaux, et le rappel des matériaux peut ensuite demander des images de texture.

Le rappel oncomplete associé à l'instance ResourceLoader principale n'est appelé qu'une fois toutes les ressources chargées. L'écran de chargement du jeu peut simplement attendre que ce rappel soit invoqué avant de passer à l'écran suivant.

Bien sûr, cette interface vous permet d'effectuer bien d'autres actions. Parmi les exercices proposés au lecteur, quelques fonctionnalités supplémentaires valent la peine d'être étudiées : ajout de la prise en charge de la progression/du pourcentage, ajout du chargement d'image (à l'aide du type d'image), analyse automatique des fichiers JSON et, bien sûr, la gestion des erreurs.

La fonctionnalité la plus importante de cet article est le champ baseurl, qui nous permet de changer facilement la source des fichiers que nous demandons. Il est facile de configurer le moteur principal pour autoriser un paramètre de requête de type ?uselocal dans l'URL à demander des ressources à une URL diffusée par le même serveur Web local (comme python -m SimpleHTTPServer) qui a diffusé le document HTML principal du jeu, tout en utilisant le système de cache si le paramètre n'est pas défini.

Ressources d'empaquetage

L'un des problèmes du chargement en chaîne des ressources est qu'il n'existe aucun moyen d'obtenir un nombre d'octets complet de toutes les données. En conséquence, il n'existe aucun moyen de créer une boîte de dialogue de progression simple et fiable pour les téléchargements. Étant donné que nous allons télécharger tout le contenu et le mettre en cache, ce qui peut prendre beaucoup de temps pour les jeux plus volumineux, il est important de présenter au joueur une boîte de dialogue de progression intéressante.

La solution la plus simple à ce problème (ce qui nous offre également d'autres avantages intéressants) consiste à regrouper tous les fichiers de ressources dans un seul bundle, que nous téléchargerons par un seul appel XHR, ce qui nous donnera les événements de progression dont nous avons besoin pour afficher une bonne barre de progression.

Créer un format de fichier de bundle personnalisé n'est pas très difficile et cela permettrait même de résoudre quelques problèmes, mais il faudrait créer un outil pour créer le format du bundle. Une autre solution consiste à utiliser un format d'archive existant pour lequel des outils existent déjà, puis à écrire un décodeur à exécuter dans le navigateur. Nous n'avons pas besoin d'un format d'archive compressé, car HTTP peut déjà compresser les données à l'aide de gzip ou déformer les algorithmes. Pour ces raisons, nous nous sommes tournés vers le format de fichier TAR.

Le format TAR est relativement simple. Chaque enregistrement (fichier) possède un en-tête de 512 octets, suivi du contenu du fichier complété jusqu'à 512 octets. L'en-tête ne contient que quelques champs pertinents ou intéressants pour nos besoins, principalement le type et le nom du fichier, qui sont stockés à des positions fixes dans l'en-tête.

Les champs d'en-tête au format TAR sont stockés à des emplacements fixes avec des tailles fixes dans le bloc d'en-tête. Par exemple, le code temporel de dernière modification du fichier est stocké à 136 octets à partir du début de l'en-tête, et sa longueur est de 12 octets. Tous les champs numériques sont encodés en tant que nombres octaux stockés au format ASCII. Pour analyser les champs, nous les extrayons de notre tampon de tableau. Pour les champs numériques, nous appelons parseInt() en veillant à transmettre le deuxième paramètre pour indiquer la base octale souhaitée.

L'un des champs les plus importants est le champ "Type". Il s'agit d'un nombre octal à un seul chiffre qui nous indique le type de fichier que contient l'enregistrement. Les deux seuls types d'enregistrements intéressants pour nos besoins sont les fichiers standards ('0') et les répertoires ('5'). Si nous utilisions des fichiers TAR arbitraires, nous pourrions également nous intéresser aux liens symboliques ('2') et éventuellement aux liens physiques ('1').

Chaque en-tête est immédiatement suivi du contenu du fichier décrit par l'en-tête (à l'exception des types de fichiers qui n'ont pas de contenu propre, comme les répertoires). Le contenu du fichier est ensuite suivi d'une marge intérieure pour s'assurer que chaque en-tête commence sur une limite de 512 octets. Ainsi, pour calculer la longueur totale d'un enregistrement de fichier dans un fichier TAR, nous devons d'abord lire l'en-tête du fichier. Nous ajoutons ensuite la longueur de l'en-tête (512 octets) avec la longueur du contenu du fichier extrait de l'en-tête. Enfin, nous ajoutons tous les octets de marge intérieure nécessaires pour aligner le décalage sur 512 octets, ce qui peut être fait facilement en divisant la longueur du fichier par 512, en prenant le plafond du nombre, puis en multipliant par 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
  };
};

J'ai cherché des lecteurs TAR existants et j'en ai trouvé quelques-uns, mais aucun qui n'avait pas d'autres dépendances ou qui intégrerait facilement notre codebase existant. C'est pourquoi j'ai choisi d'écrire moi-même. J'ai également pris le temps d'optimiser le chargement du mieux possible et de m'assurer que le décodeur gère facilement les données binaires et les données de chaîne dans l'archive.

L'un des premiers problèmes que j'ai dû résoudre était de savoir comment charger les données à partir d'une requête XHR. Au départ, j'ai adopté une approche de type "chaîne binaire". Malheureusement, la conversion de chaînes binaires en formes binaires plus faciles à utiliser, comme ArrayBuffer, n'est pas simple, et ces conversions ne sont pas particulièrement rapides. La conversion en objets Image est tout aussi pénible.

J'ai décidé de charger les fichiers TAR en tant que ArrayBuffer directement à partir de la requête XHR et d'ajouter une petite fonction pratique pour convertir des fragments de ArrayBuffer en chaîne. Actuellement, mon code prend uniquement en charge les caractères ANSI/8 bits de base, mais ce problème pourra être résolu une fois qu'une API de conversion plus pratique sera disponible dans les navigateurs.

Le code analyse simplement ArrayBuffer en analysant les en-têtes d'enregistrement, qui incluent tous les champs d'en-tête TAR pertinents (et quelques-uns moins pertinents), ainsi que l'emplacement et la taille des données de fichier dans le ArrayBuffer. Le code peut également extraire les données sous la forme d'une vue ArrayBuffer et les stocker dans la liste des en-têtes d'enregistrement renvoyés.

Le code est disponible sans frais sous une licence Open Source conviviale et permissive à l'adresse https://github.com/subsonicllc/TarReader.js.

API FileSystem

Pour stocker le contenu des fichiers et y accéder plus tard, nous avons utilisé l’API FileSystem. L'API est récente, mais elle dispose déjà d'une excellente documentation, dont l'excellent article HTML5 Rocks FileSystem.

L'API FileSystem n'est pas sans ses mises en garde. D'une part, il s'agit d'une interface basée sur des événements. Cela rend l'API non bloquante, ce qui est idéal pour l'interface utilisateur, mais aussi difficile à utiliser. L'utilisation de l'API FileSystem à partir d'un WebWorker peut atténuer ce problème, mais cela nécessiterait de diviser l'ensemble du système de téléchargement et de décompression dans un WebWorker. C'est peut-être même la meilleure approche, mais ce n'est pas celle que j'ai choisie en raison de contraintes de temps (je ne connaissais pas encore WorkWorkers), donc j'ai dû faire face à la nature asynchrone de l'API, basée sur les événements.

Nos besoins se concentrent principalement sur l'écriture de fichiers dans une structure de répertoires. Pour ce faire, vous devez suivre une série d'étapes pour chaque fichier. Tout d'abord, nous devons prendre le chemin d'accès au fichier et le transformer en une liste, ce qui se fait facilement en divisant la chaîne du chemin d'accès sur le caractère de séparateur de chemin (qui est toujours la barre oblique, comme les URL). Nous devons ensuite itérer chaque élément de la liste obtenue et enregistrer le dernier, en créant de manière récursive un répertoire (si nécessaire) dans le système de fichiers local. Nous pouvons ensuite créer le fichier, puis créer un FileWriter, et enfin écrire le contenu du fichier.

Un deuxième élément important à prendre en compte est la taille maximale de fichier de l'espace de stockage PERSISTENT de l'API FileSystem. Nous voulions un espace de stockage persistant, car celui-ci peut être effacé à tout moment, y compris lorsque l'utilisateur est en train de jouer, juste avant d'essayer de charger le fichier évincé.

Pour les applications ciblant le Chrome Web Store, aucune limite de stockage n'est imposée lors de l'utilisation de l'autorisation unlimitedStorage dans le fichier manifeste de l'application. Toutefois, les applications Web standards peuvent toujours demander de l'espace via l'interface de demande de quota expérimentale.

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