Прошлым летом я работал техническим руководителем коммерческой игры WebGL под названием SONAR . Проект занял около трёх месяцев и был полностью написан с нуля на JavaScript. В ходе разработки SONAR нам пришлось искать инновационные решения ряда проблем в новых и непроверенных условиях HTML5. В частности, нам нужно было решить, казалось бы, простую задачу: как загрузить и кэшировать более 70 МБ игровых данных при запуске игры?
На других платформах есть готовые решения этой проблемы. Большинство игр для консолей и ПК загружают ресурсы с локального CD/DVD или жёсткого диска. Flash может упаковать все ресурсы в SWF-файл, содержащий игру, а Java — с JAR-файлами. Платформы цифровой дистрибуции, такие как Steam или App Store, гарантируют загрузку и установку всех ресурсов ещё до того, как игрок сможет запустить игру.
HTML5 не предоставляет нам этих механизмов, но предоставляет все необходимые инструменты для создания собственной системы загрузки игровых ресурсов. Преимущество разработки собственной системы заключается в том, что мы получаем весь необходимый контроль и гибкость, а также можем создать систему, которая точно соответствует нашим потребностям.
Извлечение
До появления кэширования ресурсов у нас был простой цепочечный загрузчик ресурсов. Эта система позволяла нам запрашивать отдельные ресурсы по относительному пути, что, в свою очередь, позволяло запрашивать дополнительные ресурсы. Наш экран загрузки представлял собой простой индикатор прогресса, который отслеживал объём данных, необходимых для загрузки, и переходил к следующему экрану только после опустошения очереди загрузчика ресурсов.
Конструкция этой системы позволяла нам легко переключаться между упакованными ресурсами и свободными (неупакованными) ресурсами, обслуживаемыми через локальный HTTP-сервер, что сыграло решающую роль в обеспечении возможности быстрой итерации как игрового кода, так и данных.
Следующий код иллюстрирует базовую конструкцию нашего загрузчика связанных ресурсов, в котором обработка ошибок и более сложный код загрузки XHR/изображений удалены для удобства чтения.
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();
};
Использование этого интерфейса довольно простое, но в то же время весьма гибкое. Исходный код игры может запрашивать файлы данных, описывающие начальный уровень игры и игровые объекты. Это могут быть, например, простые JSON-файлы. Обратный вызов, используемый для этих файлов, затем анализирует эти данные и может выполнять дополнительные запросы (цепочечные запросы) для определения зависимостей. Файл определения игровых объектов может содержать список моделей и материалов, а обратный вызов для материалов может запрашивать изображения текстур.
Обратный вызов oncomplete , прикреплённый к основному экземпляру ResourceLoader , будет вызван только после загрузки всех ресурсов. Экран загрузки игры может просто дождаться вызова этого обратного вызова, прежде чем перейти к следующему экрану.
Конечно, с этим интерфейсом можно сделать гораздо больше. В качестве упражнений для читателя стоит рассмотреть несколько дополнительных функций, таких как поддержка прогресса/процентов, загрузка изображений (с использованием типа Image), автоматический разбор JSON-файлов и, конечно же, обработка ошибок.
Важнейшей функцией в этой статье является поле baseurl, которое позволяет легко переключаться между источниками запрашиваемых файлов. Легко настроить ядро движка так, чтобы параметр запроса типа ?uselocal в URL-адресе позволял запрашивать ресурсы с URL-адреса, обслуживаемого тем же локальным веб-сервером (например, python -m SimpleHTTPServer ), который обслуживал основной HTML-документ игры, используя при этом систему кэширования, если этот параметр не задан.
Ресурсы упаковки
Одна из проблем с последовательной загрузкой ресурсов заключается в невозможности получить полный объём всех данных в байтах. В результате невозможно создать простой и надёжный диалог прогресса загрузки. Поскольку мы будем загружать весь контент и кэшировать его, а это может занять довольно много времени в больших играх, очень важно предоставить игроку удобный диалог прогресса.
Самое простое решение этой проблемы (которое также дает нам несколько других приятных преимуществ) — упаковать все файлы ресурсов в один пакет, который мы загрузим с помощью одного вызова XHR, что даст нам события прогресса, необходимые для отображения удобного индикатора прогресса.
Создание собственного формата файла пакета не так уж сложно и даже решило бы некоторые проблемы, но потребовало бы создания инструмента для его создания. Альтернативное решение — использовать существующий формат архива, для которого уже существуют инструменты, а затем написать декодер для работы в браузере. Нам не нужен сжатый формат архива, поскольку HTTP уже прекрасно сжимает данные с помощью алгоритмов gzip или deflate. По этим причинам мы остановились на формате файла TAR.
TAR — относительно простой формат. Каждая запись (файл) имеет заголовок размером 512 байт, за которым следует содержимое файла, дополненное до 512 байт. Заголовок содержит лишь несколько релевантных или интересных для нас полей, в основном тип и имя файла, которые хранятся в фиксированных позициях внутри заголовка.
Поля заголовков в формате TAR хранятся в фиксированных местах и имеют фиксированный размер в блоке заголовка. Например, метка времени последнего изменения файла хранится в 136 байтах от начала заголовка и имеет длину 12 байт. Все числовые поля кодируются восьмеричными числами в формате ASCII. Для анализа полей мы извлекаем их из буфера массива, а для числовых полей вызываем функцию parseInt() обязательно передавая второй параметр, указывающий желаемую восьмеричную основу.
Одно из важнейших полей — поле «Тип». Это однозначное восьмеричное число, указывающее тип файла, содержащегося в записи. Для наших целей интерес представляют только два типа записей: обычные файлы ( '0' ) и каталоги ( '5' ). Если бы мы работали с произвольными TAR-файлами, нам также могли бы быть интересны символические ссылки ( '2' ) и, возможно, жёсткие ссылки ( '1' ).
За каждым заголовком сразу следует содержимое файла, описанного этим заголовком (за исключением типов файлов, не имеющих собственного содержимого, например, каталогов). Затем за содержимым файла следует заполнение, гарантирующее, что каждый заголовок начинается с границы в 512 байт. Таким образом, чтобы вычислить общую длину записи файла в TAR-файле, сначала необходимо прочитать заголовок файла. Затем мы складываем длину заголовка (512 байт) с длиной содержимого файла, извлечённого из заголовка. Наконец, мы добавляем необходимые байты заполнения, чтобы смещение стало равным 512 байтам. Это можно легко сделать, разделив длину файла на 512, взяв максимальное значение и умножив на 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
};
};
Я поискал существующие программы для чтения TAR-файлов и нашёл несколько, но ни одна из них не имела бы других зависимостей и не вписывалась бы в нашу существующую кодовую базу. Поэтому я решил написать свою. Я также постарался максимально оптимизировать загрузку и убедиться, что декодер легко обрабатывает как двоичные, так и строковые данные в архиве.
Одной из первых проблем, которые мне пришлось решить, было как загрузить данные из XHR-запроса. Изначально я начал с подхода с использованием «двоичной строки». К сожалению, преобразование двоичных строк в более удобные двоичные формы, такие как ArrayBuffer , не так просто, да и такие преобразования не отличаются быстротой. Преобразование в объекты Image также вызывает трудности.
Я остановился на загрузке TAR-файлов как ArrayBuffer непосредственно из XHR-запроса и добавил небольшую удобную функцию для преобразования фрагментов из ArrayBuffer в строку. Сейчас мой код обрабатывает только базовые символы ANSI/8-бит, но это можно будет исправить, когда в браузерах появится более удобный API для конвертации.
Код просто сканирует ArrayBuffer , анализируя заголовки записей, включая все соответствующие поля заголовка TAR (и несколько менее важных), а также местоположение и размер данных файла в ArrayBuffer . Код также может опционально извлекать данные в виде представления ArrayBuffer и сохранять его в возвращаемом списке заголовков записей.
Код доступен бесплатно по дружественной разрешительной лицензии Open Source по адресу https://github.com/subsonicllc/TarReader.js .
API файловой системы
Для хранения содержимого файлов и последующего доступа к нему мы использовали API FileSystem. Этот API довольно новый, но уже имеет отличную документацию, включая отличную статью HTML5 Rocks FileSystem .
API FileSystem не лишено недостатков. Во-первых, это интерфейс, управляемый событиями; это делает API неблокирующим, что отлично для пользовательского интерфейса, но и усложняет его использование. Использование API FileSystem из WebWorker может решить эту проблему, но это потребовало бы выделения всей системы загрузки и распаковки в WebWorker. Возможно, это даже лучший подход, но я не выбрал его из-за ограничений по времени (я ещё не был знаком с WorkWorker), поэтому мне пришлось иметь дело с асинхронной природой API, управляемой событиями.
Наши потребности в основном сосредоточены на записи файлов в структуру каталогов. Это требует выполнения ряда шагов для каждого файла. Сначала нам нужно взять путь к файлу и преобразовать его в список, что легко сделать, разделив строку пути по символу-разделителю пути (который всегда является косой чертой, как в URL). Затем нам нужно пройтись по каждому элементу в полученном списке, за исключением последнего, рекурсивно создавая каталог (при необходимости) в локальной файловой системе. Затем мы можем создать файл, затем создать FileWriter и, наконец, записать содержимое файла.
Второй важный момент, который следует учитывать, — это ограничение на размер файла в PERSISTENT хранилище FileSystem API. Мы хотели использовать постоянное хранилище, поскольку временное хранилище можно очистить в любой момент, в том числе во время игры, непосредственно перед тем, как пользователь попытается загрузить удалённый файл.
Для приложений, ориентированных на Chrome Web Store, ограничения по объёму хранилища отсутствуют при использовании разрешения unlimitedStorage в файле манифеста приложения. Однако обычные веб-приложения по-прежнему могут запрашивать дисковое пространство с помощью экспериментального интерфейса запроса квот.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}