HTML5 ゲームのシンプルなアセット管理

はじめに

HTML5 には、レスポンシブでパワフルな最新ウェブ アプリケーションをブラウザで構築するための便利な API が多数用意されています。それも素晴らしいですが、本当はゲームを作ってプレイしたいと思っています。幸いなことに、HTML5 はゲーム開発の新時代の到来を告げました。Canvas などの API や強力な JavaScript エンジンを使用して、プラグインを必要とせずブラウザに直接ゲームを配信します。

この記事では、HTML5 ゲーム用のシンプルなアセット管理コンポーネントを作成する手順について説明します。アセット マネージャーがないと、不明なダウンロード時間や非同期画像の読み込みにゲームの対応が難しくなります。HTML5 ゲームのシンプルなアセット マネージャーの例を紹介します。

問題

HTML5 ゲームでは、画像や音声などのアセットがプレーヤーのローカルマシンにあると想定することはできません。HTML5 ゲームは、HTTP 経由でアセットがダウンロードされ、ウェブブラウザでプレイされることを前提としているためです。ネットワークが関係しているため、ブラウザはゲームのアセットがいつダウンロードされて利用可能になるかを把握できません。

プログラムでウェブブラウザに画像を読み込む基本的な方法は、次のコードです。

var image = new Image();
image.addEventListener("success", function(e) {
  // do stuff with the image
});
image.src = "/some/image.png";

ここで、ゲームの起動時に読み込んで表示する必要がある画像を 100 枚用意するとします。100 個の画像がすべて揃ったことを確認するにはどうすればよいでしょうか。それらはすべて正常に読み込まれましたか?試合を実際に開始するタイミングを指定してください。

ソリューション

アセットのキュー処理はアセット マネージャーに任せ、準備が整ったらゲームに報告します。アセット マネージャーは、ネットワーク経由でアセットを読み込むロジックを一般化し、ステータスを簡単にチェックする方法を提供します。

シンプルなアセット マネージャーには次の要件があります。

  • ダウンロードをキューに入れる
  • ダウンロードを開始
  • 成功と失敗を追跡する
  • すべて完了すると
  • アセットを簡単に取得

待機中

1 つ目の要件は、ダウンロードをキューに入れることです。この設計では、実際にダウンロードすることなく、必要なアセットを宣言できます。これは、たとえば、あるゲームレベルのすべてのアセットを構成ファイル内で宣言する場合に役立ちます。

コンストラクタとキューイングのコードは次のようになります。

function AssetManager() {
  this.downloadQueue = [];
}

AssetManager.prototype.queueDownload = function(path) {
    this.downloadQueue.push(path);
}

ダウンロードを開始

ダウンロードするすべてのアセットをキューに追加したら、アセット マネージャーにすべてのダウンロードの開始を依頼できます。

幸いなことに、ウェブブラウザはダウンロードを並列化できます。通常はホストあたり最大 4 つの接続です。アセットのダウンロードを高速化する 1 つの方法は、アセットのホスティングにさまざまなドメイン名を使用することです。たとえば、assets.example.com からすべてを配信するのではなく、assets1.example.com、assets2.example.com、assets3.example.com などを使用します。たとえ各ドメイン名が同じ Web サーバーへの CNAME にすぎないとしても、Web ブラウザはそれらを別々のサーバーと見なし、アセットのダウンロードに使用される接続の数を増やします。この手法について詳しくは、ウェブサイトの速度を改善するベスト プラクティスのドメイン間でのコンポーネント分割をご覧ください。

ダウンロードの初期化メソッドは downloadAll() です。時間の経過とともに、さらに発展させていきます。ひとまず、ダウンロードを開始する最初のロジックは次のとおりです。

AssetManager.prototype.downloadAll = function() {
    for (var i = 0; i < this.downloadQueue.length; i++) {
        var path = this.downloadQueue[i];
        var img = new Image();
        var that = this;
        img.addEventListener("load", function() {
            // coming soon
        }, false);
        img.src = path;
    }
}

