Introduzione
La scorsa estate ho lavorato come Technical Lead per un gioco commerciale WebGL chiamato SONAR. Il progetto ha richiesto circa tre mesi ed è stato realizzato completamente da zero in JavaScript. Durante lo sviluppo di SONAR, abbiamo dovuto trovare soluzioni innovative per una serie di problemi relativi al nuovo e inesplorato HTML5. In particolare, avevamo bisogno di una soluzione a un problema apparentemente semplice: come scaricare e memorizzare nella cache più di 70 MB di dati di gioco quando il giocatore avvia il gioco?
Altre piattaforme hanno soluzioni pronte per questo problema. La maggior parte dei giochi per console e PC carica le risorse da un CD/DVD locale o da un hard disk. Flash può pacchettizzare tutte le risorse all'interno del file SWF che contiene il gioco e Java può fare lo stesso con i file JAR. Le piattaforme di distribuzione digitale come Steam o l'App Store assicurano che tutte le risorse vengano scaricate e installate prima che il giocatore possa avviare il gioco.
HTML5 non ci offre questi meccanismi, ma ci fornisce tutti gli strumenti necessari per creare il nostro sistema di download delle risorse di gioco. Il vantaggio di creare il nostro sistema è che abbiamo tutto il controllo e la flessibilità di cui abbiamo bisogno e possiamo creare un sistema che soddisfi esattamente le nostre esigenze.
Recupero
Prima di avere la memorizzazione nella cache delle risorse, avevamo un semplice caricatore di risorse incatenato. Questo sistema ci ha permesso di richiedere singole risorse in base al percorso relativo, che a sua volta poteva richiedere altre risorse. La nostra schermata di caricamento presentava un semplice indicatore di avanzamento che misurava la quantità di dati ancora da caricare e passava alla schermata successiva solo dopo che la coda del caricatore delle risorse era vuota.
Il design di questo sistema ci ha permesso di passare facilmente da risorse pacchettizzate a risorse non pacchettizzate (svincolate) servite su un server HTTP locale, il che è stato davvero fondamentale per garantire che potessimo eseguire rapidamente l'iterazione sia sul codice del gioco che sui dati.
Il codice seguente illustra il design di base del nostro caricatore di risorse concatenato, con la gestione degli errori e il codice di caricamento XHR/delle immagini più avanzato rimossi per mantenere la leggibilità.
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'utilizzo di questa interfaccia è abbastanza semplice, ma anche abbastanza flessibile. Il codice di gioco iniziale può richiedere alcuni file di dati che descrivono il livello di gioco iniziale e gli oggetti di gioco. ad esempio semplici file JSON. Il callback utilizzato per questi file ispeziona quindi i dati e può effettuare richieste aggiuntive (richieste in catena) per le dipendenze. Il file di definizione degli oggetti di gioco potrebbe elencare modelli e materiali e il callback per i materiali potrebbe richiedere immagini di texture.
Il callback oncomplete
associato all'istanza ResourceLoader
principale verrà chiamato solo dopo il caricamento di tutte le risorse. La schermata di caricamento del gioco può semplicemente attendere l'attivazione del richiamo prima di passare alla schermata successiva.
Naturalmente, con questa interfaccia è possibile fare molto di più. Come esercizi per il lettore, alcune funzionalità aggiuntive che vale la pena esaminare sono l'aggiunta del supporto di avanzamento/percentuale, il caricamento di immagini (utilizzando il tipo di immagine), l'analisi automatica dei file JSON e, naturalmente, la gestione degli errori.
La funzionalità più importante per questo articolo è il campo baseurl, che ci consente di cambiare facilmente l'origine dei file richiesti. È facile configurare il motore di base in modo da consentire a un tipo di parametro di query ?uselocal
nell'URL di richiedere risorse da un URL pubblicato dallo stesso server web locale (ad esempio python -m SimpleHTTPServer
) che ha pubblicato il documento HTML principale per il gioco, utilizzando al contempo il sistema di cache se il parametro non è impostato.
Risorse per l'imballaggio
Un problema con il caricamento in catena delle risorse è che non è possibile ottenere un conteggio completo dei byte di tutti i dati. Di conseguenza, non è possibile creare una finestra di dialogo di avanzamento semplice e affidabile per i download. Poiché scaricheremo tutti i contenuti e li memorizzeremo nella cache, il che può richiedere molto tempo per i giochi più grandi, è molto importante mostrare al giocatore una bella finestra di dialogo di avanzamento.
La soluzione più semplice a questo problema (che offre anche altri vantaggi) è impacchettare tutti i file di risorse in un unico bundle, che scaricheremo con una singola chiamata XHR, che ci fornisce gli eventi di avanzamento di cui abbiamo bisogno per visualizzare una bella barra di avanzamento.
Creare un formato file del bundle personalizzato non è molto difficile e potrebbe anche risolvere alcuni problemi, ma richiederebbe la creazione di uno strumento per creare il formato del bundle. Una soluzione alternativa è utilizzare un formato di archivio esistente per il quale esistono già strumenti e poi scrivere un decodificatore da eseguire nel browser. Non abbiamo bisogno di un formato di archivio compresso perché HTTP può già comprimere i dati utilizzando gli algoritmi gzip o deflate. Per questi motivi, abbiamo scelto il formato file TAR.
TAR è un formato relativamente semplice. Ogni record (file) ha un'intestazione di 512 byte, seguita dai contenuti del file riempiti fino a 512 byte. L'intestazione contiene solo alcuni campi pertinenti o interessanti per le nostre finalità, principalmente il tipo e il nome del file, che sono memorizzati in posizioni fisse all'interno dell'intestazione.
I campi dell'intestazione nel formato TAR vengono archiviati in posizioni fisse con dimensioni fisse nel blocco dell'intestazione. Ad esempio, il timestamp dell'ultima modifica del file è memorizzato a 136 byte dall'inizio dell'intestazione ed è lungo 12 byte. Tutti i campi numerici sono codificati come numeri esadecimali memorizzati in formato ASCII. Per analizzare i campi, li estraiamo dall'array buffer e, per i campi numerici, chiamiamo parseInt()
assicurandoci di passare il secondo parametro per indicare la base octal desiderata.
Uno dei campi più importanti è il campo type. Si tratta di un numero esadecimale a una cifra che indica il tipo di file contenuto nel record. Gli unici due tipi di record interessanti per i nostri scopi sono i file normali ('0'
) e le directory ('5'
). Se avessimo a che fare con file TAR arbitrari, potremmo anche interessarci ai link simbolici ('2'
) e, eventualmente, ai link fissi ('1'
).
Ogni intestazione è seguita immediatamente dai contenuti del file descritti dall'intestazione (tranne i tipi di file che non hanno contenuti propri, come le directory). I contenuti del file sono seguiti da spaziatura interna per garantire che ogni intestazione inizi su un confine di 512 byte. Pertanto, per calcolare la lunghezza totale di un record di file in un file TAR, dobbiamo prima leggere l'intestazione del file. Aggiungiamo quindi la lunghezza dell'intestazione (512 byte) alla lunghezza dei contenuti del file estratti dall'intestazione. Infine, aggiungiamo eventuali byte di riempimento necessari per allineare l'offset a 512 byte, il che può essere fatto facilmente dividendo la lunghezza del file per 512, prendendo il limite superiore del numero e moltiplicando per 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
};
};
Ho cercato lettori TAR esistenti e ne ho trovati alcuni, ma nessuno che non avesse altre dipendenze o che potesse essere facilmente integrato nella nostra base di codice esistente. Per questo motivo, ho scelto di scriverne uno mio. Ho anche ottimizzato il caricamento al meglio e ho fatto in modo che il decodificatore gestisse facilmente i dati binari e di stringa all'interno dell'archivio.
Uno dei primi problemi che ho dovuto risolvere è stato come caricare effettivamente i dati da una richiesta XHR. Inizialmente ho iniziato con un approccio di "stringa binaria". Purtroppo, la conversione da stringhe binarie a forme binarie più facilmente utilizzabili come un ArrayBuffer
non è semplice e queste conversioni non sono particolarmente rapide. La conversione agli oggetti Image
è altrettanto spiacevole.
Ho deciso di caricare i file TAR come ArrayBuffer
direttamente dalla richiesta XHR e di aggiungere una piccola funzione di praticità per convertire i chunk dal ArrayBuffer
in una stringa. Al momento il mio codice gestisce solo i caratteri ANSI/8 bit di base, ma questo problema può essere risolto quando sarà disponibile un'API di conversione più pratica nei browser.
Il codice esegue semplicemente la scansione del file ArrayBuffer
analizzando le intestazioni dei record, che includono tutti i campi dell'intestazione TAR pertinenti (e alcuni non molto pertinenti), nonché la posizione e le dimensioni dei dati del file all'interno del file ArrayBuffer
. Il codice può anche, facoltativamente, estrarre i dati come visualizzazione ArrayBuffer
e memorizzarli nell'elenco delle intestazioni dei record restituiti.
Il codice è disponibile senza costi con una licenza open source permissiva e facile da usare all'indirizzo https://github.com/subsonicllc/TarReader.js.
API FileSystem
Per archiviare effettivamente i contenuti dei file e accedervi in un secondo momento, abbiamo utilizzato l'API FileSystem. L'API è piuttosto recente, ma dispone già di una documentazione eccellente, tra cui l'eccellente articolo HTML5 Rocks FileSystem.
L'API FileSystem non è priva di inconvenienti. Innanzitutto, è un'interfaccia basata su eventi. Ciò rende l'API non bloccante, il che è ottimo per l'interfaccia utente, ma la rende anche difficile da utilizzare. L'utilizzo dell'API FileSystem da un WebWorker può ovviare a questo problema, ma richiederebbe la suddivisione dell'intero sistema di download e decompressione in un WebWorker. Potrebbe anche essere l'approccio migliore, ma non è quello che ho scelto a causa di vincoli di tempo (non conoscevo ancora WorkWorkers), quindi ho dovuto gestire la natura asincrona basata su eventi dell'API.
Le nostre esigenze si concentrano principalmente sulla scrittura di file in una struttura di directory. Questa operazione richiede una serie di passaggi per ogni file. Innanzitutto, dobbiamo prendere il percorso del file e trasformarlo in un elenco, il che è facile da fare dividendo la stringa del percorso in base al carattere separatore del percorso (che è sempre la barra, come negli URL). Poi dobbiamo eseguire l'iterazione su ogni elemento dell'elenco risultante, tranne che per l'ultimo, creando ricorsamente una directory (se necessario) nel file system locale. Quindi possiamo creare il file, creare un FileWriter
e infine scrivere i contenuti del file.
Un secondo aspetto importante da tenere presente è il limite di dimensioni dei file dello spazio di archiviazione PERSISTENT
dell'API FileSystem. Volevamo uno spazio di archiviazione permanente perché quello temporaneo può essere cancellato in qualsiasi momento, anche mentre l'utente sta giocando al nostro gioco, poco prima che provi a caricare il file espulso.
Per le app destinate al Chrome Web Store, non sono previsti limiti di spazio di archiviazione quando si utilizza l'autorizzazione unlimitedStorage
nel file manifest dell'applicazione. Tuttavia, le app web normali possono comunque richiedere spazio con l'interfaccia di richiesta di quota sperimentale.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}