昨年の夏、私は SONAR という商用 WebGL ゲームのテクニカル リードを務めました。このプロジェクトは 3 か月ほどで完了し、JavaScript で完全にゼロから作成されました。SONAR の開発中、Google は新しい未テストの 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 フィールドです。これにより、リクエストするファイルのソースを簡単に切り替えることができます。コアエンジンを簡単に設定して、URL の ?uselocal
タイプのクエリ パラメータで、ゲームのメイン HTML ドキュメントを配信した同じローカル ウェブサーバー(python -m SimpleHTTPServer
など)が配信する URL からリソースをリクエストできるようにすることができます。パラメータが設定されていない場合は、キャッシュ システムが使用されます。
パッケージ リソース
リソースのチェーン読み込みの問題の 1 つは、すべてのデータの合計バイト数を取得する方法がないことです。このため、ダウンロードの進捗状況を示すシンプルで信頼性の高いダイアログを作成する方法はありません。すべてのコンテンツをダウンロードしてキャッシュに保存するため、ゲームのサイズが大きい場合はかなり時間がかかることがあります。そのため、進行状況を示すダイアログを適切に表示することが重要です。
この問題を解決する最も簡単な方法(他にもいくつかのメリットがあります)は、すべてのリソース ファイルを 1 つのバンドルにパッケージ化することです。このバンドルは 1 回の XHR 呼び出しでダウンロードされるため、進捗状況を表示するのに必要な進捗イベントを取得できます。
カスタム バンドル ファイル形式の構築はそれほど難しくなく、いくつかの問題も解決できますが、バンドル形式を作成するためのツールを作成する必要があります。別の解決策は、ツールがすでに存在する既存のアーカイブ形式を使用し、ブラウザで実行するデコーダを作成することです。HTTP は gzip または deflate アルゴリズムを使用してデータを圧縮できるため、圧縮アーカイブ形式は必要ありません。これらの理由から、TAR ファイル形式を採用しました。
TAR は比較的シンプルな形式です。すべてのレコード(ファイル)には 512 バイトのヘッダーがあり、その後に 512 バイトまでパディングされたファイル コンテンツが続きます。ヘッダーには、ファイルの種類と名前など、目的に関連するフィールドがいくつかしかありません。これらのフィールドはヘッダー内の固定位置に保存されています。
TAR 形式のヘッダー フィールドは、ヘッダー ブロック内の固定サイズで固定された場所に保存されます。たとえば、ファイルの最終変更タイムスタンプはヘッダーの先頭から 136 バイトの位置に 12 バイトの長さで保存されます。すべての数値フィールドは、ASCII 形式で保存された 8 進数としてエンコードされます。フィールドを解析するには、配列バッファからフィールドを抽出し、数値フィールドに対して parseInt()
を呼び出します。このとき、目的の 8 進数ベースを示す 2 番目のパラメータを必ず渡します。
最も重要なフィールドの 1 つは type フィールドです。これは、レコードに含まれるファイルの種類を示す 1 桁の 8 進数です。この目的で重要なレコード タイプは、通常ファイル('0'
)とディレクトリ('5'
)の 2 つだけです。任意の 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 リーダーを探したところ、いくつか見つかりましたが、他の依存関係がないものや、既存のコードベースに簡単に組み込めるものはありませんでした。そのため、私は自分で書くことにしました。また、読み込みを可能な限り最適化し、デコーダがアーカイブ内のバイナリデータと文字列データの両方を簡単に処理できるようにしました。
最初に解決しなければならなかった問題の 1 つは、XHR リクエストからデータを読み込む方法でした。当初は「バイナリ文字列」アプローチで始めました。残念ながら、バイナリ文字列から ArrayBuffer
などのより使いやすいバイナリ形式への変換は簡単ではなく、特に高速でもありません。Image
オブジェクトへの変換も同様に面倒です。
XHR リクエストから ArrayBuffer
として TAR ファイルを直接読み込み、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 は非ブロックになり、UI には最適ですが、使用が難しくなります。WebWorker から FileSystem API を使用することでこの問題を軽減できますが、ダウンロードと解凍のシステム全体を WebWorker に分割する必要があります。それが最善のアプローチだったかもしれませんが、時間の制約(WorkWorkers にまだ慣れていなかった)のため、そのアプローチは採用しませんでした。そのため、API の非同期イベント駆動型の性質に対処する必要がありました。
ニーズは主に、ディレクトリ構造へのファイルの書き出しに集中しています。これには、ファイルごとに一連の手順が必要です。まず、ファイルパスを取得してリストに変換する必要があります。これは、パス文字列をパス区切り文字(常に URL のようなスラッシュ)で分割することで簡単に行えます。次に、結果のリストの最後の要素を除く各要素を反復処理し、ローカル ファイル システムに(必要に応じて)ディレクトリを再帰的に作成する必要があります。次に、ファイルを作成し、FileWriter
を作成して、最後にファイルの内容を書き出すことができます。
2 つ目の重要な考慮事項は、FileSystem API の PERSISTENT
ストレージのファイルサイズの上限です。一時ストレージは、ユーザーがゲームをプレイしている最中など、追放されたファイルを読み込もうとする直前にもクリアされる可能性があるため、永続ストレージが必要でした。
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
);
}