すべてのゲーム開発チュートリアルは、同じところから始まります。灰色のキャンバスの上を、色付きの長方形が滑っていく様子です。文法を学ぶにはそれで十分ですが、ゲームエンジンが実際にどのように「息づいている」かについては、何も教えてくれません。私は長方形を作るだけの世界から抜け出したかったのです。移動、戦闘、HUD、そしてサウンドを備えた、トップダウン型のダンジョン探索ゲームのような、本物のゲームのように感じられるものを作りたかったのです。

私はPhaser v4を使うことを決め、自分自身に一つの厳しいルールを課しました。「外部アセットは一切使わない」ことです。画像ファイルも、オーディオクリップも、WebpackやViteのようなビルドツールも使いません。プロジェクト全体を、プレーンなJavaScriptで書かれた単一のHTMLファイル内に収めなければなりませんでした。その制約は、単なるミニマリズムのためではありませんでした。あらゆる言い訳と、あらゆるブラックボックスを取り除くためのものでした。知識の欠如を補うためにスプライトパックをダウンロードできない状況では、エンジンが内部でどのようにテクスチャ、アニメーション、オーディオ、そして状態を管理しているのかを学ばざるを得なくなるからです。

コードから世界を描画する

通常のPhaserプロジェクトでは、preload関数内でthis.load.image()を呼び出し、エンジンにPNGファイルを指定します。その選択肢がない場合、Graphicsオブジェクトに頼ることになります。それをインスタンス化し、プリミティブな図形(床タイル用の長方形、壁用の太い線、プレイヤーのトークン用の円など)を描画し、それからgenerateTextureを呼び出します。このメソッドはグラフィックスバッファをキャプチャし、選択したキーでPhaserのテクスチャマネージャーに登録します。

その瞬間から、エンジンはその生成されたビットマップを、ロードされた画像ファイルと全く同じように扱います。それをタイルマップに割り当てたり、スプライトに分割したり、色を付けたりすることができます。ダンジョン探索ゲームにおいて、これはコードエディタから離れることなく、床のグリッドをプロシージャルに生成し、壁のセグメントを配置し、カラーパレットを繰り返し調整できることを意味しました。実践的な教訓は、テクスチャとは単にメモリ上に存在するビットマップデータの塊に過ぎないということです。Phaserは、それがHTTPリクエスト経由で届いたのか、手書きのGraphics呼び出しによって生成されたのかは気にしません。

このアプローチは、描画順序やバッチングについても深く考えさせてくれます。すべての壁や床のタイルが同じ系統の生成テクスチャから構成されていると、Phaserがどのようにレンダーコールをグループ化しているかに注意を払うようになります。どのオブジェクトをテクスチャインスタンスとして存在させるべきかを自分自身で判断するため、静的なタイルマップレイヤーと個々のオブジェクトのスプライトマップとの違いに気づくようになります。

スプライトシートなしのアニメーション

