Phaser を読み込まずにゲームページを開く

ゲームエンジンは重い。でも記事を読みに来た人にまで読ませる必要はありません。動的インポートで「開始ボタンを押すまで読み込まない」構成にした話。

このサイトのゲームは Phaser というライブラリで作っています。多機能で助かるのですが、ライブラリ自体がそれなりに大きいという現実があります。

そしてこのサイトのゲームページには、ゲーム本体だけでなく遊び方やスコアの仕組みの解説が数千文字あります。つまり、ゲームを遊ばずに読むだけの人がいるわけです。

その人たちにゲームエンジンを読み込ませるのは、まったくの無駄です。

やったこと:開始ボタンを押すまで読み込まない

結論はこれです。ページを開いた時点では、ゲームのコードを一切読み込みません。

「ゲームを開始」ボタンが押された瞬間に、初めて読み込みます。

startBtn?.addEventListener('click', async () => {
  // ...
  const gameLoaders: Record<string, () => Promise<...>> = {
    'poko-poko':      () => import('../../games/poko-poko/index'),
    'reaction-time':  () => import('../../games/reaction-time/index'),
    'danmaku-fever':  () => import('../../games/danmaku-fever/index'),
    'calc-rush':      () => import('../../games/calc-rush/index'),
    // ...
  };

  const loader = slug ? gameLoaders[slug] : undefined;
  if (loader) {
    const { startGame } = await loader();
    activeGame = startGame(canvas);
  }
});

ポイントは import(...)関数の中で呼んでいることです。ファイル先頭の import 文は読み込んだ瞬間に解決されますが、この形(動的インポート)は呼ばれるまで何も起きません

ビルドツールはこれを見て、ゲームごとに別のファイルへ分割してくれます。結果として、ページを開いただけの人には解説のHTMLとCSSしか届きません。

なぜ「全部入り」を1つ用意しなかったか

10本のゲームがあるので、最初は「全ゲームをまとめた1つのファイル」を作ることも考えました。そのほうがキャッシュが効きそうに思えたからです。

やめました。1本しか遊ばない人に10本分を読ませることになるからです。

このサイトの遊ばれ方を考えると、1回の訪問で複数のゲームを遊ぶ人より、1本だけ遊んで帰る人のほうがずっと多いはずです。だとすれば、必要なものだけを個別に読み込むほうが理にかなっています。

ゲーム名をキーにした表(gameLoaders)を用意しているのは、このためです。スラッグから対応するローダーを引いて、そのゲームだけを読み込みます。

なぜ変数を使わず、ベタ書きしているのか

一見すると、こう書きたくなります。

// これは動かない
const mod = await import(`../../games/${slug}/index`);

短くて美しく見えますが、この書き方だと分割がうまくいきません

ビルドツールは、ビルド時に「どのファイルが読み込まれる可能性があるか」を静的に解析します。パスが変数だと、どれが読み込まれるか分からないので、該当しそうなファイルを全部まとめて出力するか、あるいは解決に失敗します。

だから面倒でも、10本ぶんのパスをすべて書き並べています。冗長ですが、これで各ゲームが確実に別ファイルへ分かれます。

ビルドツールに分かる形で書くというのは、この手の最適化ではよくある制約です。「賢く書く」より「解析しやすく書く」ほうが、結果として速くなります。

読み込みに失敗したときのために

存在しないスラッグが渡された場合に備えて、フォールバックを用意してあります。

} else {
  const ctx = canvas.getContext('2d');
  if (ctx) {
    ctx.fillStyle = '#f0f0f0';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#666';
    ctx.font = '16px system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('このゲームは準備中です', canvas.width / 2, canvas.height / 2);
  }
}

真っ黒な画面のまま何も起きない、という状態を避けるためです。何が起きているか分かるだけで、印象はずいぶん変わります。

ページ遷移との兼ね合い

このサイトは Astro のクライアントルーターを使っていて、ページ間の移動時にページ全体を再読み込みしません。速いのですが、初期化処理の書き方に注意が必要になります。

普通に書くと、こうしたくなります。

// 最初の1回しか動かない
initGamePage();

これだと、他のページからゲームページへ移動してきたときに初期化が走りません。ボタンを押しても何も起きない、という状態になります。

なので、ページが表示されるたびに発火するイベントで初期化しています。

document.addEventListener('astro:page-load', initGamePage);

このイベントは初回の読み込み時にも発火するので、これ1行で両方の場合をカバーできます。

効果

この構成の効果は、ゲームページを開いてすぐの状態で確かめられます。「ゲームを開始」を押すまで、Phaser は読み込まれません。ブラウザの開発者ツールでネットワークを見ていると、ボタンを押した瞬間に初めてゲームのファイルが取得されるのが分かります。

解説を読みに来ただけの人は、記事ページとまったく同じ軽さでページを見られます。遊ぶ人だけがコストを払うという、素直な構造になりました。

副産物:ゲームを足すのが楽になった

この形にしておいたおかげで、新しいゲームを追加するときの手順が単純になりました。

  1. src/games/<slug>/ にゲームを実装する
  2. gameLoaders に1行足す
  3. src/content/games/<slug>.md に解説を書く

既存のゲームには一切触りません。バンドルも自動的に分かれるので、ゲームが増えてもページの初期表示が重くなることがありません。

10本を超えたあたりから、この「増やしても壊れない」構造のありがたみを実感するようになりました。最初に少し面倒な書き方を選んでおくと、後がずっと楽になります

広告