Case study - SONAR, sviluppo di giochi HTML5

[Nome di persona]
Sean Middleditch

Introduzione

L'estate scorsa ho lavorato come responsabile tecnico per un gioco WebGL commerciale chiamato SONAR. Il completamento del progetto ha richiesto circa tre mesi ed è stato fatto completamente da zero in JavaScript. Durante lo sviluppo di SONAR, abbiamo dovuto trovare soluzioni innovative a una serie di problemi nelle nuove e non testate soluzioni HTML5. In particolare, avevamo bisogno di una soluzione a un problema apparentemente semplice: come facciamo a scaricare e memorizzare nella cache oltre 70 MB di dati di gioco quando il giocatore avvia il gioco?

Altre piattaforme dispongono di soluzioni già pronte per questo problema. La maggior parte delle console e dei giochi per PC carica risorse da un CD/DVD locale o da un disco rigido. Flash può pacchettizzare tutte le risorse come parte del file SWF che contiene il gioco, mentre Java può fare lo stesso con i file JAR. Le piattaforme di distribuzione digitale come Steam o App Store assicurano che tutte le risorse vengano scaricate e installate prima che il giocatore possa ancora iniziare il gioco.

L'HTML5 non ci offre questi meccanismi, ma ci fornisce tutti gli strumenti necessari per creare il nostro sistema di download di risorse di gioco. Il vantaggio di costruire un nostro sistema è che otteniamo tutto il controllo e la flessibilità di cui abbiamo bisogno e siamo in grado di creare un sistema che soddisfi esattamente le nostre esigenze.

Recupero

Prima di avviare la memorizzazione nella cache delle risorse, avevamo un semplice caricatore di risorse concatenato. Questo sistema ci ha consentito di richiedere singole risorse in base al percorso relativo, il che potrebbe a sua volta richiedere più risorse. La schermata di caricamento mostrava un semplice indicatore di avanzamento che mostrava la quantità di dati da caricare in più 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 in pacchetto alle risorse sfuse (non in pacchetto) pubblicate su un server HTTP locale, il che è stato davvero fondamentale per garantire la rapida iterazione sia del codice di gioco che dei dati.

Il seguente codice illustra il design di base del nostro caricatore di risorse concatenato, con la gestione degli errori e la rimozione del codice di caricamento XHR/immagine più avanzato per garantire 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 abbastanza flessibile. Il codice di gioco iniziale può richiedere alcuni file di dati che descrivono il livello iniziale e gli oggetti del gioco. Può trattarsi, ad esempio, di semplici file JSON. Il callback utilizzato per questi file quindi controlla 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 le immagini della 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 che venga richiamato il callback prima di passare alla schermata successiva.

Ovviamente, con questa interfaccia si può 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, l'aggiunta del caricamento delle immagini (utilizzando il tipo di immagine), l'aggiunta dell'analisi automatica dei file JSON e, naturalmente, la gestione degli errori.

La funzionalità più importante di questo articolo è il campo baseurl, che ci consente di cambiare facilmente l'origine dei file richiesti. È facile configurare il motore principale per consentire a un tipo di parametro di query ?uselocal nell'URL di 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 per l'imballaggio

Un problema del caricamento concatenato delle risorse è che non è possibile ottenere un conteggio completo dei byte di tutti i dati. La conseguenza è che non sarà possibile creare una finestra di dialogo di avanzamento semplice e affidabile per i download. Poiché scaricheremo tutti i contenuti e li memorizzeremo nella cache. Ciò può richiedere molto tempo per i giochi più grandi, quindi fornire al giocatore una finestra di dialogo di avanzamento è molto importante.

La soluzione più semplice per questo problema (il che ci offre anche alcuni altri utili vantaggi) consiste nel pacchettizzare tutti i file delle risorse in un unico bundle, che scaricheremo con un'unica chiamata XHR, che ci fornisce gli eventi di avanzamento necessari per visualizzare una bella barra di avanzamento.

Creare un formato file personalizzato per i bundle non è molto difficile e risolverebbe anche alcuni problemi, ma richiederebbe la creazione di uno strumento per creare il formato del bundle. Una soluzione alternativa consiste nell'utilizzare un formato di archivio esistente per il quale esistono già strumenti e quindi dover scrivere un decoder 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.

Il TAR è un formato relativamente semplice. Ogni record (file) ha un'intestazione di 512 byte, seguita dal contenuto del file riempito fino a 512 byte. L'intestazione contiene solo alcuni campi pertinenti o interessanti per i nostri scopi, principalmente il tipo e il nome del file, che vengono memorizzati in posizioni fisse all'interno dell'intestazione.

I campi di intestazione nel formato TAR vengono memorizzati in posizioni fisse con dimensioni fisse nel blocco di intestazione. Ad esempio, il timestamp dell'ultima modifica del file viene archiviato a 136 byte dall'inizio dell'intestazione ed è lungo 12 byte. Tutti i campi numerici sono codificati come numeri ottali memorizzati in formato ASCII. Per analizzare i campi, estraiamo i campi dal buffer dell'array e, per i campi numerici, chiamiamo parseInt(), assicurandoci di passare nel secondo parametro per indicare la base ottale desiderata.

