Estudo de caso: SONAR, desenvolvimento de jogos em HTML5

Sean middleditch
Sean Middleditch

Introdução

No verão passado, trabalhei como líder técnico de um jogo comercial 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 nos ambientes novos e ainda não testados 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 consoles e jogos de 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 Java pode fazer o mesmo com arquivos JAR. Plataformas de distribuição digital como Steam ou App Store garantem que todos os recursos sejam transferidos por download e instalados antes mesmo de o jogador começar o jogo.

O HTML5 não nos oferece esses mecanismos, mas nos fornece 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 de que precisamos e podemos criar um sistema que corresponda exatamente às nossas necessidades.

Recuperação

Antes de termos armazenamento em cache de recursos, tínhamos um carregador de recursos encadeado simples. Com esse sistema, pudemos solicitar recursos individuais por caminho relativo, o que, por sua vez, poderia solicitar mais recursos. Nossa tela de carregamento apresentou um medidor de progresso simples que avaliava quantos dados precisavam ser carregados, e fez a transição para a próxima tela somente depois que a fila do carregador de recursos estava vazia.

O design desse sistema nos permitiu alternar facilmente entre recursos empacotados e recursos independentes (não empacotados) atendidos por um servidor HTTP local, o que foi realmente fundamental para garantir que pudéssemos iterar rapidamente no código do jogo e nos dados.

O código a seguir ilustra o design básico do nosso carregador de recursos encadeados, com a manipulação de erros e o código mais avançado de carregamento de imagem/XHR 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 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. Podem ser arquivos JSON simples, por exemplo. O callback usado para esses arquivos inspeciona esses dados e pode fazer outras solicitações (encadeadas) para dependências. O arquivo de definição de objetos do jogo pode listar modelos e materiais, e o callback de materiais pode solicitar imagens de textura.

O callback oncomplete anexado à instância principal do ResourceLoader só será chamado depois que todos os recursos forem carregados. A tela de carregamento do jogo pode simplesmente esperar que esse callback seja invocado antes de fazer a transição para a próxima tela.

Obviamente, é possível fazer muito mais com essa interface. Como exercícios para o leitor, alguns recursos extras que valem a pena investigar são a adição de suporte de progresso/porcentagem, adição de carregamento de imagem (usando o tipo de imagem), adição de análises automáticas de arquivos JSON e, é claro, o tratamento 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 parâmetro de consulta do tipo ?uselocal no URL solicite recursos de um URL exibido pelo mesmo servidor da Web local (como python -m SimpleHTTPServer) que veiculou o documento HTML principal do jogo enquanto usa o sistema de cache se o parâmetro não estiver definido.

Recursos de empacotamento

Um problema do carregamento encadeado de recursos é que não há como conseguir 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 fazer o download de todo o conteúdo e armazená-lo em cache, o que pode levar muito tempo em jogos maiores, é muito importante oferecer ao jogador uma boa caixa de diálogo de progresso.

A soluçã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 será transferido por download com uma única chamada XHR, que nos dá 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 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 existente para o qual já existem ferramentas e, em seguida, programar um decodificador para ser executado no navegador. Não precisamos de um formato de arquivo compactado porque o HTTP já pode compactar dados usando gzip ou desflar os algoritmos sem problemas. 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 objetivos, 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 a partir do início do cabeçalho e tem 12 bytes. Todos os campos numéricos são codificados como números octal armazenados em formato ASCII. Para analisar os campos, eles são extraídos do buffer de matriz e, para campos numéricos, chamamos parseInt() para transmitir o segundo parâmetro para indicar a base octal desejada.

Um dos campos mais importantes é o de tipo. Este é um número octal de um só dígito que informa o tipo de arquivo que o registro contém. Os únicos dois tipos de registro interessantes para nossos objetivos são arquivos regulares ('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 possivelmente links físicos ('1').

Cada cabeçalho é seguido imediatamente pelo conteúdo do arquivo descrito por ele (exceto tipos de arquivo que não têm conteúdo próprio, como diretórios). O conteúdo do arquivo é seguido por padding para garantir que cada cabeçalho comece em um limite de 512 bytes. Assim, para calcular o comprimento total de um registro de arquivo em um arquivo TAR, primeiro devemos 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 todos os bytes de preenchimento necessários para alinhar o deslocamento em 512 bytes, o que pode ser feito facilmente ao dividir o comprimento do arquivo por 512, tomar o teto do número e, em seguida, multiplicar 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 TAR existentes e encontrei alguns, mas nenhum que não tivesse outras dependências ou que se encaixaria facilmente em nossa base de código existente. Por isso, decidi escrever o meu próprio. Também dediquei um tempo para otimizar o carregamento da melhor forma possível e garantir que o decodificador gerenciasse facilmente os dados binários e de string dentro do arquivo.

Um dos primeiros problemas que tive que resolver foi como obter os dados carregados de uma solicitação XHR. No início, 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 um ArrayBuffer, não é simples, e essas conversões não são particularmente rápidas. Converter em objetos Image também é uma tarefa difícil.

Fiz a escolha de carregar os arquivos TAR como um ArrayBuffer diretamente da solicitação XHR e adicionar uma pequena função de conveniência para converter blocos do ArrayBuffer em uma string. Atualmente, meu código lida apenas com 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 simplesmente verifica o ArrayBuffer analisando cabeçalhos de registro, que inclui 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, opcionalmente, 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 compatível e permissiva em https://github.com/subsonicllc/TarReader.js.

API FileSystem

Para armazenar o conteúdo dos arquivos e acessá-los mais tarde, usamos a API FileSystem. A API é bastante nova, mas já possui uma ótima documentação, incluindo o excelente artigo HTML5 Rocks FileSystem.

A API FileSystem não está isenta de ressalvas. Por um lado, é uma interface orientada a eventos. Isso torna a API sem bloqueio, o que é ótimo para a interface de usuário, 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 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 orientada a eventos da API.

Nossas necessidades estão concentradas principalmente em gravar 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 é feito facilmente ao dividir a string do caminho usando o caractere separador de caminho (que é sempre a barra, como URLs). Em seguida, precisamos iterar em cada elemento da lista resultante, salvar o último, criando recursivamente um diretório (se necessário) no sistema de arquivos local. Em seguida, podemos criar o arquivo, criar uma FileWriter e, por fim, gravar o conteúdo dele.

Outra coisa importante a considerar é o limite de tamanho de arquivo do armazenamento de PERSISTENT da API FileSystem. Queríamos armazenamento permanente porque ele pode ser apagado a qualquer momento, inclusive enquanto o usuário está no meio do jogo antes de tentar carregar o arquivo removido.

Para apps destinados à 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
  );
}