简介
去年夏天,我担任了一款名为 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
);
}