Uno dei campi più importanti è il campo type. Si tratta di un numero ottale di una sola 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 dovessimo avere a che fare con file TAR arbitrari, potrebbero essere importanti anche i link simbolici ('2') e possibilmente i link reali ('1').

Ogni intestazione è seguita immediatamente dai contenuti del file descritto dall'intestazione (ad eccezione dei tipi di file che non hanno contenuti propri, come le directory). I contenuti del file sono seguiti da una spaziatura interna per assicurare che ogni intestazione inizi su un limite di 512 byte. Pertanto, per calcolare la lunghezza totale di un record file in un file TAR, dobbiamo prima leggere l'intestazione del file. Quindi aggiungiamo la lunghezza dell'intestazione (512 byte) con la lunghezza dei contenuti del file estratti dall'intestazione. Infine, aggiungiamo i byte di spaziatura interna necessari per allineare l'offset a 512 byte, cosa che può essere eseguita facilmente dividendo la lunghezza del file per 512, prendendo il tetto massimo del numero e moltiplicando il risultato 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 in giro i lettori TAR esistenti e ne ho trovati alcuni, ma nessuno che non avesse altre dipendenze o che si adatti facilmente al nostro codebase esistente. Per questo motivo, ho scelto di scriverne uno personalizzato. Ho anche dedicato del tempo a ottimizzare al meglio il caricamento e ad assicurarmi che il decoder gestisca facilmente dati binari e stringhe all'interno dell'archivio.

Uno dei primi problemi che ho dovuto risolvere è stato come caricare i dati da una richiesta XHR. All'inizio ho iniziato con un approccio "stringa binaria". Purtroppo, la conversione da stringhe binarie a forme binarie più facilmente utilizzabili come ArrayBuffer non è semplice e neanche queste conversioni sono particolarmente rapide. La conversione in Image oggetti è altrettanto problematica.

Ho scelto di caricare i file TAR come ArrayBuffer direttamente dalla richiesta XHR e di aggiungere una piccola funzione di convenienza per convertire i blocchi da ArrayBuffer in una stringa. Al momento il mio codice gestisce solo caratteri ANSI di base a 8 bit, ma questo problema può essere risolto una volta che è disponibile un'API di conversione più pratica nei browser.

Il codice esegue semplicemente la scansione delle intestazioni del record di analisi ArrayBuffer, che includono tutti i campi dell'intestazione TAR pertinenti (e alcuni non pertinenti), nonché la posizione e le dimensioni dei dati del file all'interno dell'ArrayBuffer. Il codice può anche facoltativamente estrarre i dati sotto forma di vista ArrayBuffer e archiviarli nell'elenco delle intestazioni dei record restituiti.

Il codice è disponibile senza costi con una licenza open source amichevole e permissiva all'indirizzo https://github.com/subsonicllc/TarReader.js.

API FileSystem

Per archiviare i contenuti dei file e accedervi in un secondo momento, abbiamo usato l'API FileSystem. L'API è abbastanza nuova ma dispone già di un'ottima documentazione, tra cui l'eccellente articolo sul file system di HTML5 Rocks.

L'API FileSystem non è esente da avvertenze. Per prima cosa è un'interfaccia basata su eventi. Questo rende l'API non bloccante, ottima per l'interfaccia utente, ma ne rende anche un uso complicato. Utilizzare l'API FileSystem da un WebWorker può alleviare questo problema, ma questo richiederebbe la suddivisione dell'intero sistema di download e decompressione in WebWorker. Forse è l'approccio migliore, ma non è quello che ho scelto a causa dei limiti di tempo (non avevo ancora familiarità con WorkWorkers), quindi ho dovuto fare i conti con la natura asincrona dell'API basata sugli eventi.

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. Per prima cosa, dobbiamo prendere il percorso del file e trasformarlo in un elenco, cosa che può essere eseguita facilmente dividendo la stringa del percorso nel carattere separatore di percorso (che è sempre la barra, come gli URL). Quindi dobbiamo ripetere l'iterazione su ogni elemento dell'elenco risultante, salvando l'ultimo, creando in modo ricorsivo una directory (se necessario) nel file system locale. Possiamo quindi creare il file, creare un FileWriter e infine scrivere il contenuto del file.

Un secondo aspetto importante da considerare è il limite delle dimensioni del file dello spazio di archiviazione (PERSISTENT) dell'API FileSystem. Volevamo spazio di archiviazione permanente perché lo spazio di archiviazione temporaneo può essere cancellato in qualsiasi momento, anche quando l'utente sta giocando al nostro gioco appena prima di tentare di caricare il file rimosso.

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 sperimentale di richiesta della quota.

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