Caso de éxito: SONAR, HTML5 Game Development

Sean Middleditch,
Sean Middleditch

Introducción

El verano pasado, trabajé como líder técnico en un juego comercial de WebGL llamado SONAR. El proyecto tardó alrededor de tres meses en completarse y se hizo completamente desde cero en JavaScript. Durante el desarrollo de SONAR, tuvimos que encontrar soluciones innovadoras a varios problemas en el entorno nuevo y no probado de HTML5. En particular, necesitábamos una solución a un problema aparentemente simple: ¿cómo descargamos y almacenamos en caché más de 70 MB de datos de juego cuando el jugador inicia el juego?

Otras plataformas tienen soluciones prediseñadas para este problema. La mayoría de las consolas y los juegos para PC cargan recursos de un CD/DVD local o de un disco duro. Flash puede empaquetar todos los recursos como parte del archivo SWF que contiene el juego, y Java puede hacer lo mismo con los archivos JAR. Las plataformas de distribución digital, como Steam o App Store, garantizan que todos los recursos se descarguen e instalen antes de que el jugador pueda iniciar el juego.

HTML5 no nos brinda estos mecanismos, pero sí todas las herramientas que necesitamos para construir nuestro propio sistema de descarga de recursos de juegos. La ventaja de crear nuestro propio sistema es que obtenemos todo el control y la flexibilidad que necesitamos, y podemos crear un sistema que coincida exactamente con nuestras necesidades.

Recuperación

Antes de que existiera el almacenamiento en caché de recursos, teníamos un cargador de recursos en cadena simple. Este sistema nos permitió solicitar recursos individuales por ruta de acceso relativa, lo que, a su vez, podría solicitar más recursos. Nuestra pantalla de carga presentó un medidor de progreso simple que calculaba la cantidad de datos que debían cargarse y pasó a la siguiente pantalla solo después de que la cola del cargador de recursos estuviera vacía.

El diseño de este sistema nos permitió cambiar fácilmente entre recursos empaquetados y recursos sueltos (sin empaquetar) que se entregan a través de un servidor HTTP local, lo que fue realmente fundamental para garantizar que pudiéramos iterar con rapidez tanto el código del juego como los datos.

El siguiente código ilustra el diseño básico de nuestro cargador de recursos en cadena, con el manejo de errores y el código de carga de imágenes o XHR más avanzado eliminado para mantener la legibilidad.

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();
};

El uso de esta interfaz es bastante simple, pero también bastante flexible. El código inicial del juego puede solicitar algunos archivos de datos que describan el nivel y los objetos iniciales del juego. Estos podrían ser, por ejemplo, archivos JSON simples. La devolución de llamada que se usa para estos archivos inspecciona esos datos y puede realizar solicitudes adicionales (solicitudes encadenadas) de dependencias. El archivo de definición de objetos del juego puede enumerar modelos y materiales, y la devolución de llamada para materiales puede solicitar imágenes de textura.

Solo se llamará a la devolución de llamada oncomplete adjunta a la instancia principal de ResourceLoader después de que se hayan cargado todos los recursos. La pantalla de carga del juego puede esperar a que se invoque esa devolución de llamada antes de pasar a la siguiente pantalla.

Por supuesto, se puede hacer bastante más con esta interfaz. Como ejercicios para el lector, algunas funciones adicionales que vale la pena investigar son agregar compatibilidad con el progreso o el porcentaje, agregar la carga de imágenes (con el tipo de imagen), el análisis automático de archivos JSON y, por supuesto, el manejo de errores.

La función más importante de este artículo es el campo baseurl, que nos permite cambiar fácilmente la fuente de los archivos que solicitamos. Es fácil configurar el motor principal para permitir que un tipo ?uselocal de parámetro de consulta en la URL solicite recursos de una URL entregada por el mismo servidor web local (como python -m SimpleHTTPServer) que entregó el documento HTML principal del juego, mientras se usa el sistema de caché si no se estableció el parámetro.

Recursos de empaquetado

Un problema con la carga en cadena de los recursos es que no hay forma de obtener un recuento completo de bytes de todos los datos. La consecuencia de esto es que no hay forma de crear un diálogo de progreso simple y confiable para las descargas. Dado que descargaremos todo el contenido y lo almacenaremos en caché, y esto puede demorar bastante en el caso de los juegos más grandes, es muy importante brindarle al jugador un diálogo de progreso agradable.

La solución más fácil para este problema (que también nos da otras ventajas útiles) es empaquetar todos los archivos de recursos en un único paquete, que descargaremos con una sola llamada XHR, que nos proporciona los eventos de progreso que necesitamos para mostrar una buena barra de progreso.

Compilar un formato de archivo de paquete personalizado no es muy difícil e incluso resolvería algunos problemas, pero requeriría la creación de una herramienta para crear el formato de paquete. Una solución alternativa es usar un formato de archivo existente para el que ya existen herramientas y, luego, escribir un decodificador que se ejecute en el navegador. No necesitamos un formato de archivo comprimido porque HTTP ya puede comprimir datos con gzip o algoritmos de reducción de inflado sin problemas. Por estos motivos, nos decidimos por el formato de archivo TAR.

TAR es un formato relativamente simple. Cada registro (archivo) tiene un encabezado de 512 bytes, seguido del contenido del archivo con padding a 512 bytes. El encabezado solo tiene algunos campos relevantes o interesantes para nuestros fines, principalmente el tipo y el nombre de archivo, que se almacenan en posiciones fijas dentro del encabezado.

