Практический пример: SONAR, разработка игр на HTML5

Шон Миддлдич
Шон Миддлдич

Введение

Прошлым летом я работал техническим руководителем коммерческой игры на WebGL под названием SONAR . На реализацию проекта ушло около трех месяцев, и он был выполнен полностью с нуля на JavaScript. В ходе разработки SONAR нам пришлось найти инновационные решения ряда проблем в новых и непроверенных водах HTML5. В частности, нам нужно было решение, казалось бы, простой проблемы: как загрузить и кэшировать более 70 МБ игровых данных, когда игрок запускает игру?

На других платформах есть готовые решения этой проблемы. Большинство консолей и компьютерных игр загружают ресурсы с локального CD/DVD или жесткого диска. Flash может упаковать все ресурсы как часть SWF-файла, содержащего игру, а Java может сделать то же самое с файлами JAR. Платформы цифрового распространения, такие как Steam или App Store, гарантируют, что все ресурсы будут загружены и установлены еще до того, как игрок сможет запустить игру.

HTML5 не дает нам этих механизмов, но дает нам все инструменты, необходимые для создания собственной системы загрузки игровых ресурсов. Преимущество создания нашей собственной системы заключается в том, что мы получаем весь необходимый нам контроль и гибкость и можем создать систему, которая точно соответствует нашим потребностям.

Retrieval

До того, как у нас появилось кэширование ресурсов, у нас был простой цепной загрузчик ресурсов. Эта система позволяла нам запрашивать отдельные ресурсы по относительному пути, что, в свою очередь, могло запрашивать больше ресурсов. Наш экран загрузки представлял собой простой индикатор прогресса, который измерял, сколько еще данных необходимо загрузить, и переходил к следующему экрану только после того, как очередь загрузчика ресурсов опустела.

Конструкция этой системы позволяла нам легко переключаться между упакованными ресурсами и свободными (неупакованными) ресурсами, обслуживаемыми через локальный 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 и сохранить их в списке заголовков возвращаемых записей.

Код доступен бесплатно по дружественной, разрешающей лицензии с открытым исходным кодом по адресу https://github.com/subsonicllc/TarReader.js .

API файловой системы

Для фактического хранения содержимого файла и последующего доступа к нему мы использовали API FileSystem. API довольно новый, но уже имеет отличную документацию, включая отличную статью HTML5 Rocks FileSystem .

API файловой системы не лишен своих предостережений. Во-первых, это интерфейс, управляемый событиями; это одновременно делает API неблокирующим, что отлично подходит для пользовательского интерфейса, но в то же время затрудняет его использование. Использование API файловой системы из WebWorker может облегчить эту проблему, но для этого потребуется разделить всю систему загрузки и распаковки на WebWorker. Возможно, это даже лучший подход, но я не выбрал его из-за нехватки времени (я еще не был знаком с WorkWorkers), поэтому мне пришлось иметь дело с асинхронной, управляемой событиями природой API.

Наши потребности в основном сосредоточены на записи файлов в структуру каталогов. Это требует ряда шагов для каждого файла. Во-первых, нам нужно взять путь к файлу и превратить его в список, что легко сделать, разделив строку пути на символ-разделитель пути (который всегда представляет собой косую черту, как в URL-адресах). Затем нам нужно перебрать каждый элемент полученного списка, за исключением последнего, рекурсивно создавая каталог (при необходимости) в локальной файловой системе. Затем мы можем создать файл, затем создать FileWriter и, наконец, записать содержимое файла.

Вторая важная вещь, которую следует принять во внимание, — это ограничение размера файла в PERSISTENT хранилище API файловой системы. Нам нужно было постоянное хранилище, потому что временное хранилище можно очистить в любой момент, в том числе когда пользователь играет в нашу игру, прямо перед тем, как он попытается загрузить вытесненный файл.

Для приложений, предназначенных для Интернет-магазина Chrome, ограничения на объем хранилища отсутствуют при использовании разрешения unlimitedStorage в файле манифеста приложения. Однако обычные веб-приложения по-прежнему могут запрашивать пространство с помощью экспериментального интерфейса запроса квот.

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