No ano passado, trabalhei como líder técnico em um jogo comercial em WebGL chamado SONAR. O projeto levou cerca de três meses para ser concluído e foi feito completamente do zero em JavaScript. Durante o desenvolvimento do SONAR, tivemos que encontrar soluções inovadoras para vários problemas nas águas novas e não testadas do HTML5. Em particular, precisávamos de uma solução para um problema aparentemente simples: como fazer o download e armazenar em cache mais de 70 MB de dados de jogos quando o jogador inicia o jogo?
Outras plataformas têm soluções prontas para esse problema. A maioria dos consoles e jogos para PC carrega recursos de um CD/DVD local ou de um disco rígido. O Flash pode empacotar todos os recursos como parte do arquivo SWF que contém o jogo, e o Java pode fazer o mesmo com arquivos JAR. Plataformas de distribuição digital, como Steam ou App Store, garantem que todos os recursos sejam baixados e instalados antes que o jogador possa iniciar o jogo.
O HTML5 não oferece esses mecanismos, mas oferece todas as ferramentas necessárias para criar nosso próprio sistema de download de recursos de jogos. A vantagem de criar nosso próprio sistema é que temos todo o controle e a flexibilidade necessários e podemos criar um sistema que corresponda exatamente às nossas necessidades.
Recuperação
Antes de ter o cache de recursos, tínhamos um carregador de recursos encadeado simples. Esse sistema permitia solicitar recursos individuais por caminho relativo, o que, por sua vez, podia solicitar mais recursos. Nossa tela de carregamento apresentava um medidor de progresso simples que avaliava quantos dados ainda precisavam ser carregados e fazia a transição para a próxima tela somente depois que a fila do carregador de recursos estava vazia.
O design desse sistema permitiu alternar facilmente entre recursos empacotados e recursos soltos (não empacotados) veiculados em um servidor HTTP local, o que foi fundamental para garantir que pudéssemos iterar rapidamente no código e nos dados do jogo.
O código a seguir ilustra o design básico do carregador de recursos encadeados, com tratamento de erros e o código mais avançado de carregamento de XHR/imagens removido para facilitar a leitura.
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();
};
O uso dessa interface é bem simples, mas também bastante flexível. O código inicial do jogo pode solicitar alguns arquivos de dados que descrevem o nível e os objetos iniciais do jogo. Por exemplo, arquivos JSON simples. O callback usado para esses arquivos inspeciona os dados e pode fazer outras solicitações (encadeadas) de dependências. O arquivo de definição de objetos do jogo pode listar modelos e materiais, e o callback para materiais pode solicitar imagens de textura.
O callback oncomplete anexado à instância ResourceLoader principal só será chamado depois que todos os recursos forem carregados. A tela de carregamento do jogo pode esperar que esse callback seja invocado antes de fazer a transição para a próxima tela.
É possível fazer muito mais com essa interface. Como exercícios para o leitor, alguns recursos adicionais que valem a pena investigar são: adicionar suporte a progresso/porcentagem, adicionar carregamento de imagens (usando o tipo Image), adicionar análise automática de arquivos JSON e, é claro, tratamento de erros.
O recurso mais importante para este artigo é o campo "baseurl", que permite mudar facilmente a origem dos arquivos que solicitamos. É fácil configurar o mecanismo principal para permitir um parâmetro de consulta do tipo ?uselocal no URL e solicitar recursos de um URL veiculado pelo mesmo servidor da Web local (como python -m SimpleHTTPServer) que veiculou o documento HTML principal do jogo, usando o sistema de cache se o parâmetro não estiver definido.
Recursos de empacotamento
Um problema com o carregamento encadeado de recursos é que não há como obter uma contagem completa de bytes de todos os dados. A consequência disso é que não há como criar uma caixa de diálogo de progresso simples e confiável para downloads. Como vamos baixar e armazenar em cache todo o conteúdo, e isso pode levar um tempo considerável para jogos maiores, é muito importante mostrar ao jogador uma caixa de diálogo de progresso.
A correção mais fácil para esse problema (que também nos dá algumas outras vantagens interessantes) é empacotar todos os arquivos de recursos em um único pacote, que vamos baixar com uma única chamada XHR, o que nos dá os eventos de progresso necessários para mostrar uma barra de progresso interessante.
Criar um formato de arquivo de pacote personalizado não é muito difícil e até resolveria alguns problemas, mas exigiria a criação de uma ferramenta para criar o formato de pacote. Uma solução alternativa é usar um formato de arquivo para o qual já existem ferramentas e escrever um decodificador para ser executado no navegador. Não precisamos de um formato de arquivo compactado porque o HTTP já pode compactar dados usando algoritmos gzip ou deflate. Por esses motivos, escolhemos o formato de arquivo TAR.
O TAR é um formato relativamente simples. Cada registro (arquivo) tem um cabeçalho de 512 bytes, seguido pelo conteúdo do arquivo preenchido até 512 bytes. O cabeçalho tem apenas alguns campos relevantes ou interessantes para nossos fins, principalmente o tipo e o nome do arquivo, que são armazenados em posições fixas dentro dele.
Os campos de cabeçalho no formato TAR são armazenados em locais fixos com tamanhos fixos no bloco de cabeçalho. Por exemplo, o carimbo de data/hora da última modificação do arquivo é armazenado a 136 bytes do início do cabeçalho e tem 12 bytes de comprimento. Todos os campos numéricos são codificados como números octais armazenados em formato ASCII. Para analisar os campos, extraímos os campos do buffer de matriz e, para campos numéricos, chamamos parseInt(), transmitindo o segundo parâmetro para indicar a base octal desejada.
Um dos campos mais importantes é o "type". É um número octal de um único dígito que informa o tipo de arquivo que o registro contém. Os dois únicos tipos de registros interessantes para nossos fins são arquivos regulares ('0') e diretórios ('5'). Se estivéssemos lidando com arquivos TAR arbitrários, também nos importaríamos com links simbólicos ('2') e possivelmente links físicos ('1').
Cada cabeçalho é seguido imediatamente pelo conteúdo do arquivo descrito por ele, exceto os tipos de arquivo que não têm conteúdo próprio, como diretórios. Em seguida, o conteúdo do arquivo é seguido por um padding para garantir que todos os cabeçalhos comecem em um limite de 512 bytes. Portanto, para calcular o comprimento total de um registro de arquivo em um arquivo TAR, primeiro precisamos ler o cabeçalho do arquivo. Em seguida, adicionamos o comprimento do cabeçalho (512 bytes) com o comprimento do conteúdo do arquivo extraído do cabeçalho. Por fim, adicionamos os bytes de padding necessários para alinhar o deslocamento a 512 bytes. Isso pode ser feito facilmente dividindo o comprimento do arquivo por 512, pegando o teto do número e multiplicando por 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
};
};
Procurei leitores de TAR e encontrei alguns, mas nenhum que não tivesse outras dependências ou que se encaixasse facilmente na nossa base de código atual. Por isso, decidi escrever o meu. Também otimizei o carregamento da melhor forma possível e garanti que o decodificador lide facilmente com dados binários e de string no arquivo.
Um dos primeiros problemas que tive que resolver foi como carregar os dados de uma solicitação XHR. Inicialmente, comecei com uma abordagem de "string binária". Infelizmente, converter strings binárias em formas binárias mais fáceis de usar, como um ArrayBuffer, não é simples nem rápido. Converter para objetos Image é igualmente difícil.
Decidi carregar os arquivos TAR como um ArrayBuffer diretamente da solicitação XHR e adicionar uma pequena função de conveniência para converter partes do ArrayBuffer em uma string. No momento, meu código só processa caracteres ANSI/8 bits básicos, mas isso pode ser corrigido quando uma API de conversão mais conveniente estiver disponível nos navegadores.
O código apenas verifica o ArrayBuffer, analisando os cabeçalhos de registro, que incluem todos os campos de cabeçalho TAR relevantes (e alguns não tão relevantes), bem como a localização e o tamanho dos dados do arquivo no ArrayBuffer. O código também pode extrair os dados como uma visualização ArrayBuffer e armazená-los na lista de cabeçalhos de registros retornados.
O código está disponível sem custo financeiro sob uma licença de código aberto amigável e permissiva em https://github.com/subsonicllc/TarReader.js (em inglês).
API FileSystem
Para armazenar o conteúdo dos arquivos e acessá-los mais tarde, usamos a API FileSystem. A API é bem nova, mas já tem uma ótima documentação, incluindo o excelente artigo do FileSystem do HTML5 Rocks (em inglês).
A API FileSystem tem algumas limitações. Para começar, é uma interface orientada a eventos. Isso torna a API não bloqueadora, o que é ótimo para a interface, mas também dificulta o uso. Usar a API FileSystem de um WebWorker pode aliviar esse problema, mas isso exigiria dividir todo o sistema de download e descompactação em um WebWorker. Essa pode ser a melhor abordagem, mas não foi a que usei devido a restrições de tempo (ainda não conhecia os WorkWorkers). Por isso, tive que lidar com a natureza assíncrona e orientada a eventos da API.
Nossas necessidades estão focadas principalmente na gravação de arquivos em uma estrutura de diretórios. Isso exige uma série de etapas para cada arquivo. Primeiro, precisamos pegar o caminho do arquivo e transformá-lo em uma lista. Isso é fácil de fazer: basta dividir a string do caminho no caractere separador de caminho, que é sempre a barra (como nos URLs). Em seguida, precisamos iterar sobre cada elemento na lista resultante, exceto o último, criando recursivamente um diretório (se necessário) no sistema de arquivos local. Em seguida, podemos criar o arquivo, um FileWriter e, por fim, escrever o conteúdo do arquivo.
Outro aspecto importante a ser considerado é o limite de tamanho de arquivo do armazenamento PERSISTENT da API FileSystem. Queríamos um armazenamento persistente porque o temporário pode ser limpo a qualquer momento, inclusive enquanto o usuário está jogando, logo antes de o jogo tentar carregar o arquivo removido.
Para apps direcionados à Chrome Web Store, não há limites de armazenamento ao usar a permissão unlimitedStorage no arquivo de manifesto do aplicativo. No entanto, os apps da Web comuns ainda podem solicitar espaço com a interface experimental de solicitação de cota.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}