La scorsa estate ho lavorato come responsabile tecnico a un gioco commerciale WebGL chiamato SONAR. Il progetto ha richiesto circa tre mesi per essere completato ed è stato realizzato completamente da zero in JavaScript. Durante lo sviluppo di SONAR, abbiamo dovuto trovare soluzioni innovative a una serie di problemi nelle nuove e inesplorate acque dell'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 inizia a giocare?
Altre piattaforme hanno soluzioni già pronte per questo problema. La maggior parte delle console e dei giochi per PC carica le risorse da un CD/DVD locale o da un disco rigido. Flash può raggruppare tutte le risorse nel 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 garantiscono che tutte le risorse vengano scaricate e installate prima che il giocatore possa iniziare la partita.
HTML5 non ci fornisce questi meccanismi, ma ci offre tutti gli strumenti necessari per creare il nostro sistema di download delle risorse di gioco. Il vantaggio di creare un nostro sistema è che abbiamo tutto il controllo e la flessibilità di cui abbiamo bisogno e possiamo creare un sistema che corrisponda esattamente alle nostre esigenze.
Recupero
Prima di avere la memorizzazione nella cache delle risorse, avevamo un semplice caricatore di risorse concatenato. Questo sistema ci ha permesso di richiedere singole risorse in base al percorso relativo, che a sua volta poteva richiedere altre risorse. La schermata di caricamento mostrava un semplice indicatore di avanzamento che misurava la quantità di dati da caricare e passava alla schermata successiva solo dopo che la coda del caricatore di risorse era vuota.
La progettazione di questo sistema ci ha permesso di passare facilmente dalle risorse pacchettizzate a quelle non pacchettizzate servite tramite un server HTTP locale, il che è stato fondamentale per garantire di poter iterare rapidamente sia il codice che i dati del gioco.
Il codice seguente illustra la progettazione di base del nostro caricatore di risorse concatenato, con la gestione degli errori e il codice di caricamento di immagini/XHR più avanzato rimosso 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 è piuttosto semplice, ma anche molto flessibile. Il codice di gioco iniziale può richiedere alcuni file di dati che descrivono il livello di gioco e gli oggetti di gioco iniziali. Ad esempio, potrebbero essere semplici file JSON. Il callback utilizzato per questi file ispeziona quindi i dati e può effettuare richieste aggiuntive (richieste concatenate) per le dipendenze. Il file di definizione degli oggetti di gioco potrebbe elencare modelli e materiali, mentre il callback per i materiali potrebbe richiedere immagini di texture.
Il callback oncomplete collegato all'istanza ResourceLoader principale verrà chiamato solo dopo il caricamento di tutte le risorse. La schermata di caricamento del gioco può semplicemente attendere la chiamata di callback prima di passare alla schermata successiva.
Naturalmente, con questa interfaccia si possono fare molte altre cose. Come esercizi per il lettore, alcune funzionalità aggiuntive che vale la pena esaminare sono l'aggiunta del supporto per l'avanzamento/la percentuale, l'aggiunta del caricamento delle immagini (utilizzando il tipo Immagine), l'aggiunta dell'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 che richiediamo. È facile configurare il motore principale per consentire un tipo di parametro di query ?uselocal nell'URL per richiedere risorse da un URL pubblicato dallo stesso server web locale (come python -m SimpleHTTPServer) che ha pubblicato il documento HTML principale per il gioco, utilizzando il sistema di cache se il parametro non è impostato.
Risorse di packaging
Un problema con il caricamento concatenato delle risorse è che non è possibile ottenere un conteggio completo dei byte di tutti i dati. La conseguenza è che non è possibile creare una finestra di dialogo di avanzamento semplice e affidabile per i download. Poiché scaricheremo e memorizzeremo nella cache tutti i contenuti, e questa operazione può richiedere molto tempo per i giochi più grandi, è piuttosto importante mostrare al giocatore una finestra di dialogo di avanzamento.
La soluzione più semplice a questo problema (che ci offre anche altri vantaggi) è raggruppare tutti i file di risorse in un unico bundle, che scaricheremo con una singola chiamata XHR, che ci fornisce gli eventi di avanzamento necessari per visualizzare una barra di avanzamento.
Creare un formato di file bundle personalizzato non è molto difficile e risolverebbe anche alcuni problemi, ma richiederebbe la creazione di uno strumento per creare il formato bundle. Una soluzione alternativa è utilizzare un formato di archivio esistente per il quale esistono già strumenti e quindi 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 di 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 ai nostri fini, principalmente il tipo e il nome del file, che sono memorizzati in posizioni fisse all'interno dell'intestazione.
I campi di intestazione nel formato TAR vengono archiviati in posizioni fisse con dimensioni fisse nel blocco di intestazione. Ad esempio, il timestamp dell'ultima modifica del file è memorizzato a 136 byte dall'inizio dell'intestazione e ha una lunghezza di 12 byte. Tutti i campi numerici sono codificati come numeri ottali memorizzati in formato ASCII. Per analizzare i campi, li estraiamo dal nostro buffer di array e per i campi numerici chiamiamo parseInt() assicurandoci di passare il secondo parametro per indicare la base ottale desiderata.
Uno dei campi più importanti è il campo Tipo. Si tratta di un numero ottale a una sola cifra che indica il tipo di file contenuto nel record. Ai nostri fini, gli unici due tipi di record interessanti sono i file normali ('0') e le directory ('5'). Se avessimo a che fare con file TAR arbitrari, potremmo anche occuparci di link simbolici ('2') e, possibilmente, di hard link ('1').
Ogni intestazione è seguita immediatamente dai contenuti del file descritti dall'intestazione (ad eccezione dei tipi di file che non hanno contenuti propri, come le directory). I contenuti del file sono seguiti da un riempimento per garantire che ogni intestazione inizi su un limite 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 i byte di riempimento necessari per allineare l'offset a 512 byte, operazione che può essere eseguita facilmente dividendo la lunghezza del file per 512, prendendo il numero intero superiore e moltiplicandolo 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 si adattasse facilmente alla nostra base di codice esistente. Per questo motivo, ho scelto di scriverne una mia. Ho anche dedicato del tempo a ottimizzare il caricamento nel miglior modo possibile e ad assicurarmi che il decodificatore gestisca facilmente sia i dati binari che quelli stringa all'interno dell'archivio.
Uno dei primi problemi che ho dovuto risolvere è stato come caricare 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 nemmeno particolarmente rapida. La conversione in oggetti Image è altrettanto difficile.
Ho deciso di caricare i file TAR come ArrayBuffer direttamente dalla richiesta XHR e di aggiungere una piccola funzione di utilità per convertire i blocchi da ArrayBuffer a una stringa. Al momento il mio codice gestisce solo i caratteri ANSI/8 bit di base, ma questo problema può essere risolto una volta che nei browser sarà disponibile un'API di conversione più comoda.
Il codice esegue la scansione di ArrayBuffer analizzando le intestazioni dei record, che includono tutti i campi di intestazione TAR pertinenti (e alcuni meno pertinenti), nonché la posizione e le dimensioni dei dati dei file all'interno di ArrayBuffer. Il codice può anche estrarre facoltativamente i dati come visualizzazione ArrayBuffer e memorizzarli nell'elenco delle intestazioni dei record restituito.
Il codice è disponibile senza costi con una licenza open source permissiva e di facile utilizzo 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 nuova, ma ha già un'ottima documentazione, incluso l'eccellente articolo di HTML5 Rocks sul file system.
L'API FileSystem non è esente da avvertenze. Innanzitutto, è un'interfaccia basata sugli eventi, il che rende l'API non bloccante, il che è ottimo per la UI, ma anche difficile da usare. L'utilizzo dell'API FileSystem da un WebWorker può alleviare questo problema, ma ciò richiederebbe di dividere l'intero sistema di download e decompressione in un WebWorker. Questo potrebbe anche essere l'approccio migliore, ma non è quello che ho scelto a causa dei limiti di tempo (non avevo ancora familiarità con i service worker), quindi ho dovuto gestire la natura asincrona e basata sugli 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, operazione che si esegue facilmente dividendo la stringa del percorso in base al carattere separatore del percorso (che è sempre la barra, come negli URL). Dopodiché, dobbiamo scorrere ogni elemento dell'elenco risultante, ad eccezione dell'ultimo, creando in modo ricorsivo una directory (se necessario) nel file system locale. Quindi possiamo creare il file, poi creare un FileWriter e infine scrivere i contenuti del file.
Un'altra cosa importante da tenere presente è il limite di dimensioni dei file dello spazio di archiviazione PERSISTENT dell'API FileSystem. Volevamo uno spazio di archiviazione persistente perché quello temporaneo può essere cancellato in qualsiasi momento, anche mentre l'utente sta giocando alla nostra partita, proprio prima che tenti di caricare il file eliminato.
Per le app destinate al Chrome Web Store, non sono previsti limiti di spazio di archiviazione quando viene utilizzata l'autorizzazione unlimitedStorage nel file manifest dell'applicazione. Tuttavia, le app web regolari possono comunque richiedere spazio con l'interfaccia sperimentale di richiesta di quota.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}