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 类型查询参数从同一本地 Web 服务器(如 python -m SimpleHTTPServer)提供的网址请求资源,该服务器还提供游戏的主 HTML 文档,同时在未设置参数时使用缓存系统。

打包资源

链式加载资源的一个问题是,无法获取所有数据的完整字节数。这样做的后果是,无法为下载操作制作简单可靠的进度对话框。由于我们要下载所有内容并将其缓存,而对于大型游戏来说,这可能需要相当长的时间,因此为玩家提供一个美观的进度对话框非常重要。

解决此问题的最简单方法(同时还能带来一些其他优势)是将所有资源文件打包到一个软件包中,然后通过一次 XHR 调用下载该软件包,这样我们就能获得显示精美进度条所需的进度事件。

构建自定义软件包文件格式并不难,甚至可以解决一些问题,但需要创建一个用于创建软件包格式的工具。另一种解决方案是使用已存在工具的现有归档格式,然后编写一个在浏览器中运行的解码器。我们不需要压缩归档格式,因为 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。这可能也是最好的方法,但由于时间限制(当时我还不太熟悉 WorkWorkers),我没有采用这种方法,而是不得不处理 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
  );
}