上記のコードからわかるように、downloadAll() は単純に downloadQueue を反復処理して、新しい Image オブジェクトを作成します。load イベントのイベント リスナーが追加され、画像の src が設定され、実際のダウンロードがトリガーされます。

この方法でダウンロードを開始できます。

成功と失敗のトラッキング

残念なことに、すべてがうまくいくとは限りません。そのため、もう一つの要件は、成功と失敗の両方を追跡することです。ここまでのコードは、正常にダウンロードされたアセットのみをトラッキングします。エラーイベントのイベント リスナーを追加すると、成功と失敗の両方のシナリオをキャプチャできます。

AssetManager.prototype.downloadAll = function(downloadCallback) {
  for (var i = 0; i < this.downloadQueue.length; i++) {
    var path = this.downloadQueue[i];
    var img = new Image();
    var that = this;
    img.addEventListener("load", function() {
        // coming soon
    }, false);
    img.addEventListener("error", function() {
        // coming soon
    }, false);
    img.src = path;
  }
}

アセット マネージャーは、これまでに遭遇した成功と失敗の数を把握する必要があります。さもなければ、ゲームはいつ開始できるかわかりません。

まず、次のようにコンストラクタのオブジェクトにカウンタを追加します。

function AssetManager() {
<span class="highlight">    this.successCount = 0;
    this.errorCount = 0;</span>
    this.downloadQueue = [];
}

次に、イベント リスナーのカウンタをインクリメントします。コードは次のようになります。

img.addEventListener("load", function() {
    <span class="highlight">that.successCount += 1;</span>
}, false);
img.addEventListener("error", function() {
    <span class="highlight">that.errorCount += 1;</span>
}, false);

アセット マネージャーは、読み込まれたアセットと失敗したアセットの両方を追跡します。

完了時のシグナリング

ゲームがダウンロード用のアセットをキューに追加し、すべてのアセットをダウンロードするようにアセット マネージャーに要求したら、すべてのアセットがダウンロードされたときにゲームに通知する必要があります。アセット マネージャーは、アセットがダウンロードされたかどうかを何度も確認するのではなく、ゲームにシグナルを戻すことができます。

アセット マネージャーはまず、すべてのアセットが終了したことを把握する必要があります。ここで isDone メソッドを追加します。

AssetManager.prototype.isDone = function() {
    return (this.downloadQueue.length == this.successCount + this.errorCount);
}

アセット マネージャーは、successCount + errorCount を downloadQueue のサイズと比較することで、すべてのアセットが正常に終了したか、なんらかのエラーが発生したかを認識します。

もちろん、完了したかどうかを知るだけでは、戦術の半分に過ぎません。アセット マネージャーもこの方法をチェックする必要があります。次のコードに示すように、このチェックを両方のイベント ハンドラ内に追加します。

img.addEventListener("load", function() {
    console.log(this.src + ' is loaded');
    that.successCount += 1;
    if (that.isDone()) {
        // ???
    }
}, false);
img.addEventListener("error", function() {
    that.errorCount += 1;
if (that.isDone()) {
        // ???
    }
}, false);

カウンタがインクリメントされた後、それがキュー内の最後のアセットかどうかを確認します。アセット マネージャーがダウンロードを完了したのであれば、どうすればよいでしょうか。

アセット マネージャーがすべてのアセットをダウンロードしたら、もちろんコールバック メソッドを呼び出します。downloadAll() を変更して、コールバックのパラメータを追加しましょう。

