L'été dernier, j'ai travaillé en tant que responsable technique sur un jeu WebGL commercial appelé SONAR. Le projet a duré environ trois mois et a été réalisé entièrement à partir de zéro en JavaScript. Lors du développement de SONAR, nous avons dû trouver des solutions innovantes à un certain nombre de problèmes dans les eaux nouvelles et inexplorées d'HTML5. Nous avions notamment besoin d'une solution à un problème apparemment simple : comment télécharger et mettre en cache plus de 70 Mo de données de jeu lorsque le joueur démarre le jeu ?
D'autres plates-formes proposent des solutions prêtes à l'emploi pour ce problème. La plupart des jeux sur console et PC chargent les ressources à partir d'un CD/DVD local ou d'un disque dur. Flash peut regrouper toutes les ressources dans le fichier SWF contenant le jeu, et Java peut faire de même avec les fichiers JAR. Les plates-formes de distribution numérique comme Steam ou l'App Store garantissent que toutes les ressources sont téléchargées et installées avant que le joueur puisse lancer le jeu.
HTML5 ne nous fournit pas ces mécanismes, mais il nous donne 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 obtenons tout le contrôle et la flexibilité dont nous avons besoin, et que nous pouvons créer un système qui correspond exactement à nos besoins.
Récupération
Avant la mise en cache des ressources, nous avions un simple chargeur de ressources en chaîne. Ce système nous permettait de demander des ressources individuelles par chemin relatif, qui pouvaient à leur tour demander d'autres ressources. Notre écran de chargement présentait un simple indicateur de progression qui mesurait la quantité de données à charger et passait à l'écran suivant uniquement lorsque la file d'attente du chargeur de ressources était vide.
La conception de ce système nous a permis de passer facilement des ressources packagées aux ressources non packagées diffusées sur un serveur HTTP local. Cela nous a vraiment aidés à 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 chaînées, avec la gestion des erreurs et le code de chargement XHR/d'image plus avancé supprimé pour plus de clarté.
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 aussi très flexible. Le code de jeu initial peut demander certains fichiers de données qui décrivent le niveau de jeu initial et les objets du jeu. Il peut s'agir de fichiers JSON simples, par exemple. Le rappel utilisé pour ces fichiers inspecte ensuite ces données et peut effectuer des requêtes supplémentaires (requêtes en chaîne) pour les dépendances. Le fichier de définition des objets de jeu peut lister des modèles et des matériaux, et le rappel pour les matériaux peut ensuite demander des images de texture.
Le rappel oncomplete associé à l'instance ResourceLoader principale ne sera 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, vous pouvez faire beaucoup plus avec cette interface. Pour que le lecteur puisse s'exercer, voici quelques fonctionnalités supplémentaires qui méritent d'être étudiées : l'ajout d'une prise en charge de la progression/du pourcentage, l'ajout du chargement d'images (à l'aide du type Image), l'ajout de l'analyse automatique des fichiers JSON et, bien sûr, la gestion des erreurs.
La fonctionnalité la plus importante pour 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 type de paramètre de requête ?uselocal dans l'URL afin de demander des ressources à partir d'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 sur l'emballage
L'un des problèmes liés au chargement en chaîne des ressources est qu'il n'existe aucun moyen d'obtenir un nombre d'octets complet pour toutes les données. Il est donc impossible de créer une boîte de dialogue de progression simple et fiable pour les téléchargements. Comme 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 fournir au joueur une boîte de dialogue de progression agréable.
La solution la plus simple à ce problème (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 avec un seul appel XHR. Cela nous donnera les événements de progression dont nous avons besoin pour afficher une barre de progression esthétique.
Créer un format de fichier groupé personnalisé n'est pas très difficile et résoudrait même quelques problèmes, mais cela nécessiterait de créer un outil pour créer le format groupé. 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 des algorithmes gzip ou deflate. C'est pourquoi nous avons opté pour le format de fichier TAR.
TAR est un format relativement simple. Chaque enregistrement (fichier) comporte un en-tête de 512 octets, suivi du contenu du fichier complété à 512 octets. L'en-tête ne comporte 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 et avec des tailles fixes dans le bloc d'en-tête. Par exemple, le code temporel de la dernière modification du fichier est stocké à 136 octets du début de l'en-tête et comporte 12 octets. Tous les champs numériques sont encodés sous forme de nombres octaux stockés au format ASCII. Pour analyser les champs, nous les extrayons de notre tampon de tableau, et pour les champs numériques, nous appelons parseInt() en veillant à transmettre le deuxième paramètre pour indiquer la base octale souhaitée.
Le champ "type" est l'un des plus importants. Il s'agit d'un nombre octal à un chiffre qui nous indique le type de fichier contenu dans l'enregistrement. Les deux types d'enregistrements qui nous intéressent sont les fichiers standards ('0') et les répertoires ('5'). Si nous avions affaire à des fichiers TAR arbitraires, nous nous intéresserions également 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'un remplissage 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) à la longueur du contenu du fichier extrait de l'en-tête. Enfin, nous ajoutons les octets de remplissage nécessaires pour que le décalage soit aligné sur 512 octets. Pour ce faire, il suffit de diviser la longueur du fichier par 512, de prendre le plafond du nombre, puis de multiplier le résultat 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 en ai trouvé quelques-uns, mais aucun n'était dépourvu d'autres dépendances ou ne s'intégrait facilement à notre codebase existant. C'est pourquoi j'ai choisi d'écrire le mien. J'ai également pris le temps d'optimiser le chargement autant que possible et de m'assurer que le décodeur gère facilement les données binaires et les chaînes de caractères 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. J'ai commencé par une approche de "chaîne binaire". Malheureusement, la conversion de chaînes binaires en formes binaires plus facilement utilisables, comme un ArrayBuffer, n'est pas simple et n'est pas particulièrement rapide. La conversion en objets Image est tout aussi pénible.
J'ai choisi 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 les blocs de ArrayBuffer en chaîne. Actuellement, mon code ne gère que les caractères ANSI/8 bits de base, mais cela peut être corrigé une fois qu'une API de conversion plus pratique sera disponible dans les navigateurs.
Le code analyse simplement le ArrayBuffer en analysant les en-têtes d'enregistrement, qui incluent tous les champs d'en-tête TAR pertinents (et quelques-uns qui le sont moins), ainsi que l'emplacement et la taille des données de fichier dans le ArrayBuffer. Le code peut également extraire les données sous forme de vue ArrayBuffer et les stocker dans la liste des en-têtes d'enregistrement renvoyée.
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 ultérieurement, nous avons utilisé l'API FileSystem. L'API est assez récente, mais elle dispose déjà d'une excellente documentation, y compris l'article HTML5 Rocks FileSystem.
L'API FileSystem n'est pas sans inconvénients. Tout d'abord, il s'agit d'une interface événementielle. Cela rend l'API non bloquante, ce qui est idéal pour l'UI, mais la rend également 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 en 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). J'ai donc dû faire face à la nature asynchrone et événementielle de l'API.
Nos besoins sont principalement axés sur l'écriture de fichiers dans une structure de répertoires. Cela nécessite une série d'étapes pour chaque fichier. Tout d'abord, nous devons transformer le chemin d'accès au fichier en liste. Pour ce faire, il suffit de diviser la chaîne de chemin d'accès en utilisant le caractère de séparation de chemin d'accès (qui est toujours la barre oblique, comme pour les URL). Nous devons ensuite parcourir chaque élément de la liste résultante, à l'exception du 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 un FileWriter, et enfin écrire le contenu du fichier.
Une deuxième chose importante à prendre en compte est la limite de taille de fichier du stockage PERSISTENT de l'API FileSystem. Nous voulions un stockage persistant, car le stockage temporaire peut être effacé à tout moment, y compris lorsque l'utilisateur est en train de jouer à notre jeu, juste avant qu'il n'essaie de charger le fichier évincé.
Pour les applications ciblant le Chrome Web Store, il n'y a pas de limite de stockage lorsque l'autorisation unlimitedStorage est utilisée dans le fichier manifeste de l'application. Toutefois, les applications Web standards peuvent toujours demander de l'espace avec 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
);
}