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 para varios problemas en las nuevas y no probadas aguas de HTML5. En particular, necesitábamos una solución para un problema aparentemente simple: ¿cómo descargamos y almacenamos en caché más de 70 MB de datos del juego cuando el jugador lo inicia?
Otras plataformas tienen soluciones listas para este problema. La mayoría de los juegos para consolas y PC cargan recursos desde un CD o DVD local, o desde 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 proporciona estos mecanismos, pero sí nos brinda todas las herramientas que necesitamos para crear 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 tener almacenamiento en caché de recursos, teníamos un simple cargador de recursos encadenado. Este sistema nos permitió solicitar recursos individuales por ruta relativa, lo que a su vez podía solicitar más recursos. Nuestra pantalla de carga presentaba un medidor de progreso simple que indicaba cuántos datos más debían cargarse y realizaba la transición a la siguiente pantalla solo después de que la cola del cargador de recursos estaba vacía.
El diseño de este sistema nos permitió cambiar fácilmente entre los recursos empaquetados y los recursos sueltos (sin empaquetar) que se publicaban a través de un servidor HTTP local, lo que fue fundamental para garantizar que pudiéramos realizar iteraciones rápidamente en el código y los datos del juego.
El siguiente código ilustra el diseño básico de nuestro cargador de recursos encadenado, con el control de errores y el código de carga de imágenes o XHR más avanzado quitados para que sea más legible.
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 muy flexible. El código inicial del juego puede solicitar algunos archivos de datos que describen el nivel inicial del juego y los objetos del juego. Por ejemplo, pueden ser archivos JSON simples. Luego, la devolución de llamada que se usa para estos archivos inspecciona esos datos y puede realizar solicitudes adicionales (solicitudes encadenadas) para las dependencias. El archivo de definición de objetos del juego puede enumerar modelos y materiales, y la devolución de llamada para los materiales puede solicitar imágenes de texturas.
La devolución de llamada oncomplete
adjunta a la instancia principal de ResourceLoader
solo se llamará después de que se carguen 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 mucho más con esta interfaz. Como ejercicios para el lector, algunas funciones adicionales que vale la pena investigar son la incorporación de compatibilidad con el progreso o el porcentaje, la incorporación de la carga de imágenes (con el tipo Image), la incorporación del análisis automático de archivos JSON y, por supuesto, el control de errores.
La función más importante para 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 un parámetro de consulta de tipo ?uselocal
en la URL para solicitar recursos de una URL que publica el mismo servidor web local (como python -m SimpleHTTPServer
) que publicó el documento HTML principal del juego, mientras se usa el sistema de caché si no se establece el parámetro.
Recursos de empaquetado
Un problema con la carga encadenada de 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. Como vamos a descargar todo el contenido y almacenarlo en caché, y esto puede llevar bastante tiempo en el caso de los juegos más grandes, es muy importante mostrarle al jugador un diálogo de progreso agradable.
La solución más sencilla para este problema (que también nos brinda otras ventajas) es empaquetar todos los archivos de recursos en un solo paquete, que descargaremos con una sola llamada a XHR, lo que nos proporciona los eventos de progreso que necesitamos para mostrar una barra de progreso atractiva.
Crear un formato de archivo de paquete personalizado no es demasiado difícil y hasta resolvería algunos problemas, pero requeriría crear 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 para que se ejecute en el navegador. No necesitamos un formato de archivo comprimido porque HTTP ya puede comprimir datos con los algoritmos gzip o deflate 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 un relleno de 512 bytes. El encabezado solo tiene algunos campos relevantes o interesantes para nuestros fines, principalmente el tipo y el nombre del archivo, que se almacenan en posiciones fijas dentro del encabezado.
Los campos de encabezado en el 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 del inicio del encabezado y tiene una longitud de 12 bytes. Todos los campos numéricos se codifican como números octales almacenados en formato ASCII. Para analizar los campos, extraemos los campos de nuestro búfer de matriz y, para los campos numéricos, llamamos a parseInt()
asegurándonos 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 indica qué tipo de archivo contiene el registro. Los únicos dos tipos de registros interesantes para nuestros fines son los archivos normales ('0'
) y los directorios ('5'
). Si trabajáramos con archivos TAR arbitrarios, también nos interesarían los vínculos simbólicos ('2'
) y, posiblemente, los vínculos físicos ('1'
).
A cada encabezado le sigue inmediatamente el contenido del archivo que describe (excepto los tipos de archivos que no tienen contenido propio, como los directorios). Luego, se agrega relleno al contenido del archivo 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, sumamos 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 la compensación se alinee con 512 bytes. Esto se puede hacer fácilmente dividiendo la longitud del archivo por 512, tomando el límite superior del número y, luego, multiplicándolo 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 de TAR existentes y encontré algunos, pero ninguno que no tuviera otras dependencias o que se ajustara fácilmente a nuestra base de código existente. Por este motivo, decidí escribir mi propia versión. También me tomé el tiempo para optimizar la carga lo mejor posible y asegurarme de que el decodificador maneje fácilmente los datos binarios y de cadena dentro del archivo.
Uno de los primeros problemas que tuve que resolver fue cómo cargar los datos desde una solicitud XHR. Originalmente, comencé con un enfoque de "cadena binaria". Desafortunadamente, la conversión de cadenas binarias a formas binarias más fáciles de usar, como un ArrayBuffer
, no es sencilla ni rápida. Convertir a objetos Image
es igual de doloroso.
Decidí cargar los archivos TAR como un ArrayBuffer
directamente desde la solicitud XHR y agregar una pequeña función de conveniencia para convertir fragmentos del ArrayBuffer
en una cadena. Actualmente, mi código solo controla caracteres básicos ANSI/8 bits, pero esto se puede corregir una vez que haya disponible en los navegadores una API de conversión más conveniente.
El código simplemente analiza ArrayBuffer
para extraer los encabezados de los registros, que incluyen todos los campos de encabezado TAR pertinentes (y algunos no tan pertinentes), así como la ubicación y el tamaño de los datos del archivo dentro de ArrayBuffer
. El código también puede extraer los datos de forma opcional como una vista ArrayBuffer
y almacenarlos en la lista de encabezados de registros devueltos.
El código está disponible de forma gratuita bajo 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 ellos más tarde, usamos la API de FileSystem. La API es bastante nueva, pero ya tiene una excelente documentación, incluido el artículo FileSystem de HTML5 Rocks.
La API de FileSystem tiene sus limitaciones. Por un lado, es una interfaz controlada por eventos, lo que hace que la API no se bloquee, lo que es excelente para la IU, pero también hace que sea difícil de usar. Usar la API de FileSystem desde un WebWorker puede aliviar este problema, pero eso requeriría dividir todo el sistema de descarga y desempaquetado en un WebWorker. Incluso podría ser el mejor enfoque, pero no fue el que elegí debido a las limitaciones de tiempo (aún no conocía los Service Workers), por lo que tuve que lidiar con la naturaleza asíncrona basada en 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 al archivo y convertirla en una lista, lo que se hace fácilmente dividiendo la cadena de ruta en el carácter separador de ruta (que siempre es la barra diagonal, como en las URLs). Luego, debemos iterar sobre cada elemento de la lista resultante, excepto el último, y crear de forma recursiva un directorio (si es necesario) en el sistema de archivos local. Luego, podemos crear el archivo, crear un FileWriter
y, finalmente, escribir el contenido del archivo.
Un segundo aspecto importante que se debe tener en cuenta es el límite de tamaño de archivo del almacenamiento PERSISTENT
de la API de FileSystem. Queríamos un almacenamiento persistente porque el almacenamiento temporal se puede borrar en cualquier momento, incluso mientras el usuario está jugando y justo antes de que se 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 experimental de solicitud de cuota.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}