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

肖恩·米德蒂奇
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 文档的本地网络服务器(如 python -m SimpleHTTPServer)提供的网址请求资源,同时在未设置此参数的情况下使用缓存系统。

包装资源

以链接方式加载资源的一个问题是无法获取所有数据的完整字节数。这样做的后果是,无法为下载创建简单可靠的进度对话框。由于我们要下载所有内容并进行缓存,而对于大型游戏而言,这可能要花费相当长的时间,因此为玩家提供良好的进度对话框非常重要。

解决此问题的最简单方式是将所有资源文件打包到一个捆绑包中(这也带来了一些其他很棒的优点),我们将通过单个 XHR 调用下载该软件包,从而为我们提供所需的进度事件,从而显示一个不错的进度条。

构建自定义 bundle 文件格式并不难,甚至能够解决一些问题,但需要创建用于创建 bundle 格式的工具。一种替代解决方案是使用已经有工具的现有归档格式,然后需要编写可在浏览器中运行的解码器。我们不需要压缩的归档文件格式,因为 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 对象同样令人痛苦。

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

代码只是对 ArrayBuffer 进行扫描,以解析出记录标头,其中包含所有相关的 TAR 标头字段(以及几个不太相关的字段)以及 ArrayBuffer 中文件数据的位置和大小。代码也可以选择以 ArrayBuffer 视图的形式提取数据,并将其存储在返回的记录标头列表中。

该代码根据 https://github.com/subsonicllc/TarReader.js 提供的友好、宽松的开源许可免费提供。

FileSystem API

为了实际存储文件内容并在稍后访问,我们使用了 FileSystem API。此 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
  );
}