AssetManager.prototype.downloadAll = function(downloadCallback) {
    ...

イベント リスナー内で downloadCallback メソッドを呼び出します。

img.addEventListener("load", function() {
    that.successCount += 1;
    if (that.isDone()) {
        downloadCallback();
    }
}, false);
img.addEventListener("error", function() {
    that.errorCount += 1;
    if (that.isDone()) {
        downloadCallback();
    }
}, false);

アセット マネージャーは、ついに最後の要件に対応できるようになりました。

アセットを簡単に取得

ゲームを開始できることが通知されると、ゲームは画像のレンダリングを開始します。アセット マネージャーは、アセットのダウンロードとトラッキングだけでなく、それらをゲームに提供する役割も担います。

最後の要件には、なんらかの getAsset メソッドが含まれるため、これを追加します。

AssetManager.prototype.getAsset = function(path) {
    return this.cache[path];
}

このキャッシュ オブジェクトは、次のようにコンストラクタで初期化されます。

function AssetManager() {
    this.successCount = 0;
    this.errorCount = 0;
    this.cache = {};
    this.downloadQueue = [];
}

以下に示すように、キャッシュは downloadAll() の最後に入力されます。

AssetManager.prototype.downloadAll = function(downloadCallback) {
  ...
      img.addEventListener("error", function() {
          that.errorCount += 1;
          if (that.isDone()) {
              downloadCallback();
          }
      }, false);
      img.src = path;
      <span class="highlight">this.cache[path] = img;</span>
  }
}

ボーナス: バグの修正

バグは見つかりましたか?前述のように、isDone メソッドは、load イベントまたは error イベントがトリガーされた場合にのみ呼び出されます。しかし、アセット マネージャーにダウンロード待ちのアセットがない場合はどうすればよいでしょうか。isDone メソッドはトリガーされず、ゲームも開始されません。

このシナリオに対応するには、次のコードを downloadAll() に追加します。

AssetManager.prototype.downloadAll = function(downloadCallback) {
    if (this.downloadQueue.length === 0) {
      downloadCallback();
  }
 ...

キューに入れられたアセットがない場合、コールバックはすぐに呼び出されます。バグが修正されました。

使用例

HTML5 ゲームでこのアセット マネージャーを使用するのはとても簡単です。このライブラリの最も基本的な使用方法は次のとおりです。

var ASSET_MANAGER = new AssetManager();

ASSET_MANAGER.queueDownload('img/earth.png');

ASSET_MANAGER.downloadAll(function() {
    var sprite = ASSET_MANAGER.getAsset('img/earth.png');
    ctx.drawImage(sprite, x - sprite.width/2, y - sprite.height/2);
});

上記のコードは以下を示しています。

  1. 新しいアセット マネージャーを作成する
  2. ダウンロードするアセットをキューに入れる
  3. downloadAll() でダウンロードを開始する
  4. コールバック関数を呼び出して、アセットの準備が整ったときに通知します
  5. getAsset() を使用してアセットを取得する

改善の余地がある領域

ゲームを構築する際に、このシンプルなアセット マネージャーをうまく使いこなせるようになることは間違いありません。ただし、基本的なスタートを切るきっかけになれば幸いです。今後追加される機能には次のようなものがあります。

  • どのアセットにエラーが発生したかを示す
  • 進行状況を示す
  • File System API からのアセットの取得

改善点、フォーク、コードへのリンクを以下のコメント欄に投稿してください。

完全なソース

このアセット マネージャーと抽象化されたゲームのソースは、Apache ライセンスに基づくオープンソースであり、Bad Aliens GitHub アカウントで入手できます。バッド エイリアンのゲームは HTML5 対応ブラウザでプレイできます。このゲームは、Google IO に関する講演「Super Browser 2 Turbo HD Remix: Introduction to HTML5 Game Development(スライド動画)」の題材にした。

まとめ

ほとんどのゲームにはなんらかのアセット マネージャーがありますが、HTML5 ゲームには、ネットワーク経由でアセットを読み込んでエラーを処理するアセット マネージャーが必要です。この記事では、次の HTML5 ゲームでも簡単に使用でき、調整できるシンプルなアセット マネージャーについて概説しました。今後ともどうぞよろしくお願いいたします。下のコメント欄でぜひご意見をお聞かせください。よろしくお願いいたします。