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 para varios problemas en el nuevo y sin probar 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 de juegos cuando el jugador inicia el juego?
Otras plataformas tienen soluciones listas para este problema. La mayoría de los juegos de consolas y PC cargan recursos desde un CD/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 y se instalen antes de que el jugador pueda iniciar el juego.
HTML5 no nos brinda estos mecanismos, pero 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 el almacenamiento en caché de recursos, teníamos un cargador de recursos encadenado simple. Este sistema nos permitió solicitar recursos individuales por ruta de acceso relativa, que a su vez podría solicitar más recursos. Nuestra pantalla de carga presentaba un medidor de progreso simple que medía cuántos datos más se debían cargar y pasaba 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 entregaban a través de un servidor HTTP local, lo que fue fundamental para garantizar que pudiéramos iterar rápidamente en el código y los datos del juego.
En el siguiente código, se ilustra el diseño básico de nuestro cargador de recursos encadenado, con la eliminación del manejo de errores y el código de carga de XHR o imagen más avanzado para que todo sea 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 bastante 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 textura.
Solo se llamará a la devolución de llamada oncomplete
adjunta a la instancia principal de ResourceLoader
después de que se carguen todos los recursos. La pantalla de carga del juego solo puede esperar a que se invoque esa devolución de llamada antes de realizar la transición 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 agregar compatibilidad con el progreso o el porcentaje, agregar la carga de imágenes (con el tipo de imagen), agregar el análisis automático de archivos JSON y, por supuesto, la administración 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 un tipo de parámetro de consulta ?uselocal
en la URL para solicitar recursos de una URL que entrega 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 configuró 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. Dado que descargaremos todo el contenido y lo almacenaremos en caché, y esto puede llevar bastante tiempo en juegos más grandes, es importante mostrarle al jugador un diálogo de progreso agradable.
La solución más fácil para este problema (que también nos brinda otras ventajas interesantes) es empaquetar todos los archivos de recursos en un solo paquete, que descargaremos con una sola llamada XHR, lo que nos brinda los eventos de progreso que necesitamos para mostrar una buena barra de progreso.
Crear un formato de archivo de paquete personalizado no es muy difícil y hasta podría resolver 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 ejecutarlo en el navegador. No necesitamos un formato de archivo comprimido porque HTTP ya puede comprimir datos con algoritmos gzip o deflate. Por estos motivos, elegimos 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 relleno 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 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 desde el inicio del encabezado y tiene 12 bytes de longitud. Todos los campos numéricos se codifican como números octal almacenados en formato ASCII. Para analizar los campos, extraemos los campos de nuestro búfer de array y, para los 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 registros interesantes para nuestros fines son los archivos normales ('0'
) y los directorios ('5'
). Si estuviéramos tratando con archivos TAR arbitrarios, también nos interesarían los vínculos simbólicos ('2'
) y, posiblemente, los vínculos duros ('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, el contenido del archivo se completa con 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 entre 512, tomando el techo 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 propio libro. También me tomé el tiempo de 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”. Lamentablemente, la conversión de cadenas binarias a formas binarias más fáciles de usar, como un ArrayBuffer
, no es sencilla ni estas conversiones son particularmente rápidas. La conversión a objetos Image
es igual de difícil.
Me decidí a cargar los archivos TAR como un ArrayBuffer
directamente desde la solicitud XHR y agregar una pequeña función de conveniencia para convertir fragmentos de ArrayBuffer
en una cadena. Actualmente, mi código solo controla caracteres ANSI/8 bits básicos, pero esto se puede corregir una vez que haya una API de conversión más conveniente disponible en los navegadores.
El código simplemente analiza los encabezados de registro de análisis de ArrayBuffer
, que incluyen todos los campos de encabezado de TAR relevantes (y algunos no 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 de forma gratuita 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 ellos más adelante, usamos la API de FileSystem. La API es bastante nueva, pero ya tiene una excelente documentación, incluido el excelente artículo de HTML5 Rocks FileSystem.
La API de FileSystem no está exenta de advertencias. En primer lugar, es una interfaz basada en eventos, lo que hace que la API no sea de bloqueo, lo cual es excelente para la IU, pero también dificulta su uso. Usar la API de FileSystem desde un WebWorker puede aliviar este problema, pero eso requeriría dividir todo el sistema de descarga y descompresión en un WebWorker. Ese podría ser el mejor enfoque, pero no es el que elegí debido a limitaciones de tiempo (aún no estaba familiarizado con WorkWorkers), por lo que tuve que lidiar con la naturaleza asíncrona de la API basada en eventos.
Nuestras necesidades se centran principalmente en escribir archivos en una estructura de directorio. 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 puede hacer fácilmente dividiendo la cadena de ruta de acceso en el carácter separador de ruta de acceso (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 recursivamente un directorio (si es necesario) en el sistema de archivos local. Luego, podemos crear el archivo, crear un FileWriter
y, por último, escribir el contenido del archivo.
Un segundo aspecto importante que debes tener en cuenta es el límite de tamaño de archivo del almacenamiento PERSISTENT
de la API de FileSystem. Queríamos usar el almacenamiento persistente porque el almacenamiento temporal se puede borrar 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 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
);
}