ケーススタディ - SONAR、HTML5 ゲーム開発

Sean Middleditch
Sean Middleditch

はじめに

昨年の夏、私は SONAR という商用 WebGL ゲームのテクニカル リードとして働きました。このプロジェクトの完了には 3 か月ほどかかり、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 フィールドです。このフィールドを使用すると、リクエストするファイルのソースを簡単に切り替えることができます。URL 内の ?uselocal タイプのクエリ パラメータで、ゲームのメイン HTML ドキュメントを提供する同じローカル ウェブサーバー(python -m SimpleHTTPServer など)によって提供される URL からリソースをリクエストできるように、コアエンジンを簡単に設定できます。パラメータが設定されていない場合は、キャッシュ システムを使用します。

梱包リソース

リソースの連鎖読み込みの問題の一つは、すべてのデータのバイト数を完全に取得できないことです。その結果、ダウンロードのシンプルで信頼できる進行状況ダイアログを作成することはできません。すべてのコンテンツをダウンロードしてキャッシュに保存するため、大規模なゲームでは時間がかかることがあります。そのため、プレーヤーにわかりやすい進行状況ダイアログを表示することが重要です。

この問題を最も簡単に解決する方法(他のいくつかの利点もあります)は、すべてのリソース ファイルを 1 つのバンドルにパッケージ化して、1 回の XHR 呼び出しでダウンロードすることです。これにより、適切な進行状況バーを表示するために必要な進行状況イベントが得られます。

カスタム バンドル ファイル形式を構築するのはそれほど難しくなく、いくつかの問題を解決することもできますが、バンドル形式を作成するためのツールを作成する必要があります。別の解決策として、ツールがすでに存在する既存のアーカイブ形式を使用する方法があります。この場合、ブラウザで実行するデコーダを記述する必要があります。HTTP はすでに gzip または deflate アルゴリズムを使用してデータを圧縮できるため、圧縮アーカイブ形式は必要ありません。これらの理由から、TAR ファイル形式に決定しました。

TAR は比較的シンプルな形式です。すべてのレコード(ファイル)には 512 バイトのヘッダーがあり、その後に 512 バイトにパディングされたファイル コンテンツが続きます。ヘッダーには、この目的に関連するフィールドや興味深いフィールドがいくつかあります。主にファイルの種類と名前で、ヘッダー内の固定された位置に保存されています。

TAR 形式のヘッダー フィールドは、ヘッダー ブロック内の固定位置に固定サイズで保存されます。たとえば、ファイルの最終更新タイムスタンプはヘッダーの先頭から 136 バイトの位置に保存され、長さは 12 バイトです。すべての数値フィールドは、ASCII 形式で保存される八進数としてエンコードされます。フィールドを解析するには、配列バッファからフィールドを抽出し、数値フィールドの場合は parseInt() を呼び出して、目的の 8 進数を示す 2 番目のパラメータを渡します。

最も重要なフィールドの 1 つは type フィールドです。これは 1 桁の八進数で、レコードに含まれるファイルのタイプを示します。ここでは、通常のファイル('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 リーダーを探したところ、いくつか見つかりましたが、他の依存関係がない、または既存のコードベースに簡単に適合するものはありませんでした。そのため、自分で作成することにしました。また、読み込みを可能な限り最適化し、デコーダがアーカイブ内のバイナリデータと文字列データの両方を簡単に処理できるようにしました。

最初に解決しなければならなかった問題の一つは、XHR リクエストからデータを実際に読み込む方法でした。最初は「バイナリ文字列」アプローチで始めました。残念ながら、バイナリ文字列から ArrayBuffer などのより簡単に使用できるバイナリ形式に変換するのは簡単ではなく、そのような変換は特に迅速ではありません。Image オブジェクトへの変換も同様に面倒です。

最終的に、TAR ファイルを XHR リクエストから直接 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 は非ブロッキングになりますが、これは UI には適していますが、使いづらいという欠点もあります。WebWorker から FileSystem API を使用すると、この問題を軽減できますが、ダウンロードと解凍のシステム全体を WebWorker に分割する必要があります。これは最善のアプローチかもしれません。しかし、時間の制約(WorkWorker にまだ慣れていなかった)のため、この方法は採用しませんでした。そのため、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
  );
}