Introdução
No verão passado, trabalhei como líder técnico em um jogo comercial da WebGL chamado SONAR. O projeto levou cerca de três meses para ser concluído e foi feito do zero em JavaScript. Durante o desenvolvimento do SONAR, tivemos que encontrar soluções inovadoras para vários problemas em á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 do jogo quando o jogador inicia o jogo?
Outras plataformas têm soluções prontas para esse problema. A maioria dos jogos de console e 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 o Steam ou a App Store, garantem que todos os recursos sejam transferidos por download e instalados antes mesmo 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 flexibilidade necessários e podemos criar um sistema que corresponda exatamente às nossas necessidades.
Recuperação
Antes de usar o armazenamento em cache de recursos, tínhamos um carregador de recursos em cadeia simples. Esse sistema nos permitiu solicitar recursos individuais por caminho relativo, que, por sua vez, podiam solicitar mais recursos. Nossa tela de carregamento apresentava um medidor de progresso simples que avaliava quantos dados ainda precisavam ser carregados e só fazia a transição para a próxima tela depois que a fila do carregador de recursos ficasse vazia.
O design desse sistema nos permitiu alternar facilmente entre recursos empacotados e recursos soltos (desempacotados) disponibilizados em um servidor HTTP local, o que foi fundamental para garantir a iteração rápida do código e dos dados do jogo.
O código a seguir ilustra o design básico do nosso carregador de recursos em cadeia, com o processamento de erros e o código de carregamento de XHR/imagem mais avançado removido para manter as coisas legíveis.
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 é bastante simples, mas também muito flexível. O código inicial do jogo pode solicitar alguns arquivos de dados que descrevem o nível inicial e os objetos do jogo. Por exemplo, eles podem ser arquivos JSON simples. O callback usado para esses arquivos inspeciona esses dados e pode fazer outras solicitações (solicitações em cadeia) para 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 principal ResourceLoader
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.
É claro que é possível fazer muito mais com essa interface. Como exercícios para o leitor, alguns outros recursos que vale a pena investigar são adicionar suporte a progresso/porcentagem, adicionar carregamento de imagem (usando o tipo "Imagem"), adicionar análise automática de arquivos JSON e, claro, o processamento de erros.
O recurso mais importante para este artigo é o campo baseurl, que permite alternar facilmente a origem dos arquivos solicitados. É fácil configurar o mecanismo principal para permitir que um tipo ?uselocal
de parâmetro de consulta no URL solicite 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 embalagem
Um problema com o carregamento em cadeia de recursos é que não há como conseguir uma contagem de bytes completa 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 fazer o download de todo o conteúdo e armazená-lo em cache, o que pode levar um tempo maior para jogos maiores, é muito importante mostrar ao jogador uma boa caixa de diálogo de progresso.
A solução mais fácil para esse problema (que também oferece algumas outras vantagens) é empacotar todos os arquivos de recursos em um único pacote, que será feito o download com uma única chamada XHR, que fornece os eventos de progresso necessários para exibir uma boa barra de progresso.
Criar um formato de arquivo de pacote personalizado não é muito difícil e até resolve alguns problemas, mas exige a criação de uma ferramenta para criar o formato do pacote. Uma solução alternativa é usar um formato de arquivo para o qual já existem ferramentas e, em seguida, escrever um decodificador para execução 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, decidimos usar 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 com 512 bytes. O cabeçalho tem apenas alguns campos relevantes ou interessantes para nossos propósitos, principalmente o tipo e o nome do arquivo, que são armazenados em posições fixas no cabeçalho.
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. Todos os campos numéricos são codificados como números octais armazenados no 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 de tipo. É um número octal de um único dígito que informa o tipo de arquivo que o registro contém. Os únicos dois tipos de registro interessantes para nossos propósitos são arquivos normais ('0'
) e diretórios ('5'
). Se estivéssemos lidando com arquivos TAR arbitrários, também poderíamos nos preocupar com links simbólicos ('2'
) e links físicos ('1'
).
Cada cabeçalho é seguido imediatamente pelo conteúdo do arquivo descrito por ele (exceto tipos de arquivos que não têm conteúdo próprio, como diretórios). O conteúdo do arquivo é seguido por um padding para garantir que cada cabeçalho comece 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) ao comprimento do conteúdo do arquivo extraído do cabeçalho. Por fim, adicionamos todos os bytes de preenchimento necessários para alinhar o deslocamento a 512 bytes. Isso pode ser feito facilmente dividindo o comprimento do arquivo por 512, considerando o limite 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. Por isso, decidi escrever o meu. Também me dediquei a otimizar o carregamento o máximo possível e garantir que o decodificador processe facilmente 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. Comecei com uma abordagem de "string binária". Infelizmente, a conversão de strings binárias para formas binárias mais facilmente utilizáveis, como ArrayBuffer
, não é simples, nem essas conversões são particularmente rápidas. A conversão para objetos Image
também é 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-bit básicos, mas isso pode ser corrigido quando uma API de conversão mais conveniente estiver disponível nos navegadores.
O código simplesmente analisa 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 o local 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 registro retornada.
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.
API FileSystem
Para armazenar o conteúdo do arquivo e acessá-lo mais tarde, usamos a API FileSystem. A API é bastante nova, mas já tem uma documentação excelente, incluindo o excelente artigo do FileSystem do HTML5 Rocks.
A API FileSystem tem algumas ressalvas. Por exemplo, ela é uma interface orientada a eventos. Isso faz com que a API não seja bloqueante, o que é ótimo para a interface, mas também a torna difícil de usar. Usar a API FileSystem em um WebWorker pode aliviar esse problema, mas isso exigiria dividir todo o sistema de download e descompactação em um WebWorker. Essa pode até ser a melhor abordagem, mas não foi a que usei devido a restrições de tempo (ainda não conhecia o WorkWorkers), então tive que lidar com a natureza assíncrona e orientada a eventos da API.
Nossas necessidades estão principalmente focadas na gravação de arquivos em uma estrutura de diretórios. Isso requer uma série de etapas para cada arquivo. Primeiro, precisamos transformar o caminho do arquivo em uma lista, o que é fácil de fazer dividindo a string do caminho no caractere separador de caminho (que é sempre o caractere barra, como 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, criar um FileWriter
e, por fim, gravar o conteúdo do arquivo.
Outra coisa importante a considerar é o limite de tamanho do armazenamento PERSISTENT
da API FileSystem. Queríamos armazenamento persistente porque o armazenamento temporário pode ser limpo a qualquer momento, inclusive enquanto o usuário está jogando nosso jogo, logo antes de tentar carregar o arquivo excluído.
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 normais 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
);
}