静止した四角形はすぐに飽きてしまいますが、Phaserのアニメーションシステムは、通常グリッド状にフレームが配置された単一のPNGであるスプライトシートを想定しています。私はオフスクリーンのHTML canvas要素を使用して、そのストリップを再現しました。アニメーションの各フレームにおいて、キャンバスをクリアして新しいポーズ(単純な剣の振り、2ステップの歩行

本物のゲームには複数の画面が必要になるため、プロジェクトをMenu、Game、UI、Pauseという個別のPhaserシーンに分割しました。UIシーンはGameシーンと並行して動作し、同時に起動されることで、ダンジョン探索がその背後で行われている間、ヘルスバーやスコアカウンターは独自のサンドボックス内で動作します。これらはイベントを通じてのみ厳密に通信します。プレイヤーがダメージを受けたとき、Gameシーンは変更をemit(発行)します。UIシーンはそれをlisten(購読)してテキストオブジェクトを更新します。GameシーンはUIをインポートしたり、そのメソッドを呼び出したり、存在を確認したりさえしません。単に「虚空」に向かってデータを送るだけです。この疎結合(decoupling)のおかげで、コアとなるゲームループに一切触れることなく、テストのためにHUDを抜き出したり、完全に置き換えたりすることが可能になります。

ポーズ機能については、Gameシーンの上に重ねる形でPauseシーンをスタックさせる手法をとりました。重要なのは、Gameシーンに対してscene.pause()を呼び出すと、実際に物理演算の世界(physics world)が凍結され、タイマーが停止することです。Gameシーンの更新は止まりますが、Pauseシーンは起動したままなので、メニューを描画し、再開の信号を待つことができます。もしこれまで、巨大な一つのupdateループの中でbooleanフラグを使ってポーズ状態を管理してきたのなら、これはまるで照明のスイッチを見つけたかのような感覚でしょう。エンジンが本物のポーズ・ライフサイクルを提供してくれるため、コードの随所にif (isPaused) returnといったガード句を散りばめる必要はありません。

実戦のバグから学んだ教訓

低レイヤーに近い部分で作業することで、変えるべき2つの習慣が浮き彫りになりました。

第一に、Phaser v4において、公開されているように見えて実際にはドキュメント化されたAPIの一部ではないメソッドを使おうとしてしまったことです。そのメソッドはバージョン間で変更され、ビルドを壊しました。そこで、安定しておりドキュメント化されている公開メソッドであるgetChildren()を使用するようにリファクタリングしたところ、不安定さは解消されました。教訓は至極単純です。もしメソッドが公式ドキュメントに記載されていないのであれば、それに基づいてゲームを構築してはいけません。内部API(Internal APIs)が内部用であるには理由があります。公開されているインターフェース(public surface area)に徹すれば、プロジェクトはエンジンのアップデートを生き延びることができるでしょう。

第二に、あらゆることをイベントに頼りすぎてはいけないということを学びました。コインを拾ったり宝箱を開けたりといった、重要度の低いインタラクションであれば、イベントのコールバックは完璧に機能します。しかし、Game Overのような極めて重要な状態遷移については、メインのupdateループ内に冗長なチェックを追加しました。リスナーが削除されたり、シーンが不適切なマイクロ秒のタイミングで一時停止したり、発行と処理の間にレースコンディション(競合状態)が入り込んだりすると、イベントが正しく動作しない可能性があるからです。プレイヤーのヘルスをupdateループ内で直接チェックし、ゼロになったら強制的にゲームオーバー状態にすることで、もしイベントの発行に失敗してもゲームが中途半端な状態(limbo state)で止まることがないようにしました。イベントは依然として、画面の揺れ、サウンド、スコアの送信といった二次的な効果を処理しますが、決定的なロジック(authoritative logic)はゲームクロックが存在する場所に置くべきなのです。

なぜこれを試すべきなのか

もしあなたがゲーム開発を学んでいる最中なら、次のプロジェクトではあえてこの制約を課してみてください。「外部アセットなし、HTMLファイルは1つだけ」という制約です。制限が厳しく感じるかもしれませんが、これによってあらゆる言い訳が排除されます。バンドラーを設定したり、ローカルの音声ファイルによるCORSエラーと格闘したり、無料のアセットパックを精査するために午後を潰したりする必要はありません。コードを書き、ブラウザをリフレッシュすれば、すぐに結果が見えます。

さらに重要なのは、なぜエンジンがそのように動作するのかを理解できるようになることです。generateTextureを呼び出したことで、どのようにテクスチャがGPUに入るのかが分かります。アニメーションフレームの境界を手動で登録することで、どのようにフレームがインデックス化されるのかが分かります。オシレーターを配線することで、どのようにオーディオがスピーカーに届くのかが分かります。その知識は、外部アセットを使用するより大規模なプロジェクトにも直接転用できます。なぜなら、基礎となるメカニズムは決して変わらないからです。エンジンが——