案例研究 - SONAR,HTML5 游戏开发

Sean Middleditch
Sean Middleditch

简介

去年夏天,我担任了一款名为 SONAR 的商业 WebGL 游戏的技术主管。该项目大约花了三个月的时间才完成,并且完全是从头开始用 JavaScript 编写的。在开发 SONAR 的过程中,我们不得不针对尚未经过测试的新版 HTML5 找到创新的解决方案,以解决其中的许多问题。特别是,我们需要解决一个看似简单的问题:如何在玩家启动游戏时下载并缓存 70 多 MB 的游戏数据?

其他平台针对此问题提供了现成的解决方案。大多数游戏机和 PC 游戏都会从本地 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 文件。然后,用于这些文件的回调会检查这些数据,并可以针对依赖项发出其他请求(链式请求)。游戏对象定义文件可能会列出模型和材质,然后材质的回调可能会请求纹理图片。

仅在加载所有资源后,系统才会调用附加到主 ResourceLoader 实例的 oncomplete 回调。游戏加载屏幕只需等待调用该回调,然后再转换到下一个屏幕。

当然,您还可以通过此接口执行更多操作。作为读者的练习,值得研究的其他一些功能包括添加进度/百分比支持、添加图片加载(使用 Image 类型)、添加 JSON 文件的自动解析,当然还有错误处理。

本文最重要的功能是 baseurl 字段,它可让我们轻松切换请求的文件来源。您可以轻松设置核心引擎,以允许网址中的 ?uselocal 类型查询参数从为游戏提供主要 HTML 文档的同一本地 Web 服务器(例如 python -m SimpleHTTPServer)提供的网址请求资源,同时在未设置该参数时使用缓存系统。

打包资源

资源链式加载的一个问题是,无法获取所有数据的完整字节数。因此,您无法为下载内容创建简单、可靠的进度对话框。由于我们将下载并缓存所有内容,对于大型游戏,这可能需要很长时间,因此向玩家提供一个漂亮的进度对话框非常重要。

解决此问题的最简单方法(同时还会带来一些其他好处)是将所有资源文件打包到一个 bundle 中,然后通过单次 XHR 调用下载该 bundle,从而获得显示漂亮进度条所需的进度事件。

构建自定义软件包文件格式并不难,甚至可以解决一些问题,但需要创建用于创建软件包格式的工具。另一种解决方案是使用现有的归档格式(已有相应工具),然后编写要在浏览器中运行的解码器。我们不需要压缩归档格式,因为 HTTP 已经可以使用 gzip 或 deflate 算法轻松压缩数据。因此,我们选择了 TAR 文件格式。

TAR 是一种相对简单的格式。每条记录(文件)都有一个 512 字节的标头,后跟填充到 512 字节的文件内容。从我们的目的来看,该标头只有几个相关或有趣的字段,主要是文件类型和名称,它们存储在标头中的固定位置。

TAR 格式的头文件字段存储在头文件块中的固定位置,且大小固定。例如,文件的上次修改时间戳存储在标头开头 136 个字节处,长度为 12 个字节。所有数字字段均编码为以 ASCII 格式存储的八进制数字。因此,为了解析字段,我们需要从数组缓冲区中提取字段,对于数字字段,我们调用 parseInt(),并务必传入第二个参数来指明所需的八进制基数。

其中最重要的字段是 type 字段。这是一个单位八进制数,用于指明记录包含的文件类型。在我们的用例中,只有两种有趣的记录类型:普通文件 ('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 对象同样很麻烦。

我决定直接从 XHR 请求加载 TAR 文件作为 ArrayBuffer,并添加一个小型便捷函数,用于将 ArrayBuffer 中的分块转换为字符串。目前,我的代码仅处理基本 ANSI/8 位字符,但一旦浏览器中提供了更方便的转换 API,就可以解决此问题。

该代码只会扫描 ArrayBuffer 并解析出记录标头,其中包括所有相关的 TAR 标头字段(以及一些不太相关的字段),以及 ArrayBuffer 中文件数据的位置和大小。该代码还可以选择将数据提取为 ArrayBuffer 视图,并将其存储在返回的记录标头列表中。

该代码采用宽松的开源许可,可在 https://github.com/subsonicllc/TarReader.js 上免费获取。

FileSystem API

为了实际存储文件内容并稍后访问这些内容,我们使用了 FileSystem API。该 API 非常新,但已经有一些非常实用的文档,包括出色的 HTML5 Rocks FileSystem 文章

FileSystem API 也并非没有缺点。一方面,它是一个事件驱动型接口;这使得该 API 是非阻塞的,这对界面来说非常有用,但也使其难以使用。从 WebWorker 使用 FileSystem API 可以缓解此问题,但这需要将整个下载和解压缩系统拆分到 WebWorker 中。这甚至可能是最佳方法,但由于时间限制(我还不熟悉 WorkWorker),我没有采用这种方法,因此不得不处理 API 的异步事件驱动型特性。

我们的需求主要集中在将文件写入目录结构。这需要对每个文件执行一系列步骤。首先,我们需要将文件路径转换为列表,只需按路径分隔符(始终是正斜线,如网址)将路径字符串拆分即可轻松完成。然后,我们需要迭代生成的列表中的每个元素(除了最后一个元素),在本地文件系统中递归创建目录(如果需要)。然后,我们可以创建文件,然后创建 FileWriter,最后写出文件内容。

需要注意的第二点是 FileSystem API 的 PERSISTENT 存储空间的文件大小限制。我们需要使用永久性存储空间,因为临时存储空间可随时清除,包括在用户玩游戏时,就在系统尝试加载被驱逐的文件之前。

对于以 Chrome 应用商店为目标平台的应用,在应用的清单文件中使用 unlimitedStorage 权限时,没有存储空间限制。不过,常规 Web 应用仍可通过实验性配额请求接口请求空间。

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