Los campos de encabezado en formato TAR se almacenan en ubicaciones fijas con tamaños fijos en el bloque de encabezado. Por ejemplo, la marca de tiempo de la última modificación del archivo se almacena a 136 bytes desde el comienzo del encabezado y tiene 12 bytes de longitud. Todos los campos numéricos están codificados como números octales almacenados en formato ASCII. Para analizar los campos, extraemos los campos de nuestro búfer de array y, para campos numéricos, llamamos a parseInt() y nos aseguramos de pasar el segundo parámetro para indicar la base octal deseada.

Uno de los campos más importantes es el campo de tipo. Es un número octal de un solo dígito que nos indica el tipo de archivo que contiene el registro. Los únicos dos tipos de registro interesantes para nuestros fines son los archivos normales ('0') y los directorios ('5'). Si estuviéramos trabajando con archivos TAR arbitrarios, es posible que también nos importen los vínculos simbólicos ('2') y, posiblemente, los vínculos duros ('1').

Cada encabezado va seguido inmediatamente por el contenido del archivo que describe el encabezado (excepto los tipos de archivo que no tienen contenido propio, como los directorios). Luego, el contenido del archivo va seguido del relleno para garantizar que cada encabezado comience en un límite de 512 bytes. Por lo tanto, para calcular la longitud total de un registro de archivo en un archivo TAR, primero debemos leer el encabezado del archivo. Luego, agregamos la longitud del encabezado (512 bytes) con la longitud del contenido del archivo extraído del encabezado. Por último, agregamos los bytes de padding necesarios para que el desplazamiento se alinee a 512 bytes, lo que se puede hacer fácilmente dividiendo la longitud del archivo por 512, tomando el límite del número y, luego, multiplicando esa información 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
  };
};

Busqué lectores TAR existentes y encontré algunos, pero ninguno que no tenía otras dependencias o que se adaptara fácilmente a nuestra base de código existente. Por esta razón, elegí escribir los míos. También me tomé el tiempo necesario para optimizar la carga lo mejor posible y asegurarme de que el decodificador maneje con facilidad los datos binarios y de cadena dentro del archivo.

Uno de los primeros problemas que tuve que resolver fue cómo cargar los datos de una solicitud XHR. Originalmente, empecé con un enfoque de "cadena binaria". Por desgracia, convertir strings binarias en formas binarias más fáciles de usar, como ArrayBuffer, no es sencillo, ni estas conversiones son particularmente rápidas. La conversión a objetos Image es igual de dolorosa.

Decidí cargar los archivos TAR como ArrayBuffer directamente desde la solicitud XHR y agregar una pequeña función conveniente para convertir fragmentos de ArrayBuffer a una string. Actualmente, mi código solo maneja caracteres ANSI/8 bits básicos, pero esto se puede corregir cuando haya una API de conversiones más conveniente disponible en los navegadores.

El código simplemente analiza el ArrayBuffer analizando los encabezados del registro, que incluye todos los campos relevantes del encabezado TAR (y algunos que no son tan relevantes), así como la ubicación y el tamaño de los datos del archivo dentro de ArrayBuffer. De manera opcional, el código también puede extraer los datos como una vista ArrayBuffer y almacenarlos en la lista de encabezados de registro que se muestra.

El código está disponible sin costo con una licencia de código abierto permisiva y amigable, en https://github.com/subsonicllc/TarReader.js.

API de FileSystem

Para almacenar el contenido de los archivos y acceder a él más tarde, usamos la API de FileSystem. La API es bastante nueva, pero ya tiene una excelente documentación, incluido el excelente artículo del sistema de archivos HTML5 Rocks.

La API de FileSystem no carece de advertencias. Por un lado, se trata de una interfaz controlada por eventos. Esto hace que la API no genere bloqueos, lo cual sea excelente para la IU, pero también dificulta su uso. El uso de la API de FileSystem desde un WebWorker puede solucionar este problema, pero eso requeriría dividir todo el sistema de descarga y desempaquetado en un WebWorker. Ese puede ser el mejor enfoque, pero no es el que utilicé debido a limitaciones de tiempo (aún no estaba familiarizado con WorkWorkers), por lo que tuve que lidiar con la naturaleza asíncrona de eventos de la API.

Nuestras necesidades se centran principalmente en escribir archivos en una estructura de directorios. Esto requiere una serie de pasos para cada archivo. Primero, debemos tomar la ruta de acceso del archivo y convertirla en una lista, lo que se hace fácilmente dividiendo la cadena de la ruta de acceso en el carácter separador de ruta de acceso (que siempre es la barra diagonal, como las URLs). Luego, debemos iterar sobre cada elemento de la lista resultante para guardar el último y crear de forma recurrente un directorio (si es necesario) en el sistema de archivos local. Luego, podremos crear el archivo, un FileWriter y, por último, escribir el contenido del archivo.

Un segundo aspecto importante que se debe tener en cuenta es el límite de tamaño de archivo para el almacenamiento PERSISTENT de la API de FileSystem. Queríamos tener almacenamiento persistente porque este puede liberarse en cualquier momento, incluso mientras el usuario está jugando nuestro juego justo antes de que intente cargar el archivo expulsado.

En el caso de las apps orientadas a Chrome Web Store, no hay límites de almacenamiento cuando se usa el permiso unlimitedStorage en el archivo de manifiesto de la aplicación. Sin embargo, las apps web normales aún pueden solicitar espacio con la interfaz de solicitud de cuota experimental.

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