Phaser 4の使い方入門|Vite + TypeScript環境を構築してゲームを動かす方法

その他
記事内に広告が含まれています。

「Phaser 4ってどうやって使うの?」「Phaser 3との違いは?」「まだ不安定って聞くけど今から触って大丈夫?」——そんな疑問を感じていませんか?

Phaserはブラウザゲーム開発で人気のフレームワークですが、2026年現在はPhaser 4への移行期にあり、情報が断片的で分かりづらいのが現状です。特にこれから学ぶ人にとっては、「どのバージョンを選ぶべきか」「どうやって環境構築すればいいのか」「最短で動かす方法は?」といった悩みで手が止まりがちです。

この記事では、Phaser 4の現状やPhaser 3との違いを整理しつつ、初心者でもつまずかずに開発をスタートできるように、環境構築から最小サンプル、基本概念、実践的な開発ノウハウまでを網羅的に解説します。実際にコードを動かしながら理解できる構成なので、「とりあえず触ってみたい」という方にも最適です。

この記事を読んでわかること

  • Phaser 4の最新リリース状況と、3から乗り換えるべき判断基準
  • Vite + TypeScriptによる爆速環境構築手順
  • Scene・GameObject・PhysicsといったPhaserの核となる基本概念の正しい理解
  • キーボード・マウス・スマホ操作を網羅したキャラクター制御の実装方法
  • アニメーションやBGM、複数アセットの効率的なプリロードと再生制御
  • 中規模開発でも破綻しないディレクトリ設計とデバッグの極意
  • Phaser 3のコードを4へ書き換える際のマイグレーション手順とエラー対策

「まずは動くものを作ってみたい」という初心者の方から、「最新の環境へスムーズに移行したい」という経験者の方まで、この記事を読み終える頃には自信を持ってPhaser 4でゲーム開発を始めているはずです。さあ、一緒に次世代のWebゲーム制作を楽しみましょう!

  1. Phaser 4の概要とPhaser 3との違い
    1. Phaser 4の正式リリース状況・安定版の現状(2026年4月時点)
    2. Phaser version 3との違い(API・構造・パフォーマンス)を比較
    3. Phaser 4を選ぶべき人・Phaser 3のままでいい人
  2. 【最短5分】Phaser 4を動かす最小サンプル(Vite対応)
    1. Vite + Phaser 4の最小テンプレ(コピペOK)
    2. 画像を表示するまでのコード(preload → create)
    3. エラーなく起動するためのチェックポイント
  3. Phaser 4の環境構築(Node.js・npm・Vite・TypeScript対応)
    1. npm installから始める!Viteスターターテンプレートの導入手順
    2. TypeScriptの型定義(d.ts)と型安全なスプライト操作の設定方法
    3. プロジェクト構成(src / assets / scenes)のベストプラクティス
  4. Phaser 4の基本概念(Scene / GameObject / Physics)
    1. Sceneの構造とライフサイクル(init / preload / create / update)
    2. GameObject(Sprite・Image・Text)の基本と使い分け
    3. Arcade Physicsの有効化と衝突判定の基本設定
  5. 入力処理とキャラクター操作(キーボード・マウス・タッチ)
    1. キーボード入力(カーソルキー・WASD)の実装例
    2. マウス・タッチ操作のイベントリスナーとスマホ対応
    3. ゲームオブジェクトへのクリック判定
    4. キャラクターの移動・ジャンプ処理の実装(2Dアクション基礎)
  6. スプライト・アニメーション・アセット管理
    1. スプライトシートの読み込みとアニメーション再生
    2. 複数アセットの効率的なプリロード方法
    3. BGM・SEのロードと再生制御(最短コード例)
  7. Phaser 4の実践設計と開発効率化
    1. シーン管理(タイトル・ゲーム・リザルト)
    2. ディレクトリ設計(Vite + TS)
    3. デバッグ(FPS表示・当たり判定)と最適化
    4. よくあるパフォーマンス問題チェックリスト
  8. Phaser 3からPhaser 4への移行ガイド
    1. 破壊的変更まとめ
    2. コードの書き換え例
    3. よくある移行エラー
  9. よくある質問(FAQ)
  10. まとめ

Phaser 4の概要とPhaser 3との違い

Phaser 4が2026年4月10日正式リリースされました。「Phaser 3との違いは何?」「今すぐ乗り換えるべきか?」という疑問を持つ方も多いでしょう。このセクションでは、最新の正式リリース情報とPhaser 3との具体的な差分を整理し、あなたが「使うべきか・待つべきか」を判断できる状態にします。

Phaser 公式サイト

Phaser 4の正式リリース状況・安定版の現状(2026年4月時点)

Phaser v4.0.0「Caladan」は2026年4月10日に正式リリースされました。長い開発期間を経て、Phaser史上最大のリリースとなっており、WebGLレンダラーをゼロから作り直した、まったく新しいアーキテクチャを採用しています。

リリースまでの主な流れは以下の通りです。

時期マイルストーン
2024年11月〜Beta 1 〜 Beta 4 公開
2025年5月〜RC1 〜 RC4 公開(モバイル最大16倍高速化)
2025年12月RC6 公開
2026年3月RC7 公開(最終調整)
2026年4月10日v4.0.0 正式リリース

npmでのインストールは以下のコマンドで行えます。

npm install phaser
# または特定バージョンを指定
npm install phaser@4.0.0

CDNを使う場合は以下のURLが利用できます。

<!-- 軽量版(本番推奨) -->
<script src="https://cdn.jsdelivr.net/npm/>[email protected]/dist/phaser.min.js"></script>

<!-- 開発版(デバッグ情報あり) -->
<script src="https://cdn.jsdelivr.net/npm/>[email protected]/dist/phaser.js"></script>

Phaser version 3との違い(API・構造・パフォーマンス)を比較

表のAPIはほぼそのまま使えますが、内部レンダラーが別物に置き換わっています。カスタムパイプラインを使っていた場合のみ、大きな書き換えが必要です。

Phaser v4の最大の変更点はWebGLレンダラーの全面刷新です。v3のパイプラインシステムは、複数の責務を1つのパイプラインに持たせる設計でWebGL状態の管理が各パイプラインに分散しており、競合が発生しやすい構造でした。v4ではこれをすべて置き換え、新しいRenderNodeアーキテクチャを採用しています。

以下に主要な変更点を整理します。

パフォーマンス面

項目Phaser 3Phaser 4
WebGLレンダラーPipeline方式RenderNode方式(高速・安定)
スプライト描画通常バッチ処理SpriteGPULayer:100万体を1ドローコール
モバイル性能標準最大16倍高速化
メモリ使用量約16MB(汎用バッファ)約5MB(専用バッファ)
頂点アップロードコスト標準インデックスバッファで約1/3削減

SpriteGPULayerにより、100万体以上のスプライトを画面上に描画でき、以前の最大100倍の速度が実現されました。

API・機能面

FXとマスクというPhaser 3の2つの独立したシステムは、Phaser 4では「フィルター」という単一の統合システムに統合されました。フィルターはゲームオブジェクトにもカメラにも制限なく適用でき、Blur・Glow・Shadow・Bloom・Vignetteなど多数が標準搭載されています。

ティントシステムも変更されています。

// Phaser 3(旧)
sprite.setTintFill(0xff0000);  // ← Phaser 4では廃止

// Phaser 4(新)
sprite.setTint(0xff0000);
sprite.setTintMode(Phaser.BlendModes.FILL);  // MULTIPLY / FILL / ADD / SCREEN など6種類

削除されたクラスと代替先も把握しておきましょう。

削除されたものPhaser 4での代替
Geom.PointVector2(メソッドは直接置き換え可)
BitmapMaskMaskフィルター
Mesh(旧)新しいMesh実装
Phaser.Struct.Set/MapネイティブのSet / Map

注意: Phaser v4ではGL座標系を採用しており、Y=0が画面の下端になっています。圧縮テクスチャを使用している場合は、Y軸が下から上に増加する向きで再圧縮が必要です。ただし標準的な画像(PNG・JPG)は自動的に処理されるため、特別な対応は不要です。

Phaser 4を選ぶべき人・Phaser 3のままでいい人

新規プロジェクトはPhaser 4一択。既存プロジェクトは「カスタムパイプライン使用の有無」で判断します。

Phaser 4を選ぶべき人

  • 新規でゲーム開発を始める人:今から学ぶならPhaser 4が将来性の面で断然有利です。
  • モバイル向けゲームを作りたい人:大幅なパフォーマンス改善により、スマホでの動作が格段に向上しています。
  • エフェクト・フィルターを多用したい人:統合フィルターシステムで、Bloom・Glow・Blurなどを自由に組み合わせられます。
  • 多数のスプライトを動かすゲームを作りたい人:SpriteGPULayerを活用すれば、弾幕ゲームや粒子表現が大幅に楽になります。
  • TypeScript + Viteのモダン環境で開発したい人:Phaser 4はモダンなESモジュール設計に最適化されています。

Phaser 3のままでもいい人(当面)

  • Phaser 3で完成・稼働中のゲームがある人:動いているゲームをわざわざ移行するリスクは避けた方が無難です。
  • Spine 3/Spine 4プラグインをPhaser内蔵で使っていた人:Phaser 3/4にバンドルされていたSpineプラグインはPhaser 4では更新されません。Esoteric Software公式のPhaser Spineプラグインへの移行が必要です。
  • カスタムWebGLパイプラインを大量に書いていた人:RenderNodeへの書き換えが発生するため、移行コストを見積もってから判断しましょう。

Phaser 3で標準APIのみを使っている場合(Sprite・Text・Tilemap等)、Phaser 4の新しいレンダラーは透過的に動作するため、コードの大きな変更なく動作するケースがほとんどです。

判断フローチャート

新規プロジェクト?
  ├─ Yes → Phaser 4を選ぶ(迷わずGO)
  └─ No(既存プロジェクト)
       ├─ カスタムWebGLパイプラインを使っている?
       │     ├─ Yes → 移行コストを見積もってから慎重に判断
       │     └─ No  → Phaser 4へ移行を検討(APIほぼ互換)
       └─ Spine内蔵プラグインを使っている?
             ├─ Yes → 公式プラグインへの移行が必要
             └─ No  → 移行は比較的容易

【最短5分】Phaser 4を動かす最小サンプル(Vite対応)

「まずは動くものを見たい」という気持ちが一番大切です。このセクションでは、環境構築から画面表示まで最短ルートで解説します。難しい概念は後回し、とにかく動かすことを最優先にします。

Vite + Phaser 4の最小テンプレ(コピペOK)

たった4ファイルで、Phaser 4のゲームをブラウザで動かせます。

以下の手順通りに進めれば、5分以内にPhaser 4が起動します。

手順

1. Node.jsのインストール確認

ターミナルを開いて以下を実行してください。v18.0.0以上が表示されればOKです。

node -v
npm -v

2. プロジェクトフォルダを作成する

mkdir my-phaser-game
cd my-phaser-game

3. package.jsonを作成する

npm init -y

4. ViteとPhaserをインストールする

npm install phaser
npm install -D vite

インストールが完了したら、以下の4ファイルを作成します。

ファイル構成

my-phaser-game/
├── index.html
├── src/
│   └── main.js
├── package.json
└── vite.config.js

package.json(scriptsを追記)

{
  "name": "my-phaser-game",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "phaser": "^4.0.0"
  },
  "devDependencies": {
    "vite": "^6.0.0"
  }
}

"type": "module" を必ず入れてください。これがないと import 構文がエラーになります。

vite.config.js

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    port: 8080,
    open: true, // ブラウザを自動で開く
  },
  build: {
    rollupOptions: {
      output: {
        // Phaserを別チャンクに分離してビルドを最適化
        manualChunks: {
          phaser: ['phaser'],
        },
      },
    },
  },
});

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Phaser 4 Game</title>
    <style>
      * { 
	      margin: 0; 
	      padding: 0; 
	      box-sizing: border-box; 
	    }
      body { 
	      background: #000; 
	      display: flex; 
	      justify-content: center; 
	      align-items: center; 
	      height: 100vh; 
	    }
    </style>
  </head>
  <body>
    <div id="game-container"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

src/main.js(最小のPhaser 4ゲーム)

import { createGame } from 'phaser';
import { Scene } from 'phaser/scenes';
import { Text } from 'phaser/gameobjects';
import { GameConfig } from 'phaser/config';

// Scene(シーン):ゲームの「画面」を定義するクラス
class GameScene extends Scene {
  constructor() {
    super('GameScene');
  }

  create(): void {
    // 画面中央にテキストを表示
    // v4ではクラスをインスタンス化して add.existing するのが標準的です
    const welcomeText = new Text(400, 300, 'Phaser 4 動いた!🎉', {
      fontSize: '32px',
      color: '#ffffff',
    });
    
    welcomeText.setOrigin(0.5);
    this.add.existing(welcomeText);
  }
}

// Phaserゲームの設定オブジェクト
const config: GameConfig = {
  // v4では明示的にレンダラーを指定するか、デフォルトの自動判定に任せます
  width: 800,
  height: 600,
  backgroundColor: '#1a1a2e',
  scene: GameScene,
  // parentはHTML上の要素ID
  parent: 'game-container',
};

// ゲームを起動
// v4では new Phaser.Game ではなく createGame 関数で初期化します
createGame(config);

5. 開発サーバーを起動する

npm run dev

ブラウザが自動で開き、http://localhost:8080 に「Phaser 4 動いた!🎉」と表示されれば成功です。

実際の表示
実際の表示

画像を表示するまでのコード(preload → create)

preload() でアセットを読み込み、create() で配置する2ステップが基本パターンです。

「テキストは出た。次は画像を表示したい」という方向けに、画像ファイルの読み込みと表示を追加します。

手順

1. 画像ファイルを用意する

public/assets/ フォルダを作成し、任意の画像(例:player.png)を配置します。

my-phaser-game/
├── public/
│   └── assets/
│       └── player.png  ← ここに画像を置く
├── src/
│   └── main.js
...

補足: public/ フォルダに置いたファイルはViteによってそのままコピーされます。コード内では /assets/player.png のように絶対パスで参照するのがポイントです。

2. src/main.js を以下に書き換える

import { createGame } from 'phaser';
import { Scene } from 'phaser/scenes';
import { Image, Text } from 'phaser/gameobjects';
import { GameConfig } from 'phaser/config';

// Scene(シーン)クラスの定義
class GameScene extends Scene {
  constructor() {
    super('GameScene');
  }

  // ① アセットの読み込み
  preload(): void {
    // 画像の登録
    this.load.image('player', '/assets/player.png');

    // 読み込み進捗の監視
    this.load.on('progress', (value: number) => {
      console.log(`Loading: ${Math.floor(value * 100)}%`);
    });
  }

  // ② インスタンス化と配置
  create(): void {
    // プレイヤー画像の生成
    // v4では new Image() で作成し、add.existing() でシーンに登録するのが基本です
    const player = new Image(400, 300, 'player');
    player.setScale(0.1);
    this.add.existing(player);

    // 確認用テキストの追加
    const infoText = new Text(400, 50, 'キャラクター表示成功!', {
      fontSize: '24px',
      fontFamily: 'monospace',
      color: '#00ff88',
    });
    infoText.setOrigin(0.5);
    this.add.existing(infoText);
  }
}

// ゲーム設定
const config: GameConfig = {
  width: 800,
  height: 600,
  parent: 'game-container',
  backgroundColor: '#1a1a2e',
  scene: GameScene,
};

// ゲームの起動
createGame(config);
実際の表示

preload / create の役割まとめ

メソッドタイミング主な用途
preload()ゲーム開始前画像・音声・JSONの読み込み登録
create()preload完了直後ゲームオブジェクトの生成・配置
update()毎フレーム(約60fps)移動・入力処理・衝突判定

create() の中で this.load.image() を呼ぶのは間違いです。アセットの読み込みは必ず preload() に書いてください。create() で呼んでも読み込みが完了する前に this.add.image() が実行されるため、画像が表示されません。

エラーなく起動するためのチェックポイント

Phaser 4の起動失敗は、ほぼ「モジュール設定ミス」「パス指定ミス」「型エラー」の3つに絞られます。

以下のチェックリストを上から順に確認してください。

❌ エラー例①:Cannot use import statement

SyntaxError: Cannot use import statement in a module

原因: package.json"type": "module" が入っていない。

修正:

// package.json
{
  "type": "module",  // ← これを追加
  ...
}

❌ エラー例②:Failed to load resource (画像が読み込めない)

GET <http://localhost:8080/assets/player.png> 404 (Not Found)

原因: 画像のパス指定が間違っている。または public/ フォルダ外に画像を置いている。

修正チェックリスト

  • public/assets/player.png に画像ファイルが存在するか確認
  • コード内のパスが /assets/player.png(先頭に /)になっているか確認
  • src/assets/ に置いた場合は import 文で読み込む必要がある(public/ が最も簡単)
// ✅ 正しいパス(publicフォルダ基準)
this.load.image('player', '/assets/player.png');

// ❌ よくある間違い(./や../は使わない)
this.load.image('player', './assets/player.png');

❌ エラー例③:画面が真っ黒(エラーなし)

原因: parent に指定したHTML要素のIDが存在しない、または backgroundColor が未指定で透明になっている。

修正:

// ① index.html側にidが存在するか確認
// <div id="game-container"></div>

// ② configのparentが一致しているか確認
const config = {
  parent: 'game-container', // ← index.htmlのidと完全一致させる
  backgroundColor: '#000000', // ← 背景色を明示的に指定
  ...
};

❌ エラー例④:WebGL is not supported

原因: type: Phaser.AUTO なのに、ブラウザやVM環境でWebGLが無効になっている。

修正: 開発中に限り Phaser.CANVAS に切り替えて動作確認できます(本番は AUTO 推奨)。

const config = {
  type: Phaser.CANVAS, // 一時的にCanvasで確認
  ...
};

起動前の最終チェックリスト

  • Node.js v18以上がインストール済みか
  • package.jsonに “type”: “module” があるか
  • npm install が完了しているか(node_modulesフォルダが存在するか)
  • index.htmlの <div id=”game-container”> とconfigの parent が一致しているか
  • 画像は public/assets/ フォルダに置いてあるか
  • 画像パスは /assets/ファイル名 の形式か(先頭にスラッシュ)
  • ブラウザのコンソール(F12)にエラーが出ていないか
◆◇◆ 【衝撃価格】VPS512MBプラン!1時間1.3円【ConoHa】 ◆◇◆

Phaser 4の環境構築(Node.js・npm・Vite・TypeScript対応)

前のセクションで最小サンプルを動かせた方も、「本格的に開発するための環境を整えたい」と感じているはずです。このセクションでは、TypeScript・Vite・適切なディレクトリ構成まで含めた、実務レベルの開発環境を一から構築します。Phaser入門として、この構成を覚えておけば中〜大規模なゲームにも対応できます。

npm installから始める!Viteスターターテンプレートの導入手順

公式の create-game コマンドを使えば、TypeScript + Vite構成が1コマンドで自動生成されます。手作業でゼロから作るよりも確実で速いです。

手順

1. Node.jsのバージョンを確認する

Phaser 4 + Viteの組み合わせには Node.js v18以上 が必要です。

node -v
# v18.0.0 以上であればOK

v18未満の場合は nodejs.org から最新LTS版をインストールしてください。バージョン管理ツール(nvmvolta)を使っている方は以下で切り替えられます。

# nvmを使っている場合
nvm install --lts
nvm use --lts

2. 公式スターターテンプレートでプロジェクトを生成する

Phaser公式の create-game アプリを使うと、Vite + TypeScript構成のプロジェクトが即座に生成されます。

npm create @phaserjs/game@latest my-phaser-game

実行するといくつか質問が表示されます。

補足: フレームワークは他にReact・Vue・Angularなども選べますが、ゲーム開発に余計な依存を持ち込まないためにも Vite(素のHTML) が最もシンプルでおすすめです。

3. 依存パッケージをインストールして起動する

cd my-phaser-game
npm install
npm run dev

http://localhost:8080 が自動で開き、Phaserのデモ画面が表示されれば成功です。

※画像貼り付け

実際の表示

4. 手動でゼロから作りたい場合のpackage.json

自動生成ではなく手動で構築したい場合は、以下の package.json を使ってください。

{
  "name": "my-phaser-game",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --port 8080 --open",
    "build": "tsc && vite build",
    "preview": "vite preview --port 8080 --open"
  },
  "dependencies": {
    "phaser": "^4.0.0"
  },
  "devDependencies": {
    "typescript": "~5.6.0",
    "vite": "^6.0.0"
  }
}

インストールは1コマンドです。

npm install

5. vite.config.ts を作成する

Viteの設定ファイルでは、開発サーバーのポート指定と、Phaserを別チャンクに分離するコード分割の設定を行います。Phaserのバンドルサイズは大きいため、ゲームコードと分離することで初回ロードのキャッシュ効率が上がります。

チャンクとは?

大きすぎるコードを、扱いやすくするために切り分けた一区切りの単位のことです。

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    port: 8080,
    open: true,
  },
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Phaserをゲームコードと分離して別チャンクに
          phaser: ['phaser'],
        },
      },
    },
    // Phaserのバンドルサイズ警告を抑制
    chunkSizeWarningLimit: 3000,
  },
});

npm create @phaserjs/game@latest の1コマンドで、TypeScript + Vite環境が即座に整います。手動構築の場合は上記の package.jsonvite.config.ts をそのままコピーしてください。

TypeScriptの型定義(d.ts)と型安全なスプライト操作の設定方法

Phaser 4はTypeScript用の型定義を同梱しているため、別途インストール不要です。tsconfig.json を正しく設定するだけで型補完が即座に効きます。

手順

1. tsconfig.json を作成する

プロジェクトルートに以下の tsconfig.json を配置します。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "types": ["vite/client"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

各オプションの意味:

オプション意味
"target": "ES2020"出力するJSのバージョン。モダンブラウザ向けに最適
"moduleResolution": "bundler"Viteと相性の良いモジュール解決方式
"strict": true型チェックを厳格に。バグの早期発見に有効
"types": ["vite/client"]Vite固有の型(import.meta.env等)を認識させる

2. Phaserの型定義が効いているか確認する

src/ 以下の .ts ファイルで以下を入力し、エディタ(VS Code)の補完が出れば正常です。

import Phaser from 'phaser';

// 型補完の確認:this.add. と入力するとメソッド一覧が出るはず
class GameScene extends Phaser.Scene {
  create(): void {
    // ここで this.add. と打って補完が出ればOK
    const text = this.add.text(100, 100, 'Hello');
  }
}

補足: Phaser 4のnpmパッケージには dist/phaser.d.ts が同梱されているため、@types/phaser のような別パッケージは不要です。もし過去に入れていた場合は削除してください。

# 不要なので削除(入れていた場合)
npm uninstall @types/phaser

3. 型安全なスプライト操作の実装パターン

TypeScriptで開発する最大のメリットは、プロパティの型ミスをコンパイル時に検出できることです。以下は実務でよく使うパターンです。

import * as Phaser from 'phaser';

class GameScene extends Phaser.Scene {
  // ① クラスプロパティとして型を宣言する
  //    undefinedの可能性があるため、!(非nullアサーション)またはオプショナルで宣言
  private player!: Phaser.GameObjects.Sprite;
  private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
  private platforms!: Phaser.Physics.Arcade.StaticGroup;

  constructor() {
    super({ key: 'GameScene' });
  }

  preload(): void {
    this.load.spritesheet('player', '/assets/player.png', {
      frameWidth: 48,
      frameHeight: 48,
    });
    this.load.image('ground', '/assets/ground.png');
  }

  create(): void {
    // ② Arcade Physicsのスプライトはaddではなくphysics.add.spriteで生成
    this.player = this.physics.add.sprite(400, 300, 'player');

    // ③ カーソルキーの型もPhaser側で定義済み
    //    createCursorKeys()はPhaser.Types.Input.Keyboard.CursorKeys型を返す
    this.cursors = this.input.keyboard!.createCursorKeys();

    // ④ 物理グループもphysics.add経由で作成
    this.platforms = this.physics.add.staticGroup();
  }

  update(): void {
    // ⑤ cursorsの各キーはPhaser.Input.Keyboard.Key型
    //    .isDown プロパティで押下状態を取得
    if (this.cursors.left.isDown) {
      (this.player.body as Phaser.Physics.Arcade.Body).setVelocityX(-200);
    } else if (this.cursors.right.isDown) {
      (this.player.body as Phaser.Physics.Arcade.Body).setVelocityX(200);
    } else {
      (this.player.body as Phaser.Physics.Arcade.Body).setVelocityX(0);
    }
  }
}

const config: Phaser.Types.Core.GameConfig = {
  type: Phaser.AUTO,
  width: 800,
  height: 600,
  parent: 'game-container',
  physics: {
    default: 'arcade',
    arcade: { gravity: { x: 0, y: 300 }, debug: false },
  },
  scene: GameScene,
};

new Phaser.Game(config);

よくある詰まりポイント: this.player.body の型は Phaser.Physics.Arcade.Body | null になるため、そのままでは setVelocityX を呼べません。Arcadeを使う場合は as Phaser.Physics.Arcade.Body でキャストするか、以下のように型ガードを使うとより安全です。

// 型ガードを使うより安全なパターン
const body = this.player.body as Phaser.Physics.Arcade.Body;
body.setVelocityX(-200);

プロジェクト構成(src / assets / scenes)のベストプラクティス

シーンごとにファイルを分割し、アセットは public/assets/ に集約する構成が、規模が大きくなっても破綻しにくい設計です。

推奨ディレクトリ構成

my-phaser-game/
│
├── public/
│   └── assets/                  # 静的アセット(画像・音声・JSON)
│       ├── images/
│       │   ├── player.png
│       │   ├── ground.png
│       │   └── background.png
│       ├── audio/
│       │   ├── bgm.mp3
│       │   └── jump.wav
│       └── tilemaps/
│           └── level1.json
│
├── src/
│   ├── main.ts                  # エントリーポイント(Phaserのconfig定義)
│   │
│   ├── scenes/                  # シーンファイル群
│   │   ├── BootScene.ts         # 初期化・最小アセット読み込み
│   │   ├── PreloadScene.ts      # メインアセットの読み込み・ロード画面
│   │   ├── TitleScene.ts        # タイトル画面
│   │   ├── GameScene.ts         # メインゲーム画面
│   │   └── ResultScene.ts       # リザルト画面
│   │
│   ├── objects/                 # ゲームオブジェクト(プレイヤー・敵など)
│   │   ├── Player.ts
│   │   └── Enemy.ts
│   │
│   ├── config/                  # 定数・設定値
│   │   └── constants.ts
│   │
│   └── types/                   # 独自の型定義
│       └── index.d.ts
│
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts

各ファイルの役割と実装例

src/main.ts(エントリーポイント)

ゲームの設定とシーンの登録だけに専念させます。ゲームロジックは一切書きません。

import * as Phaser from 'phaser';
import { BootScene }    from './scenes/BootScene';
import { PreloadScene } from './scenes/PreloadScene';
import { TitleScene }   from './scenes/TitleScene';
import { GameScene }    from './scenes/GameScene';
import { ResultScene }  from './scenes/ResultScene';

const config: Phaser.Types.Core.GameConfig = {
  type: Phaser.AUTO,
  width: 800,
  height: 600,
  parent: 'game-container',
  backgroundColor: '#1a1a2e',
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { x: 0, y: 300 },
      debug: false,
    },
  },
  // シーンは起動順に配列で登録する
  // 先頭のシーン(BootScene)が最初に実行される
  scene: [BootScene, PreloadScene, TitleScene, GameScene, ResultScene],
};

window.addEventListener('load', () => {
  new Phaser.Game(config);
});

src/config/constants.ts(定数管理)

マジックナンバーをコードに直書きするのを避け、一元管理します。

export const GAME_WIDTH  = 800;
export const GAME_HEIGHT = 600;

export const PLAYER_SPEED  = 200;
export const JUMP_VELOCITY = -400;
export const GRAVITY       = 300;

// アセットキーを定数化しておくと、タイポによるバグを防げる
export const ASSETS = {
  PLAYER:     'player',
  GROUND:     'ground',
  BACKGROUND: 'background',
  BGM:        'bgm',
  SE_JUMP:    'se_jump',
} as const;

src/objects/Player.ts(ゲームオブジェクトの分離)

プレイヤーのロジックをシーンから分離することで、シーンファイルがすっきりします。

import * as Phaser from 'phaser';
import { ASSETS, PLAYER_SPEED, JUMP_VELOCITY } from '../config/constants';

export class Player extends Phaser.Physics.Arcade.Sprite {
  private cursors: Phaser.Types.Input.Keyboard.CursorKeys;

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, ASSETS.PLAYER);

    // シーンに追加して物理演算を有効化
    scene.add.existing(this);
    scene.physics.add.existing(this);

    // 画面外に出ないように設定
    (this.body as Phaser.Physics.Arcade.Body).setCollideWorldBounds(true);

    // キーボード入力の取得
    this.cursors = scene.input.keyboard!.createCursorKeys();
  }

  // update()をシーンから呼び出す
  update(): void {
    const body = this.body as Phaser.Physics.Arcade.Body;

    if (this.cursors.left.isDown) {
      body.setVelocityX(-PLAYER_SPEED);
      this.setFlipX(true); // 左向きに反転
    } else if (this.cursors.right.isDown) {
      body.setVelocityX(PLAYER_SPEED);
      this.setFlipX(false);
    } else {
      body.setVelocityX(0);
    }

    // ジャンプ:地面に接地しているときのみ有効
    if (this.cursors.up.isDown && body.blocked.down) {
      body.setVelocityY(JUMP_VELOCITY);
    }
  }
}

構成の設計原則まとめ

原則理由
public/assets/ にアセットを集約パス指定が /assets/〜 で統一でき、ビルド時に自動コピーされる
シーンは1ファイル1シーンファイルが肥大化せず、チーム開発でも衝突しにくい
ゲームオブジェクトを objects/ に分離シーンのコードが見通しよくなり、再利用もしやすくなる
定数は config/constants.ts に集約マジックナンバーをなくし、仕様変更に強くなる
main.ts はconfig定義のみエントリーポイントをシンプルに保ち、依存関係を明確にする

よくあるミス: シーンファイルが1000行を超えてきたら、オブジェクトの分離タイミングです。「プレイヤーの処理」「敵の処理」「UI処理」などをクラスに切り出すと、コードの見通しが一気に改善します。

Phaser 4の基本概念(Scene / GameObject / Physics)

環境構築が完了したら、次は「Phaserの考え方」を理解する番です。Scene・GameObject・Physicsの3つを理解すれば、どんなゲームでも設計できるようになります。このセクションを読み終えたとき、「なぜこう書くのか」が腑に落ちた状態になることを目指します。

Sceneの構造とライフサイクル(init / preload / create / update)

PhaserのSceneは「ゲームの画面1枚」に相当します。4つのライフサイクルメソッドの呼び出し順序と役割を覚えるだけで、ゲームの流れを完全にコントロールできます。

Sceneとは何か

Sceneとは、タイトル画面・ゲーム画面・リザルト画面のようなゲームの「状態」を管理する単位です。Phaserは複数のSceneを同時に動かすこともでき、たとえばゲーム画面の上にUIだけを別Sceneで重ねて表示する、といった使い方もできます。

ライフサイクルの全体像

Sceneが起動
    ↓
① init()      ← シーンに引数を渡したいときだけ使う
    ↓
② preload()   ← アセットの読み込み(完了まで待機)
    ↓
③ create()    ← ゲームオブジェクトの生成・初期設定(1回だけ)
    ↓
④ update()    ← 毎フレーム実行(約60fps)
    ↓
Sceneが終了(this.scene.start('次のScene'))

各メソッドの役割と実装例

import * as Phaser from 'phaser';

// シーン間でデータを受け渡すための型定義
interface SceneData {
  score?: number;
  level?: number;
}

// ゲーム設定の定数化
const GAME_CONFIG = {
  PLAYER: {
    SPEED: 200,
    JUMP_FORCE: 400,
    BOUNCE: 0.1,
    START_X: 100,
    START_Y: 450,
  },
  STYLE: {
    FONT_SIZE: '20px',
    COLOR: '#ffffff',
  }
} as const;

export class GameScene extends Phaser.Scene {
  // クラスプロパティの宣言(TypeScript)
  private player!: Phaser.Physics.Arcade.Sprite;
  private score: number = 0;
  private scoreText!: Phaser.GameObjects.Text;
	// カーソルキーをプロパティとして保持
  private cursors?: Phaser.Types.Input.Keyboard.CursorKeys;
  
  constructor() {
    super({ key: 'GameScene' });
  }

  // -----------------------------------------------
  // ① init() ─ シーン起動時に最初に1回だけ呼ばれる
  //    前のシーンからデータを受け取るときに使う
  // -----------------------------------------------
  init(data: SceneData): void {
    // 前のシーンから渡されたスコアを引き継ぐ例
    this.score = data.score ?? 0;
    console.log(`シーン開始。引き継ぎスコア: ${this.score}`);
  }

  // -----------------------------------------------
  // ② preload() ─ アセットの読み込みを行う
  //    Phaserが自動でロード完了を待ってからcreate()へ進む
  // -----------------------------------------------
  preload(): void {
    // 画像の読み込み
    this.load.image('background', '/assets/images/background.png');
    this.load.image('ground',     '/assets/images/ground.png');

    // スプライトシート(アニメーション用)の読み込み
    // frameWidth/frameHeightは1コマのサイズを指定
    this.load.spritesheet('player', '/assets/images/player.png', {
      frameWidth:  48,
      frameHeight: 48,
    });

    // ロード進捗をコンソールに表示(デバッグ時に便利)
    this.load.on('progress', (value: number) => {
      console.log(`Loading: ${Math.floor(value * 100)}%`);
    });

    // ロード完了時のコールバック
    this.load.on('complete', () => {
      console.log('全アセットの読み込み完了');
    });
  }

  // -----------------------------------------------
  // ③ create() ─ preload完了後に1回だけ呼ばれる
  //    ゲームオブジェクトの生成・アニメーション定義・
  //    衝突設定などの初期化処理をすべてここに書く
  // -----------------------------------------------
  create(): void {
    // 背景画像を配置(座標はデフォルトで左上基点)
    this.add.image(0, 0, 'background').setOrigin(0, 0);

		// プレイヤーの生成
    this.player = this.physics.add.sprite(
      GAME_CONFIG.PLAYER.START_X,
      GAME_CONFIG.PLAYER.START_Y,
      'player'
    );
    
    this.player.setBounce(GAME_CONFIG.PLAYER.BOUNCE);
    this.player.setCollideWorldBounds(true);
    
		// アニメーション作成の共通化(必要に応じて外部メソッド化)
    this.createAnimations();

    // UI
    this.scoreText = this.add.text(16, 16, `Score: ${this.score}`, {
      fontSize: GAME_CONFIG.STYLE.FONT_SIZE,
      color: GAME_CONFIG.STYLE.COLOR,
    }).setScrollFactor(0);

    // 【重要】入力の初期化はcreateで1回だけ行う
    if (this.input.keyboard) {
      this.cursors = this.input.keyboard.createCursorKeys();
    }
  }

	private createAnimations(): void {
    this.anims.create({
      key: 'walk',
      frames: this.anims.generateFrameNumbers('player', { start: 0, end: 7 }),
      frameRate: 12,
      repeat: -1,
    });

    this.anims.create({
      key: 'idle',
      frames: this.anims.generateFrameNumbers('player', { start: 8, end: 11 }),
      frameRate: 8,
      repeat: -1,
    });
  }

  // -----------------------------------------------
  // ④ update() ─ 毎フレーム(約60fps)呼ばれる
  //    入力処理・移動・衝突判定などのリアルタイム処理を書く
  //    重い処理を書くとフレームレートが落ちるので注意
  // -----------------------------------------------
	update(): void {
    // 1. 入力デバイスの存在確認
    if (!this.cursors) return;

    // 2. 物理ボディの参照を安全に取得
    const body = this.player.body as Phaser.Physics.Arcade.Body;
    if (!body) return;

    // 3. 左右移動ロジック
    this.handlePlayerMovement(body);

    // 4. ジャンプロジック
    this.handlePlayerJump(body);
  }

  private handlePlayerMovement(body: Phaser.Physics.Arcade.Body): void {
    const { left, right } = this.cursors!;
    const speed = GAME_CONFIG.PLAYER.SPEED;

    if (left.isDown) {
      body.setVelocityX(-speed);
      this.player.anims.play('walk', true);
      this.player.setFlipX(true);
    } else if (right.isDown) {
      body.setVelocityX(speed);
      this.player.anims.play('walk', true);
      this.player.setFlipX(false);
    } else {
      body.setVelocityX(0);
      this.player.anims.play('idle', true);
    }
  }

  private handlePlayerJump(body: Phaser.Physics.Arcade.Body): void {
    const { up } = this.cursors!;
    // 接地判定(blocked.down または touching.down)
    if (up.isDown && body.blocked.down) {
      body.setVelocityY(-GAME_CONFIG.PLAYER.JUMP_FORCE);
    }
  }
  
}

よくある詰まりポイント: update() の中で this.input.keyboard!.createCursorKeys() を毎フレーム呼ぶのは非効率です。create() でクラスプロパティに保存しておき、update() では参照するだけにしましょう。

// ❌ 毎フレーム生成してしまう(非効率)
update(): void {
  const cursors = this.input.keyboard!.createCursorKeys(); // ← NG
}

// ✅ create()で1回だけ生成し、プロパティに保存
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;

create(): void {
  this.cursors = this.input.keyboard!.createCursorKeys(); // ← OK
}

update(): void {
  if (this.cursors.left.isDown) { ... } // ← プロパティを参照するだけ
}

シーンの切り替え方法

// 別シーンへ移動(データも渡せる)
this.scene.start('ResultScene', { score: this.score });

// 現シーンを一時停止して別シーンを起動(HUDに便利)
this.scene.launch('UIScene');
this.scene.pause('GameScene');

// 一時停止したシーンを再開
this.scene.resume('GameScene');
this.scene.stop('UIScene');

GameObject(Sprite・Image・Text)の基本と使い分け

Imageは静止画、Spriteはアニメーション付き、Textはテキスト表示です。物理演算が必要なオブジェクトは physics.add.sprite() で生成します。

主要なGameObjectの比較

クラス生成方法用途物理演算
Imagethis.add.image()背景・アイコン・静止画
Spritethis.add.sprite()アニメーション付きオブジェクト
Sprite(物理)this.physics.add.sprite()プレイヤー・敵・動く物体
Textthis.add.text()スコア・メッセージ表示
Rectanglethis.add.rectangle()デバッグ・UI要素・図形

Image(静止画)

create(): void {
  // this.add.image(x, y, 'キー名')
  // デフォルトの原点(origin)は中央(0.5, 0.5)
  const bg = this.add.image(400, 300, 'background');

  // 原点を左上に変更(背景画像に多い)
  // setOrigin(0, 0) = 左上を基点にする
  const bg2 = this.add.image(0, 0, 'background').setOrigin(0, 0);

  // スケール・回転・透明度の設定
  bg.setScale(1.5);           // 1.5倍に拡大
  bg.setAngle(45);             // 45度回転
  bg.setAlpha(0.8);            // 透明度80%

  // 深度(重なり順)の設定。大きい値が前面に来る
  bg.setDepth(0);
}

Sprite(アニメーション付き)

create(): void {
  // 通常Sprite(物理演算なし)
  const decoration = this.add.sprite(200, 200, 'coin');

  // アニメーション再生
  decoration.anims.play('spin');

  // 物理演算ありのSprite(プレイヤーや敵に使う)
  const player = this.physics.add.sprite(400, 300, 'player');

  // Arcade Physics固有のメソッドが使えるようになる
  const body = player.body as Phaser.Physics.Arcade.Body;
  body.setGravityY(200);       // このオブジェクト固有の重力を追加
  body.setMaxVelocityY(500);   // Y方向の最大速度を制限
  player.setCollideWorldBounds(true); // 画面端で止まるように
}

Text(テキスト表示)

create(): void {
  // 基本的なテキスト
  // this.add.text(x, y, 'テキスト', スタイルオブジェクト)
  const title = this.add.text(400, 100, 'GAME START', {
    fontSize: '48px',
    fontFamily: '"Arial Black", sans-serif',
    color: '#ffffff',
    stroke: '#000000',        // 文字の縁取り色
    strokeThickness: 6,       // 縁取りの太さ
    shadow: {
      offsetX: 2,
      offsetY: 2,
      color: '#000000',
      blur: 4,
      fill: true,
    },
  }).setOrigin(0.5); // 中央揃え

  // テキストの動的更新(スコア表示に使う)
  let score = 0;
  const scoreText = this.add.text(16, 16, 'Score: 0', {
    fontSize: '24px',
    color: '#ffff00',
  });

  // テキストを更新する場合はsetText()を使う
  score += 100;
  scoreText.setText(`Score: ${score}`);

  // スクロールしても画面上に固定(HUD用)
  scoreText.setScrollFactor(0);
}

GameObjectに共通して使える便利メソッド

const obj = this.add.image(400, 300, 'player');

// 位置・サイズ
obj.setPosition(200, 400);   // 座標を変更
obj.setScale(2);             // 縦横同率で拡大
obj.setScale(2, 1.5);        // 縦横別々に拡大

// 表示制御
obj.setVisible(false);       // 非表示(更新は続く)
obj.setAlpha(0.5);           // 半透明
obj.setDepth(10);            // 重なり順(大きいほど前面)

// 原点の変更
obj.setOrigin(0.5, 0.5);     // 中央(デフォルト)
obj.setOrigin(0, 0);         // 左上
obj.setOrigin(0.5, 1);       // 中央下(足元を基点にしたい場合)

// 破棄(メモリ解放)
obj.destroy();

静止画はImage、アニメーション付きはSprite、テキストはText。動かしたいオブジェクトはphysics.add.sprite()で生成するのが基本パターンです。

Arcade Physicsの有効化と衝突判定の基本設定

Arcade Physicsはゲーム設定の physics キーで有効化するだけで使えます。衝突判定は addCollider()addOverlap() の2種類を使い分けます。

Arcade Physicsとは

PhaserにはArcade・Matter・Box2Dの3種類の物理エンジンがあります。2Dアクションゲームで最もよく使われるのはArcade Physicsです。計算が軽量で実装も簡単ですが、矩形(四角形)と円形の当たり判定のみサポートしています。複雑な多角形の当たり判定が必要な場合はMatter.jsを使います。

物理エンジンの違い

1. Arcade Physics(アーケード)「速さとシンプルさ重視」

  • 特徴: 最も軽量で動作が速い。衝突判定は「回転しない四角(AABB)」と「円」のみ。
  • 向いているゲーム: シンプルなプラットフォーマー(マリオ系)、アクションゲーム、弾幕シューティング。
  • 注意点: 坂道を転がったり、複雑な形の物体をぶつけたりする表現には不向き。

2. Matter.js(マター)「リアルな物理挙動重視」

  • 特徴: 本格的な物理エンジン。多角形の衝突、回転、重力、摩擦、バネのような接続(ジョイント)をシミュレーション可能。
  • 向いているゲーム: 物理パズル(Angry Birds系)、物体がリアルに転がる・積み上がるゲーム。
  • 注意点: Arcadeに比べて処理が重く、設定が少し複雑。

3. Box2D(ボックス・ツー・ディー)「最高峰の精度と多機能」

  • 特徴: 業界標準の非常に強力なエンジン。Matter.jsよりもさらに精密な計算が可能で、機能も豊富。
  • 向いているゲーム: 極めて正確な物理シミュレーションが必要なプロ仕様のゲーム。
  • 注意点: Phaser本体とは別に有料プラグインが必要な場合が多く、初心者には導入のハードルが高め。

有効化の設定

const config: Phaser.Types.Core.GameConfig = {
  type: Phaser.AUTO,
  width: 800,
  height: 600,
  physics: {
    default: 'arcade',         // ← Arcade Physicsを有効化
    arcade: {
      gravity: { x: 0, y: 300 }, // ゲーム全体にかかる重力
                                   // y: 300 = 下方向に重力
      debug: false,              // trueにすると当たり判定を可視化(開発中に便利)
    },
  },
  scene: GameScene,
};

開発中は debug: true にしておくと、当たり判定の矩形が緑色で表示されます。スプライトとズレていないか確認できるので、ステージ設計時に重宝します。

衝突判定の2種類

メソッド動作用途
addCollider()物理的に衝突して止まる地面・壁・障害物
addOverlap()すり抜けるが接触を検知コイン取得・ダメージ判定

実装例(プレイヤーと地面の衝突)

export class GameScene extends Phaser.Scene {
  private player!: Phaser.Physics.Arcade.Sprite;
  private platforms!: Phaser.Physics.Arcade.StaticGroup;
  private coins!: Phaser.Physics.Arcade.Group;
  private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
  private score: number = 0;
  private scoreText!: Phaser.GameObjects.Text;

  constructor() {
    super({ key: 'GameScene' });
  }

  preload(): void {
    this.load.image('ground', '/assets/images/ground.png');
    this.load.image('coin',   '/assets/images/coin.png');
    this.load.spritesheet('player', '/assets/images/player.png', {
      frameWidth: 48, 
      frameHeight: 48,
    });
  }

  create(): void {
    // ─── 地面(StaticGroup)の設定 ───────────────────────
    // StaticGroup = 動かない物理オブジェクトのグループ
    // 地面・壁・固定プラットフォームに使う
    this.platforms = this.physics.add.staticGroup();

    // 地面を画面下部に配置
    this.platforms.create(400, 580, 'ground').setScale(2).refreshBody();
    // ↑ setScaleでスケール変更した後はrefreshBody()を必ず呼ぶ
    //   これを忘れると当たり判定が元のサイズのまま残る(よくあるミス)

    // 足場を追加(プラットフォーマーゲーム向け)
    this.platforms.create(600, 400, 'ground');
    this.platforms.create(50,  250, 'ground');
    this.platforms.create(750, 220, 'ground');

    // ─── プレイヤーの設定 ─────────────────────────────────
    this.player = this.physics.add.sprite(100, 450, 'player');
    this.player.setBounce(0.1);
    this.player.setCollideWorldBounds(true);

    // ─── コイン(Group)の設定 ───────────────────────────
    // Group = 動く物理オブジェクトのグループ
    this.coins = this.physics.add.group();

    // コインを複数生成
    for (let i = 0; i < 10; i++) {
      const x = Phaser.Math.Between(50, 750); // 50〜750のランダムなX座標
      const coin = this.coins.create(x, 0, 'coin') as Phaser.Physics.Arcade.Image;
      coin.setBounceY(Phaser.Math.FloatBetween(0.3, 0.8)); // バウンドをランダムに
    }

    // ─── 衝突・重複判定の設定 ────────────────────────────

    // ① Collider:物理的に衝突して止まる
    //    プレイヤーと地面が当たったら「止まる」
    this.physics.add.collider(this.player, this.platforms);

    // コインも地面で止まるようにする
    this.physics.add.collider(this.coins, this.platforms);

    // ② Overlap:すり抜けるが接触を検知
    //    プレイヤーがコインに触れたらcollectCoin()を呼ぶ
    this.physics.add.overlap(
      this.player,
      this.coins,
      this.collectCoin,  // コールバック関数
      undefined,         // processCallback(trueを返したときだけ発火。通常はundefined)
      this               // thisのコンテキスト
    );

    // ─── UI ─────────────────────────────────────────────
    this.scoreText = this.add.text(16, 16, 'Score: 0', {
      fontSize: '24px',
      color: '#ffffff',
    }).setScrollFactor(0);

    this.cursors = this.input.keyboard!.createCursorKeys();
  }

  // Overlapのコールバック関数
  // 引数の型はGameObject。必要に応じてキャスト
  private collectCoin(
    _player: Phaser.Types.Physics.Arcade.GameObjectWithBody,
    coin:    Phaser.Types.Physics.Arcade.GameObjectWithBody
  ): void {
    // コインを非活性化して非表示にする(destroyより軽量)
    (coin as Phaser.Physics.Arcade.Image).disableBody(true, true);

    this.score += 10;
    this.scoreText.setText(`Score: ${this.score}`);

    // 全コインを取り終えたら次のレベルへ
    if (this.coins.countActive(true) === 0) {
      this.scene.start('ResultScene', { score: this.score });
    }
  }

  update(): void {
    const body = this.player.body as Phaser.Physics.Arcade.Body;

    if (this.cursors.left.isDown) {
      body.setVelocityX(-200);
      this.player.setFlipX(true);
    } else if (this.cursors.right.isDown) {
      body.setVelocityX(200);
      this.player.setFlipX(false);
    } else {
      body.setVelocityX(0);
    }

    // body.blocked.down = 地面に接地しているか
    if (this.cursors.up.isDown && body.blocked.down) {
      body.setVelocityY(-450);
    }
  }
}

当たり判定サイズの調整

スプライト画像に余白(透明ピクセル)がある場合、当たり判定を画像より小さく設定するとゲームの感触が自然になります。

// 当たり判定のサイズと位置をカスタマイズ
const body = this.player.body as Phaser.Physics.Arcade.Body;

// setSize(幅, 高さ) で当たり判定のサイズを変更
body.setSize(32, 44);

// setOffset(x, y) で当たり判定の位置をずらす
// スプライトが48x48で当たり判定が32x44なら
// (48-32)/2=8, (48-44)/2=2 ずらすと中央に揃う
body.setOffset(8, 2);

物理演算はphysics設定で有効化。止まる衝突はaddCollider()、すり抜け検知はaddOverlap()staticGroupが地面、groupが動くオブジェクトという使い分けを覚えれば、2Dアクションの基礎はほぼ完成です。

入力処理とキャラクター操作(キーボード・マウス・タッチ)

ゲームを「動かせる」状態にするには、入力処理が欠かせません。「キーを押したら動く」という体験があってはじめてゲームになります。このセクションではキーボード・マウス・タッチの3種類の入力を網羅し、スマホ対応まで含めた実装パターンを解説します。

キーボード入力(カーソルキー・WASD)の実装例

カーソルキーは createCursorKeys()、WASDなど任意のキーは addKey() で取得します。両方を同時に対応させるのがユーザー体験として最善です。

カーソルキーの取得と使い方

createCursorKeys() は上下左右+ShiftとSpaceの6キーをまとめて返す便利メソッドです。

export class GameScene extends Phaser.Scene {
  private player!: Phaser.Physics.Arcade.Sprite;
  private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;

  constructor() {
    super({ key: 'GameScene' });
  }

  create(): void {
    this.player = this.physics.add.sprite(400, 300, 'player');
    this.player.setCollideWorldBounds(true);

    // create()で1回だけ生成してプロパティに保存する
    this.cursors = this.input.keyboard!.createCursorKeys();

    // createCursorKeys()が返すキーの一覧
    // this.cursors.left    ← ←キー
    // this.cursors.right   ← →キー
    // this.cursors.up      ← ↑キー
    // this.cursors.down    ← ↓キー
    // this.cursors.shift   ← Shiftキー
    // this.cursors.space   ← Spaceキー
  }

  update(): void {
    const body = this.player.body as Phaser.Physics.Arcade.Body;

    // 水平移動
    if (this.cursors.left.isDown) {
      body.setVelocityX(-200);
      this.player.setFlipX(true);
    } else if (this.cursors.right.isDown) {
      body.setVelocityX(200);
      this.player.setFlipX(false);
    } else {
      body.setVelocityX(0);
    }

    // ジャンプ(接地中のみ)
    if (this.cursors.up.isDown && body.blocked.down) {
      body.setVelocityY(-450);
    }

    // Spaceでも同様にジャンプ
    if (this.cursors.space.isDown && body.blocked.down) {
      body.setVelocityY(-450);
    }

    // Shiftで高速移動
    const speed = this.cursors.shift.isDown ? 400 : 200;
    if (this.cursors.left.isDown)  body.setVelocityX(-speed);
    if (this.cursors.right.isDown) body.setVelocityX(speed);
  }
}

WASDキーの追加

カーソルキーとWASDを同時対応させることで、どちらのスタイルのプレイヤーにも対応できます。

// 'phaser' パッケージ内の各サブパスから必要な機能をインポートします
import { Scene } from 'phaser/scenes';
import { Sprite } from 'phaser/gameobjects';
import { Keyboard } from 'phaser/input/keyboard';
import { ArcadePhysics } from 'phaser/physics/arcade';

interface WASDKeys {
    W: Keyboard.Key;
    A: Keyboard.Key;
    S: Keyboard.Key;
    D: Keyboard.Key;
}

export class GameScene extends Scene {
    private player!: Sprite;
    private cursors!: any;
    private wasd!: WASDKeys;
    private physics!: ArcadePhysics;

    constructor() {
        super('GameScene');
    }

    create(): void {
        // 物理エンジンの初期化
        this.physics = new ArcadePhysics(this);

        // スプライトの作成
        this.player = new Sprite(400, 300, 'player');
        
        // 物理演算の適用
        this.physics.add.existing(this.player);
        this.player.body.setCollideWorldBounds(true);
        
        // シーンへの追加(v4では明示的に追加が必要です)
        this.add.existing(this.player);

        // キーボード入力
        const keyboard = new Keyboard(this.game);
        this.cursors = keyboard.createCursorKeys();

        // v4ではキーコードの指定がシンプルになっています
        this.wasd = {
            W: keyboard.addKey('W'),
            A: keyboard.addKey('A'),
            S: keyboard.addKey('S'),
            D: keyboard.addKey('D'),
        };
    }

    update(): void {
        const body = this.player.body;

        const goLeft  = this.cursors.left.isDown  || this.wasd.A.isDown;
        const goRight = this.cursors.right.isDown || this.wasd.D.isDown;
        const goUp    = this.cursors.up.isDown    || this.wasd.W.isDown;

        if (goLeft) {
            body.setVelocityX(-200);
            this.player.setFlipX(true);
        } else if (goRight) {
            body.setVelocityX(200);
            this.player.setFlipX(false);
        } else {
            body.setVelocityX(0);
        }

        if (goUp && body.blocked.down) {
            body.setVelocityY(-450);
        }
    }
}

キーイベント(押した瞬間・離した瞬間)の検出

isDown は「押している間ずっとtrue」ですが、押した瞬間だけ処理したい場合は JustDown() を使います。

import { Scene } from 'phaser/scenes';
import { Keyboard } from 'phaser/input/keyboard';

export class GameScene extends Scene {
  private jumpKey!: Keyboard.Key;
  private attackKey!: Keyboard.Key;

  create(): void {
    const keyboard = new Keyboard(this.game);

    // 文字列で直感的に指定可能
    this.jumpKey = keyboard.addKey('SPACE');
    this.attackKey = keyboard.addKey('Z');
  }

  update(): void {
    // Phaser 4 では Key オブジェクトのプロパティを直接参照します
    
    // JustDown 相当: 押された瞬間のフレームか判定
    if (this.jumpKey.isDown && this.jumpKey.isFresh()) {
      console.log('ジャンプ!(1回だけ発火)');
    }

    // もしくは、v4で導入されたプロパティを利用(バージョンにより実装が最適化されています)
    if (this.attackKey.justDown) {
      console.log('攻撃!(1回だけ発火)');
    }

    // JustUp 相当
    if (this.attackKey.justUp) {
      console.log('攻撃キーを離した');
    }
  }
}

ジャンプに isDown を使うと、キーを押し続けている間に連続ジャンプしてしまいます。ジャンプや攻撃など「1回だけ発動させたい処理」には必ず JustDown() を使いましょう。

移動には isDown、ジャンプ・攻撃には JustDown() を使い分けるのが基本です。カーソルキーとWASDを両対応させると、プレイヤーの満足度が上がります。

マウス・タッチ操作のイベントリスナーとスマホ対応

Phaser 4はマウスとタッチを統一した Pointer オブジェクトで扱います。Pointer を使えば、同じコードでPC・スマホ両対応が実現できます。

Pointerオブジェクトの基本

export class GameScene extends Phaser.Scene {
  constructor() {
    super({ key: 'GameScene' });
  }

  create(): void {
    // ─── クリック・タップイベント ───────────────────────

    // 画面のどこかをクリック/タップしたとき
    this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
      console.log(`クリック/タップ位置: x=${pointer.x}, y=${pointer.y}`);

      // ワールド座標(カメラスクロールを考慮した実際の座標)
      console.log(`ワールド座標: x=${pointer.worldX}, y=${pointer.worldY}`);

      // 左クリック or 右クリックの判定
      if (pointer.leftButtonDown()) {
        console.log('左クリック');
      }
      if (pointer.rightButtonDown()) {
        console.log('右クリック');
      }
    });

    // クリック/タップを離したとき
    this.input.on('pointerup', (pointer: Phaser.Input.Pointer) => {
      console.log('ポインターを離した');
    });

    // マウスを動かしている間(タッチドラッグも含む)
    this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
      if (pointer.isDown) {
        // ドラッグ中
        console.log(`ドラッグ中: x=${pointer.x}, y=${pointer.y}`);
      }
    });
  }
}

ゲームオブジェクトへのクリック判定

特定のオブジェクトがクリック/タップされたかを判定するには、そのオブジェクトをインタラクティブに設定します。

create(): void {
  const button = this.add.image(400, 300, 'button');

  // インタラクティブを有効化(クリック判定を持たせる)
  button.setInteractive();

  // クリック/タップされたとき
  button.on('pointerdown', () => {
    console.log('ボタンがクリックされた');
    this.scene.start('GameScene');
  });

  // マウスオーバー時
  button.on('pointerover', () => {
    button.setScale(1.1); // 少し拡大してホバー感を演出
    this.input.setDefaultCursor('pointer'); // カーソルを指マークに
  });

  // マウスアウト時
  button.on('pointerout', () => {
    button.setScale(1.0);
    this.input.setDefaultCursor('default');
  });

  // 画像ではなく矩形エリアを指定してインタラクティブ化
  // テキストや透明領域のあるオブジェクトに有効
  const text = this.add.text(200, 200, 'クリックしてね', {
    fontSize: '24px',
    color: '#ffffff',
  });

  // hitAreaで当たり判定の矩形を明示的に指定
  text.setInteractive(
    new Phaser.Geom.Rectangle(0, 0, text.width, text.height),
    Phaser.Geom.Rectangle.Contains
  );

  text.on('pointerdown', () => {
    console.log('テキストがクリックされた');
  });
}

スマホ対応:バーチャルパッドの実装

スマホゲームでは画面上に仮想ボタンを置くのが一般的です。以下は左右移動ボタンとジャンプボタンの実装例です。

export class GameScene extends Phaser.Scene {
  private player!: Phaser.Physics.Arcade.Sprite;

  // 仮想ボタンの状態フラグ
  private isTouchLeft:  boolean = false;
  private isTouchRight: boolean = false;
  private isTouchJump:  boolean = false;

  constructor() {
    super({ key: 'GameScene' });
  }

  create(): void {
    this.player = this.physics.add.sprite(400, 300, 'player');
    this.player.setCollideWorldBounds(true);

    this.createVirtualPad();
  }

  private createVirtualPad(): void {
    // ─── 左ボタン ────────────────────────────────────
    const leftBtn = this.add
      .rectangle(60, 520, 80, 80, 0xffffff, 0.3)
      .setInteractive()
      .setScrollFactor(0); // 画面固定(スクロールに追従しない)

    this.add.text(60, 520, '◀', {
      fontSize: '28px',
      color: '#ffffff',
    }).setOrigin(0.5).setScrollFactor(0);

    leftBtn.on('pointerdown', () => { this.isTouchLeft = true;  });
    leftBtn.on('pointerup',   () => { this.isTouchLeft = false; });
    leftBtn.on('pointerout',  () => { this.isTouchLeft = false; });

    // ─── 右ボタン ────────────────────────────────────
    const rightBtn = this.add
      .rectangle(160, 520, 80, 80, 0xffffff, 0.3)
      .setInteractive()
      .setScrollFactor(0);

    this.add.text(160, 520, '▶', {
      fontSize: '28px',
      color: '#ffffff',
    }).setOrigin(0.5).setScrollFactor(0);

    rightBtn.on('pointerdown', () => { this.isTouchRight = true;  });
    rightBtn.on('pointerup',   () => { this.isTouchRight = false; });
    rightBtn.on('pointerout',  () => { this.isTouchRight = false; });

    // ─── ジャンプボタン ──────────────────────────────
    const jumpBtn = this.add
      .circle(700, 520, 45, 0xffaa00, 0.4)
      .setInteractive()
      .setScrollFactor(0);

    this.add.text(700, 520, 'JUMP', {
      fontSize: '16px',
      color: '#ffffff',
    }).setOrigin(0.5).setScrollFactor(0);

    jumpBtn.on('pointerdown', () => { this.isTouchJump = true;  });
    jumpBtn.on('pointerup',   () => { this.isTouchJump = false; });
    jumpBtn.on('pointerout',  () => { this.isTouchJump = false; });
  }

  update(): void {
    const body    = this.player.body as Phaser.Physics.Arcade.Body;
    const cursors = this.input.keyboard!.createCursorKeys();

    // キーボードとタッチを統合
    const goLeft  = cursors.left.isDown  || this.isTouchLeft;
    const goRight = cursors.right.isDown || this.isTouchRight;
    const goJump  = cursors.up.isDown    || this.isTouchJump;

    if (goLeft) {
      body.setVelocityX(-200);
      this.player.setFlipX(true);
    } else if (goRight) {
      body.setVelocityX(200);
      this.player.setFlipX(false);
    } else {
      body.setVelocityX(0);
    }

    if (goJump && body.blocked.down) {
      body.setVelocityY(-450);
      this.isTouchJump = false; // 連続ジャンプ防止
    }
  }
}

setScrollFactor(0) を忘れると、カメラがスクロールしたときにバーチャルパッドも一緒に動いてしまい、画面外に消えます。UI要素には必ず setScrollFactor(0) を設定してください。

Phaserの Pointer はマウスとタッチを統一して扱えます。setInteractive() でオブジェクトにイベントを付け、バーチャルパッドのフラグとキーボード入力を || でまとめるのがPC・スマホ両対応の最短パターンです。

キャラクターの移動・ジャンプ処理の実装(2Dアクション基礎)

移動は速度ベクトルで制御し、ジャンプは接地判定と JustDown() を組み合わせることで、自然な操作感が実現できます。仕上げに慣性とアニメーション連動を加えると完成度が一気に上がります。

完成版:移動・ジャンプ・アニメーション連動

以下は「動いていて気持ちいい」と感じられる、実務レベルのプレイヤー制御の完全実装です。

import { Sprite } from 'phaser/gameobjects';
import { Keyboard } from 'phaser/input/keyboard';
import { ArcadePhysics } from 'phaser/physics/arcade';
import { Scene } from 'phaser/scenes';

export class Player extends Sprite {
  // 入力
  private keyboard: Keyboard;
  private cursors: any;
  private jumpKey: Keyboard.Key;

  // 状態管理
  private isJumping: boolean = false;
  private jumpCount: number = 0;
  private readonly MAX_JUMPS = 2;

  // パラメータ
  private readonly SPEED = 220;
  private readonly JUMP_POWER = -480;
  private readonly FRICTION = 0.85;

  public body: ArcadePhysics.Body;

  constructor(scene: Scene, x: number, y: number) {
    super(x, y, 'player');

    // シーンと物理演算への登録
    scene.add.existing(this);
    
    const physics = new ArcadePhysics(scene);
    physics.add.existing(this);
    
    this.body = this.body as ArcadePhysics.Body;
    this.body.setCollideWorldBounds(true);
    this.body.setMaxVelocity(400, 600);
    this.body.setDragX(800);

    // 入力のセットアップ
    this.keyboard = new Keyboard(scene.game);
    this.cursors = this.keyboard.createCursorKeys();
    this.jumpKey = this.keyboard.addKey('SPACE');

    this.setupAnimations(scene);
  }

  private setupAnimations(scene: Scene): void {
    const anims = scene.anims;

    if (!anims.exists('player_idle')) {
      anims.create({
        key: 'player_idle',
        frames: anims.generateFrameNumbers('player', { start: 0, end: 3 }),
        frameRate: 6,
        repeat: -1,
      });
    }

    if (!anims.exists('player_walk')) {
      anims.create({
        key: 'player_walk',
        frames: anims.generateFrameNumbers('player', { start: 4, end: 11 }),
        frameRate: 12,
        repeat: -1,
      });
    }

    if (!anims.exists('player_jump')) {
      anims.create({
        key: 'player_jump',
        frames: anims.generateFrameNumbers('player', { start: 12, end: 15 }),
        frameRate: 8,
        repeat: 0,
      });
    }

    if (!anims.exists('player_fall')) {
      anims.create({
        key: 'player_fall',
        frames: anims.generateFrameNumbers('player', { start: 16, end: 17 }),
        frameRate: 6,
        repeat: -1,
      });
    }
  }

  update(): void {
    const body = this.body;

    if (body.blocked.down) {
      this.jumpCount = 0;
      this.isJumping = false;
    }

    this.handleMove(body);
    this.handleJump(body);
    this.updateAnimation(body);
  }

  private handleMove(body: ArcadePhysics.Body): void {
    const goLeft = this.cursors.left.isDown;
    const goRight = this.cursors.right.isDown;

    if (goLeft) {
      body.setAccelerationX(-800);
      this.setFlipX(true);
    } else if (goRight) {
      body.setAccelerationX(800);
      this.setFlipX(false);
    } else {
      body.setAccelerationX(0);
    }
  }

  private handleJump(body: ArcadePhysics.Body): void {
    // v4の justDown プロパティを使用
    const jumpPressed = this.cursors.up.justDown || this.jumpKey.justDown;

    if (jumpPressed && this.jumpCount < this.MAX_JUMPS) {
      body.setVelocityY(this.JUMP_POWER);
      this.jumpCount++;
      this.isJumping = true;
      this.play('player_jump', true);
    }

    const jumpReleased = this.cursors.up.justUp || this.jumpKey.justUp;

    if (jumpReleased && body.velocity.y < -200) {
      body.setVelocityY(body.velocity.y * this.FRICTION);
    }
  }

  private updateAnimation(body: ArcadePhysics.Body): void {
    if (!body.blocked.down) {
      if (body.velocity.y < 0) {
        if (this.anims.getName() !== 'player_jump') {
          this.play('player_jump', true);
        }
      } else {
        this.play('player_fall', true);
      }
    } else if (Math.abs(body.velocity.x) > 20) {
      this.play('player_walk', true);
    } else {
      this.play('player_idle', true);
    }
  }
}

GameSceneでPlayerクラスを使う

import { Scene } from 'phaser/scenes';
import { ArcadePhysics } from 'phaser/physics/arcade';
import { StaticGroup } from 'phaser/gameobjects/staticgroup';
import { Loader } from 'phaser/loader';
import { Player } from '../objects/Player';

export class GameScene extends Scene {
  private player!: Player;
  private platforms!: StaticGroup;
  private physics!: ArcadePhysics;

  constructor() {
    super('GameScene');
  }

  preload(): void {
    // Loaderもv4では内部的に整理されています
    this.load.spritesheet('player', '/assets/images/player.png', {
      frameWidth: 48,
      frameHeight: 48,
    });
    this.load.image('ground', '/assets/images/ground.png');
  }

  create(): void {
    // 1. 物理エンジンの初期化
    this.physics = new ArcadePhysics(this);

    // 2. 地面(StaticGroup)の設置
    // v4では物理エンジンを引数に渡すか、physics.add経由で生成します
    this.platforms = new StaticGroup(this.world);
    this.physics.add.existingGroup(this.platforms);

    const ground1 = this.platforms.create(400, 580, 'ground');
    ground1.setScale(2);
    ground1.body.updateFromGameObject(); // refreshBodyに相当

    this.platforms.create(200, 420, 'ground');
    this.platforms.create(600, 320, 'ground');

    // 3. プレイヤーの生成
    this.player = new Player(this, 100, 450);

    // 4. 衝突設定
    this.physics.add.collider(this.player, this.platforms);

    // 5. カメラの設定
    const cam = this.cameras.main;
    cam.startFollow(this.player, true, 0.1, 0.1);
    cam.setDeadzone(100, 50);
  }

  update(): void {
    // Playerクラスの内部で作成した物理ボディを更新するため、
    // 明示的にupdateを呼び出します
    this.player.update();
  }
}

入力処理のよくあるミスと対策まとめ

ミス症状対策
createCursorKeys()update() で毎回呼ぶ毎フレームオブジェクトが生成されメモリ消費が増えるcreate() で1回だけ生成してプロパティに保存
ジャンプに isDown を使うキーを押し続けると連続ジャンプしてしまうJustDown() を使う
アニメーションの重複登録シーン再起動時に「アニメーション名が重複」エラーanims.exists() で確認してから登録
setScrollFactor(0) 忘れカメラスクロールでUIが画面外に消えるUI要素には必ず setScrollFactor(0) を設定
接地判定なしのジャンプ空中でも何度でもジャンプできるbody.blocked.down または jumpCount で制限

移動は setAccelerationX() で加速・dragX で自然減速、ジャンプは JustDown() と接地判定の組み合わせが「気持ちよく動く」キャラクター操作の基本セットです。Playerクラスに分離することでシーンがシンプルに保てます。

スプライト・アニメーション・アセット管理

キャラクターを動かせるようになったら、次は「見た目を豊かにする」番です。スプライトシートのアニメーション・複数アセットの効率的な管理・BGMやSEの再生まで、ゲームの完成度を一気に上げる実装をまとめて解説します。

スプライトシートの読み込みとアニメーション再生

スプライトシートは load.spritesheet() で読み込み、anims.create() でアニメーションを定義します。この2ステップを覚えれば、どんなキャラクターアニメーションも実装できます。

スプライトシートとは

スプライトシートとは、複数のアニメーションフレームを1枚の画像にまとめたものです。画像を1枚ずつ読み込むより通信コストが低く、ゲームで標準的に使われます。

player.png(例:横8コマ × 縦4行 = 32フレーム)
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 0  │ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │ 7  │ ← 待機アニメ(0〜3)+ 歩行(4〜7)
├────┼────┼────┼────┼────┼────┼────┼────┤
│ 8  │ 9  │ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ ← ジャンプ(8〜11)+ 攻撃(12〜15)
└────┴────┴────┴────┴────┴────┴────┴────┘
各コマのサイズ: 48px × 48px

読み込みと定義の基本パターン

export class GameScene extends Phaser.Scene {
  constructor() {
    super({ key: 'GameScene' });
  }

  preload(): void {
    // ─── スプライトシートの読み込み ───────────────────
    // load.spritesheet(キー名, パス, フレーム設定)
    this.load.spritesheet('player', '/assets/images/player.png', {
      frameWidth:  48,   // 1コマの横幅(px)
      frameHeight: 48,   // 1コマの縦幅(px)
      // 必要に応じて以下も指定できる
      // startFrame: 0,  // 読み込み開始フレーム(デフォルト: 0)
      // endFrame: 31,   // 読み込み終了フレーム(デフォルト: 全コマ)
      // margin: 0,      // シート外周の余白(px)
      // spacing: 0,     // コマ間の隙間(px)
    });
  }

  create(): void {
    // ─── アニメーションの定義 ─────────────────────────
    // アニメーションはPhaserのグローバルAnimationManagerに登録される
    // 一度登録すればすべてのシーンから参照できる

    // 待機アニメーション
    this.anims.create({
      key: 'player_idle',
      frames: this.anims.generateFrameNumbers('player', {
        start: 0,
        end: 3,
      }),
      frameRate: 6,    // 1秒あたりのフレーム数
      repeat: -1,      // -1 = 無限ループ
    });

    // 歩行アニメーション
    this.anims.create({
      key: 'player_walk',
      frames: this.anims.generateFrameNumbers('player', {
        start: 4,
        end: 11,
      }),
      frameRate: 12,
      repeat: -1,
    });

    // ジャンプアニメーション(1回だけ再生)
    this.anims.create({
      key: 'player_jump',
      frames: this.anims.generateFrameNumbers('player', {
        start: 12,
        end: 15,
      }),
      frameRate: 10,
      repeat: 0,       // 0 = 1回だけ再生
    });

    // 連続しないフレームを指定したい場合
    // generateFrameNumbers の frames 配列で任意のフレームを指定できる
    this.anims.create({
      key: 'player_hurt',
      frames: this.anims.generateFrameNumbers('player', {
        frames: [16, 17, 16, 17, 16], // 任意のフレームを順番に指定
      }),
      frameRate: 8,
      repeat: 0,
    });

    // ─── スプライトの生成とアニメーション再生 ────────
    const player = this.physics.add.sprite(400, 300, 'player');

    // アニメーションを再生する
    // play(キー名, ignoreIfPlaying)
    // ignoreIfPlayingをtrueにすると、再生中のアニメを中断しない
    player.anims.play('player_idle');
  }
}

アニメーション再生の制御

create(): void {
  const player = this.physics.add.sprite(400, 300, 'player');

  // ─── 再生制御 ─────────────────────────────────────

  // 通常再生
  player.anims.play('player_walk');

  // 再生中なら中断しない(update内での呼び出しに必須)
  player.anims.play('player_walk', true);

  // 一時停止 / 再開
  player.anims.pause();
  player.anims.resume();

  // 停止(最初のフレームに戻る)
  player.anims.stop();

  // 特定フレームで停止
  player.anims.stopOnFrame(
    player.anims.currentAnim!.frames[2]
  );

  // 再生速度を変える(1.0が通常速度)
  player.anims.setTimeScale(2.0);  // 2倍速
  player.anims.setTimeScale(0.5);  // 半速

  // アニメーション完了イベント
  player.on(
    Phaser.Animations.Events.ANIMATION_COMPLETE,
    (anim: Phaser.Animations.Animation) => {
      if (anim.key === 'player_jump') {
        // ジャンプアニメが終わったら着地アニメへ
        player.anims.play('player_idle');
      }
    }
  );

  // 特定フレームで処理を発火させる(足音SEなどに便利)
  player.on(
    Phaser.Animations.Events.ANIMATION_UPDATE,
    (
      _anim:  Phaser.Animations.Animation,
      frame: Phaser.Animations.AnimationFrame
    ) => {
      // 歩行アニメの4フレーム目で足音を鳴らす例
      if (frame.index === 4) {
        // this.sound.play('footstep');
        console.log('足音!');
      }
    }
  );
}

テクスチャアトラスを使ったアニメーション

スプライトシートより柔軟なのがテクスチャアトラスです。各フレームのサイズがバラバラでもOKで、TexturePackerなどのツールで生成したJSONと組み合わせて使います。

preload(): void {
  // テクスチャアトラスの読み込み(画像 + JSONのセット)
  this.load.atlas(
    'player_atlas',           // キー名
    '/assets/atlas/player.png',  // 画像ファイル
    '/assets/atlas/player.json'  // フレーム座標が書かれたJSON
  );
}

create(): void {
  // アトラスからアニメーションを定義
  this.anims.create({
    key: 'player_walk',
    // generateFrameNamesでJSONのフレーム名を参照する
    // player_walk_001.png, player_walk_002.png ... という名前なら:
    frames: this.anims.generateFrameNames('player_atlas', {
      prefix: 'player_walk_',
      start: 1,
      end: 8,
      zeroPad: 3, // ゼロ埋めの桁数(001, 002 ... の場合は3)
      suffix: '.png',
    }),
    frameRate: 12,
    repeat: -1,
  });

  const player = this.add.sprite(400, 300, 'player_atlas');
  player.anims.play('player_walk');
}

anims.create() を同じシーンで2回実行すると「キー名が重複している」エラーになります。シーンを再起動するときに発生しやすいので、以下のガードを必ず入れましょう。

// ❌ ガードなし(シーン再起動時にエラー)
this.anims.create({ key: 'player_walk', ... });

// ✅ ガードあり(安全)
if (!this.anims.exists('player_walk')) {
  this.anims.create({ key: 'player_walk', ... });
}

load.spritesheet() で読み込み、anims.create() で定義、anims.play() で再生の3ステップが基本です。anims.exists() のガードを入れれば、シーン再起動時のエラーも防げます。

複数アセットの効率的なプリロード方法

大量のアセットは PreloadScene に集約し、ロード画面を表示しながら読み込むのがベストプラクティスです。アセットパックを使えばJSONでアセットを一元管理できます。

専用のPreloadSceneを作る

ゲームのアセットをすべて PreloadScene に集約することで、他のシーンをスリムに保てます。

import { Scene } from 'phaser/scenes';
import { Rectangle, Text } from 'phaser/gameobjects';
import { Loader } from 'phaser/loader';

export class PreloadScene extends Scene {
  private progressBar!: Rectangle;
  private progressBox!: Rectangle;
  private loadingText!: Text;
  private percentText!: Text;

  constructor() {
    super('PreloadScene');
  }

  preload(): void {
    this.createLoadingBar();
    this.registerLoadEvents();
    this.loadAssets();
  }

  private createLoadingBar(): void {
    const { width, height } = this.scale;
    const cx = width / 2;
    const cy = height / 2;

    // 背景バー
    this.progressBox = new Rectangle(cx, cy, 400, 30, 0x333333);
    this.progressBox.setOrigin(0.5);
    this.add.existing(this.progressBox);

    // 進捗バー(初期幅0)
    this.progressBar = new Rectangle(cx - 195, cy, 0, 20, 0x00ff88);
    this.progressBar.setOrigin(0, 0.5);
    this.add.existing(this.progressBar);

    // テキスト
    this.loadingText = new Text(cx, cy - 40, 'Loading...', {
      fontSize: '20px',
      color: '#ffffff',
    });
    this.loadingText.setOrigin(0.5);
    this.add.existing(this.loadingText);

    this.percentText = new Text(cx, cy + 40, '0%', {
      fontSize: '18px',
      color: '#aaaaaa',
    });
    this.percentText.setOrigin(0.5);
    this.add.existing(this.percentText);
  }

  private registerLoadEvents(): void {
    this.load.on('progress', (value: number) => {
      // Phaser 4でもwidthプロパティの更新で描画サイズが変わります
      this.progressBar.width = 390 * value;
      this.percentText.setText(`${Math.floor(value * 100)}%`);
    });

    this.load.on('fileprogress', (file: any) => {
      this.loadingText.setText(`Loading: ${file.key}`);
    });

    this.load.on('complete', () => {
      this.progressBar.destroy();
      this.progressBox.destroy();
      this.loadingText.destroy();
      this.percentText.destroy();
    });
  }

  private loadAssets(): void {
    // ─── 画像 ────────────────────────────────────────
    this.load.image('background', '/assets/images/background.png');
    this.load.image('ground', '/assets/images/ground.png');
    this.load.image('coin', '/assets/images/coin.png');
    this.load.image('button', '/assets/images/button.png');

    // ─── スプライトシート ────────────────────────────
    this.load.spritesheet('player', '/assets/images/player.png', {
      frameWidth: 48,
      frameHeight: 48,
    });
    this.load.spritesheet('enemy', '/assets/images/enemy.png', {
      frameWidth: 48,
      frameHeight: 48,
    });

    // ─── テクスチャアトラス ──────────────────────────
    this.load.atlas('ui', '/assets/atlas/ui.png', '/assets/atlas/ui.json');

    // ─── 音声 ────────────────────────────────────────
    this.load.audio('bgm_game', '/assets/audio/bgm_game.mp3');
    this.load.audio('se_jump', '/assets/audio/se_jump.wav');
    this.load.audio('se_coin', '/assets/audio/se_coin.wav');
    this.load.audio('se_damage', '/assets/audio/se_damage.wav');

    // ─── タイルマップ ────────────────────────────────
    this.load.tilemapTiledJSON('level1', '/assets/tilemaps/level1.json');
    this.load.image('tiles', '/assets/images/tileset.png');

    // ─── フォント(Bitmapフォント)───────────────────
    this.load.bitmapFont(
      'pixel_font',
      '/assets/fonts/pixel_font.png',
      '/assets/fonts/pixel_font.xml'
    );
  }

  create(): void {
    this.scene.start('TitleScene');
  }
}

アセットパック(JSON管理)でさらに効率化

アセットが増えてきたら、JSONファイルでアセットを一元管理するアセットパックが便利です。コードを変更せずにアセットの追加・変更ができます。

// public/assets/asset-pack.json
{
  "ui": {
    "files": [
      { "type": "image", "key": "button",  "url": "images/button.png"  },
      { "type": "image", "key": "logo",    "url": "images/logo.png"    }
    ]
  },
  "game": {
    "files": [
      {
        "type": "spritesheet",
        "key": "player",
        "url": "images/player.png",
        "frameConfig": { "frameWidth": 48, "frameHeight": 48 }
      },
      { "type": "image", "key": "ground", "url": "images/ground.png" },
      { "type": "audio", "key": "bgm",    "url": "audio/bgm.mp3"     }
    ]
  }
}
preload(): void {
  // baseURLを設定すると個々のパスが短く書ける
  this.load.setBaseURL('/assets');
  this.load.setPath('/assets');

  // アセットパックをまとめて読み込む
  // 第2引数でセクションを指定(省略で全セクション)
  this.load.pack('game-assets', '/assets/asset-pack.json', 'game');
}

シーンをまたいだアセットの再利用

一度読み込んだアセットはPhaserの TextureManager にキャッシュされます。PreloadScene で読み込んでおけば、他のシーンで preload() を書く必要はありません。

// GameScene.ts
export class GameScene extends Phaser.Scene {
  create(): void {
    // PreloadSceneで読み込んだアセットをそのまま使える
    // ↓ preload()なしで使用できる
    const player = this.physics.add.sprite(400, 300, 'player');
    const bg     = this.add.image(0, 0, 'background').setOrigin(0, 0);
  }
}

シーンを this.scene.restart() で再起動すると、preload() が再実行されてアセットの二重読み込みが発生することがあります。すでにキャッシュ済みのアセットは自動的にスキップされますが、読み込みイベントが再発火してUIがちらつく場合があります。再起動時にアセットを再読み込みしたくない場合は this.scene.start() で別シーンへ移動してから戻る方式を検討してください。

アセットは PreloadScene に集約してロード画面と一緒に表示するのがベストです。一度読み込んだアセットはキャッシュされるので、他のシーンでは preload() なしでそのまま使えます。

BGM・SEのロードと再生制御(最短コード例)

BGMは this.sound.add() でインスタンスを作って play() し、SEは都度 this.sound.play() で鳴らすのが最もシンプルなパターンです。

音声ファイルの形式について

ブラウザの対応状況から、MP3(BGM)とWAV(SE)の組み合わせが最も安定します。OGGはFirefoxとChromeで対応していますがSafariが非対応なため、クロスブラウザを意識する場合はMP3を優先してください。

preload(): void {
  // BGM(ループ再生するため比較的大きなファイルでもOK)
  this.load.audio('bgm_title', '/assets/audio/bgm_title.mp3');
  this.load.audio('bgm_game',  '/assets/audio/bgm_game.mp3');

  // SE(短い音声。WAVは遅延が少なくSEに向く)
  this.load.audio('se_jump',   '/assets/audio/se_jump.wav');
  this.load.audio('se_coin',   '/assets/audio/se_coin.wav');
  this.load.audio('se_damage', '/assets/audio/se_damage.wav');
  this.load.audio('se_gameover', '/assets/audio/se_gameover.wav');
}

BGMの再生・停止・切り替え

import { Scene } from 'phaser/scenes';
import { WebAudioSound } from 'phaser/sound/webaudio';
import { Tween } from 'phaser/tweens';

export class GameScene extends Scene {
  // WebAudio環境を想定した型定義(もしくはBaseSoundに相当する型)
  private bgm!: WebAudioSound;

  constructor() {
    super('GameScene');
  }

  create(): void {
    // ─── BGMの再生 ────────────────────────────────────
    // Phaser 4ではサウンドマネージャーからインスタンスを作成
    this.bgm = this.sound.add('bgm_game', {
      loop: true,
      volume: 0, // フェードインさせるため初期値は0
    }) as WebAudioSound;

    this.bgm.play();

    // ─── 音量のフェードイン ──────────────────────────
    // this.tweens.add は v4 でも利用可能ですが、ターゲットのプロパティ操作が厳格化されています
    this.tweens.add({
      targets: this.bgm,
      volume: 0.6,
      duration: 2000,
      ease: 'Linear',
    });
  }

  // シーン遷移時にBGMをフェードアウトして停止する
  private fadeBGMAndTransition(nextScene: string): void {
    this.tweens.add({
      targets: this.bgm,
      volume: 0,
      duration: 1000,
      ease: 'Linear',
      onComplete: () => {
        this.bgm.stop();
        this.scene.start(nextScene);
      },
    });
  }

  // BGMの切り替え(フェードアウト → 別BGMをフェードイン)
  private switchBGM(newKey: string): void {
    this.tweens.add({
      targets: this.bgm,
      volume: 0,
      duration: 500,
      onComplete: () => {
        this.bgm.stop();
        this.bgm.destroy();

        this.bgm = this.sound.add(newKey, { loop: true, volume: 0 }) as WebAudioSound;
        this.bgm.play();

        this.tweens.add({
          targets: this.bgm,
          volume: 0.6,
          duration: 500,
        });
      },
    });
  }
}

SEの再生(最短コード)

import { Scene } from 'phaser/scenes';
import { Between } from 'phaser/math';

export class GameScene extends Scene {
  constructor() {
    super('GameScene');
  }

  create(): void {
    // ─── SEの最短再生パターン ─────────────────────────
    // インスタンスを作らず直接再生
    // this.sound.play('se_coin');

    // 音量とピッチを指定して再生
    this.sound.play('se_coin', {
      volume: 0.8,
      rate: 1.0,    // 再生速度
      detune: 0,    // ピッチのデチューン
    });

    // ─── SEに変化をつけてゲーム感を演出 ──────────────

    // コンボに応じてSEのピッチを上げる例
    const comboCount = 3;
    this.sound.play('se_coin', {
      volume: 1.0,
      detune: comboCount * 50, // コンボが増えるほど高い音に
    });

    // ランダムなピッチでSEに自然なばらつきを出す
    this.sound.play('se_jump', {
      volume: 0.9,
      detune: Between(-100, 100), // Phaser.Math.Between ではなく Between をインポートして使用
    });
  }
}

実践的なSoundManagerクラスの実装

SEとBGMの管理をシーンから分離した、再利用しやすいクラスです。

import { Scene } from 'phaser/scenes';
import { ISound } from 'phaser/sound';

export class SoundManager {
  private scene: Scene;
  private bgm: ISound | null = null;
  private isMuted: boolean = false;

  // 音量設定(ゲーム全体で共有)
  private bgmVolume: number = 0.6;
  private seVolume: number = 0.9;

  constructor(scene: Scene) {
    this.scene = scene;
  }

  // BGM再生(同じキーが再生中なら何もしない)
  playBGM(key: string, volume?: number): void {
    // v4ではサウンドのキー確認がシンプルになっています
    if (this.bgm?.isPlaying && this.bgm.key === key) return;

    this.stopBGM();
    this.bgm = this.scene.sound.add(key, {
      loop: true,
      volume: this.isMuted ? 0 : (volume ?? this.bgmVolume),
    });
    this.bgm.play();
  }

  // BGM停止
  stopBGM(fadeDuration: number = 0): void {
    if (!this.bgm) return;

    if (fadeDuration > 0) {
      this.scene.tweens.add({
        targets: this.bgm,
        volume: 0,
        duration: fadeDuration,
        onComplete: () => {
          this.bgm?.stop();
          this.bgm?.destroy();
          this.bgm = null;
        },
      });
    } else {
      this.bgm.stop();
      this.bgm.destroy();
      this.bgm = null;
    }
  }

  // SE再生
  playSE(key: string, options?: any): void {
    if (this.isMuted) return;

    this.scene.sound.play(key, {
      volume: this.seVolume,
      ...options,
    });
  }

  // ミュート切り替え
  toggleMute(): void {
    this.isMuted = !this.isMuted;
    this.scene.sound.mute = this.isMuted;
  }

  // マスター音量の変更(BGM・SE両方に反映)
  setMasterVolume(value: number): void {
    // value: 0〜1
    this.scene.sound.volume = value;
  }
}
import { Scene } from 'phaser/scenes';
import { Keyboard } from 'phaser/input/keyboard';
import { SoundManager } from '../managers/SoundManager';

export class GameScene extends Scene {
  private soundManager!: SoundManager;
  private keyboard!: Keyboard;

  constructor() {
    super('GameScene');
  }

  create(): void {
    // 1. SoundManager の初期化
    this.soundManager = new SoundManager(this);

    // 2. BGM の再生
    this.soundManager.playBGM('bgm_game');

    // 3. キーボード入力のセットアップ
    // Phaser 4 では Keyboard インスタンスを明示的に作成して使用します
    this.keyboard = new Keyboard(this.game);

    // ミュートボタン (Mキー)
    // v4 でも addKey().on('down', ...) のパターンは維持されていますが、
    // 文字列でキーを指定するのが標準的です
    this.keyboard.addKey('M').on('down', () => {
      this.soundManager.toggleMute();
      console.log('Mute toggled');
    });
  }
}

モバイルの自動再生制限への対応

スマートフォンとモダンブラウザでは、ユーザー操作なしに音声を自動再生できない仕様があります。タップ・クリックなどのユーザー操作を起点にBGMを開始する必要があります。

create(): void {
  // Phaserはユーザーインタラクション(クリック・タップ)後に
  // 音声を再生するための仕組みを内蔵している
  // this.sound.unlockを使うと安全に解除できる

  // ゲーム起動直後にBGMを鳴らしたい場合は
  // タイトル画面の「タップしてスタート」ボタン押下後に再生する

  const startText = this.add.text(400, 300, 'タップしてスタート', {
    fontSize: '28px',
    color: '#ffffff',
  }).setOrigin(0.5).setInteractive();

  startText.on('pointerdown', () => {
    // ユーザー操作のコールバック内でBGMを開始する
    this.sound.play('bgm_title', { loop: true, volume: 0.6 });
    this.scene.start('GameScene');
  });
}

create() の中で直接 this.sound.play() を呼んでもスマホでは無音になります。必ず pointerdown などのユーザーインタラクションのコールバック内で音声再生を開始してください。

BGMは sound.add() でインスタンスを保持し、SEは sound.play() で都度再生が最短パターンです。SoundManagerクラスに切り出せばミュート・フェードイン/アウトも一元管理でき、スマホの自動再生制限にも対応できます。

【不要なパソコンを送るだけ】パソコン無料処分サービス『送壊ゼロ』

Phaser 4の実践設計と開発効率化

ここまでの実装でゲームの基本要素は揃いました。しかし「動くコード」と「保守できるコード」は別物です。このセクションでは、シーン管理・ディレクトリ設計・デバッグと最適化という3つの観点から、実際の開発現場で使える設計パターンを解説します。規模が大きくなっても崩れない構造を最初から意識することが、完成までの最短ルートです。

シーン管理(タイトル・ゲーム・リザルト)

ゲームの画面遷移は「シーンのステートマシン」として設計します。各シーンを独立したクラスに分け、データのやり取りは scene.start() の第2引数で行うのがベストプラクティスです。

シーン構成の全体像

BootScene       ← 最小アセット(ロゴ・プログレスバー素材)だけ読み込む
    ↓
PreloadScene    ← 全アセットを読み込む。ロード画面を表示
    ↓
TitleScene      ← タイトル・メニュー画面
    ↓
GameScene       ← メインゲーム
    ↑ ↓           (同時起動)
UIScene         ← スコア・HP表示(GameSceneに重ねて表示)
    ↓
ResultScene     ← リザルト画面。スコアを表示して次へ
    ↓
(TitleSceneに戻る or GameSceneを再スタート)

BootScene(最速起動のための初期化)

import { Scene } from 'phaser/scenes';

export class BootScene extends Scene {
  constructor() {
    super('BootScene');
  }

  preload(): void {
    // PreloadSceneで使う最小限の素材をロード
    this.load.image('loading_bg', '/assets/images/loading_bg.png');
    this.load.image('loading_bar', '/assets/images/loading_bar.png');
  }

  create(): void {
    // localStorageからボリューム設定を復元
    const savedVolume = localStorage.getItem('masterVolume');
    if (savedVolume !== null) {
      // v4では setVolume() メソッドではなく volume プロパティを直接操作します
      this.sound.volume = parseFloat(savedVolume);
    }

    // PreloadSceneへ即移動
    this.scene.start('PreloadScene');
  }
}

TitleScene(メニューとシーン遷移の起点)

// src/scenes/TitleScene.ts
import Phaser from 'phaser';

export class TitleScene extends Phaser.Scene {
  constructor() {
    super({ key: 'TitleScene' });
  }

  create(): void {
    const { width, height } = this.scale;

    // ─── 背景 ─────────────────────────────────────────
    this.add.image(width / 2, height / 2, 'background');

    // ─── タイトルテキスト ─────────────────────────────
    this.add.text(width / 2, 180, 'MY GAME', {
      fontSize:        '72px',
      fontFamily:      '"Arial Black", sans-serif',
      color:           '#ffffff',
      stroke:          '#000000',
      strokeThickness: 8,
    }).setOrigin(0.5);

    // ─── スタートボタン ──────────────────────────────
    this.createMenuButton(width / 2, 360, 'ゲームスタート', () => {
      // GameSceneとUISceneを同時に起動する
      this.scene.start('GameScene');
      this.scene.launch('UIScene');  // UISceneをGameSceneの上に重ねる
    });

    this.createMenuButton(width / 2, 440, 'ランキング', () => {
      console.log('ランキング画面へ');
    });

    this.createMenuButton(width / 2, 520, '設定', () => {
      // 現在のシーンを止めずに設定シーンを起動
      this.scene.launch('SettingsScene');
      this.scene.pause();  // TitleSceneを一時停止
    });

    // ─── BGM ─────────────────────────────────────────
    if (!this.sound.get('bgm_title')?.isPlaying) {
      this.sound.play('bgm_title', { loop: true, volume: 0.5 });
    }
  }

  // 共通ボタン生成ヘルパー
  private createMenuButton(
    x:        number,
    y:        number,
    label:    string,
    callback: () => void
  ): void {
    const btn = this.add.text(x, y, label, {
      fontSize:        '28px',
      fontFamily:      'Arial, sans-serif',
      color:           '#ffffff',
      backgroundColor: '#444444',
      padding:         { x: 24, y: 10 },
    })
      .setOrigin(0.5)
      .setInteractive({ useHandCursor: true });

    btn.on('pointerover', () => {
      btn.setStyle({ backgroundColor: '#666666' });
    });

    btn.on('pointerout', () => {
      btn.setStyle({ backgroundColor: '#444444' });
    });

    btn.on('pointerdown', callback);
  }
}

UIScene(ゲーム画面に重ねて表示するHUD)

import { Scene } from 'phaser/scenes';
import { Text } from 'phaser/gameobjects';

export class UIScene extends Scene {
  private scoreText!: Text;
  private livesText!: Text;
  private levelText!: Text;

  constructor() {
    super('UIScene');
  }

  create(): void {
    const { width } = this.scale;

    // ─── スコア表示 ───────────────────────────────────
    this.scoreText = new Text(16, 16, 'Score: 0', {
      fontSize: '22px',
      color: '#ffffff',
      stroke: '#000000',
      strokeThickness: 3,
    });
    this.add.existing(this.scoreText);

    // ─── ライフ表示 ───────────────────────────────────
    this.livesText = new Text(16, 48, '❤️ × 3', {
      fontSize: '22px',
      color: '#ff6666',
    });
    this.add.existing(this.livesText);

    // ─── レベル表示 ───────────────────────────────────
    this.levelText = new Text(width - 16, 16, 'Level 1', {
      fontSize: '22px',
      color: '#ffff00',
    });
    this.levelText.setOrigin(1, 0);
    this.add.existing(this.levelText);

    // ─── GameSceneからのイベントを受け取る ───────────
    // シーンマネージャーからインスタンスを取得
    const gameScene = this.scene.get('GameScene');

    if (gameScene) {
      gameScene.events.on('scoreUpdated', (score: number) => {
        this.scoreText.setText(`Score: ${score}`);
      });

      gameScene.events.on('livesUpdated', (lives: number) => {
        this.livesText.setText(`❤️ × ${lives}`);
      });

      gameScene.events.on('levelUpdated', (level: number) => {
        this.levelText.setText(`Level ${level}`);
      });

      // GameSceneが停止したらUISceneも停止
      gameScene.events.on('shutdown', () => {
        this.scene.stop();
      });
    }
  }
}
// GameScene.tsからUISceneへイベントを送る
export class GameScene extends Phaser.Scene {
  private score: number = 0;

  private addScore(points: number): void {
    this.score += points;
    // UISceneへスコア更新を通知
    this.events.emit('scoreUpdated', this.score);
  }
}

ResultScene(スコアを受け取って表示)

import { Scene } from 'phaser/scenes';
import { Text } from 'phaser/gameobjects';

interface ResultData {
  score: number;
  level: number;
}

export class ResultScene extends Scene {
  constructor() {
    super('ResultScene');
  }

  // Phaser 4 でもシーン開始時に渡されたデータは init で受け取ります
  init(data: ResultData): void {
    const { width, height } = this.scale;

    // ─── リザルト表示 ─────────────────────────────────
    const resultTitle = new Text(width / 2, 160, 'RESULT', {
      fontSize: '64px',
      fontFamily: '"Arial Black", sans-serif',
      color: '#ffff00',
      stroke: '#000000',
      strokeThickness: 8,
    });
    resultTitle.setOrigin(0.5);
    this.add.existing(resultTitle);

    const scoreText = new Text(width / 2, 280, `Score: ${data.score}`, {
      fontSize: '40px',
      color: '#ffffff',
    });
    scoreText.setOrigin(0.5);
    this.add.existing(scoreText);

    const levelText = new Text(width / 2, 340, `Level: ${data.level}`, {
      fontSize: '30px',
      color: '#aaaaaa',
    });
    levelText.setOrigin(0.5);
    this.add.existing(levelText);

    // ─── ランク表示 ───────────────────────────────────
    const rank = this.getRank(data.score);
    const rankText = new Text(width / 2, 420, `Rank: ${rank}`, {
      fontSize: '52px',
      fontFamily: '"Arial Black", sans-serif',
      color: this.getRankColor(rank),
      stroke: '#000000',
      strokeThickness: 6,
    });
    rankText.setOrigin(0.5);
    this.add.existing(rankText);

    // ─── もう一度ボタン ───────────────────────────────
    const retryBtn = new Text(width / 2 - 120, 530, 'もう一度', {
      fontSize: '26px',
      color: '#ffffff',
      backgroundColor: '#2255aa',
      padding: { x: 20, y: 8 },
    });
    
    retryBtn.setOrigin(0.5);
    retryBtn.setInteractive({ useHandCursor: true });
    this.add.existing(retryBtn);

    retryBtn.on('pointerdown', () => {
      // v4 でも sound.stopAll() は有効です
      this.sound.stopAll();
      this.scene.start('GameScene');
      this.scene.launch('UIScene');
    });

    // ─── タイトルへ戻るボタン ─────────────────────────
    const titleBtn = new Text(width / 2 + 120, 530, 'タイトルへ', {
      fontSize: '26px',
      color: '#ffffff',
      backgroundColor: '#555555',
      padding: { x: 20, y: 8 },
    });

    titleBtn.setOrigin(0.5);
    titleBtn.setInteractive({ useHandCursor: true });
    this.add.existing(titleBtn);

    titleBtn.on('pointerdown', () => {
      this.sound.stopAll();
      this.scene.start('TitleScene');
    });
  }

  // init に描画処理を集約している場合は create は空で問題ありません
  create(): void {}

  private getRank(score: number): string {
    if (score >= 1000) return 'S';
    if (score >= 700)  return 'A';
    if (score >= 400)  return 'B';
    if (score >= 200)  return 'C';
    return 'D';
  }

  private getRankColor(rank: string): string {
    const colors: Record<string, string> = {
      S: '#ff4444',
      A: '#ffaa00',
      B: '#88ff00',
      C: '#00ccff',
      D: '#888888',
    };
    return colors ?? '#ffffff';
  }
}

各シーンを独立したクラスに分け、データは scene.start() の第2引数で渡し、リアルタイムな状態更新は events.emit() で通知するのが、保守性の高いシーン管理の基本形です。

ディレクトリ設計(Vite + TS)

「役割ごとにフォルダを分ける」原則を守れば、ファイルが増えても迷子になりません。以下の構成を初期から採用することを強くおすすめします。

推奨ディレクトリ構成(中規模ゲーム向け)

my-phaser-game/
│
├── public/
│   └── assets/
│       ├── audio/          # BGM・SE
│       │   ├── bgm_title.mp3
│       │   ├── bgm_game.mp3
│       │   └── se_jump.wav
│       ├── images/         # 画像・スプライトシート
│       │   ├── player.png
│       │   ├── enemy.png
│       │   ├── tileset.png
│       │   └── background.png
│       ├── atlas/          # テクスチャアトラス(JSON + PNG)
│       │   └── ui.json / ui.png
│       ├── fonts/          # ビットマップフォント
│       │   └── pixel.xml / pixel.png
│       └── tilemaps/       # Tiledで作ったマップJSON
│           └── level1.json
│
├── src/
│   ├── main.ts             # エントリーポイント(Game config定義のみ)
│   │
│   ├── scenes/             # シーンクラス群(1ファイル1シーン)
│   │   ├── BootScene.ts
│   │   ├── PreloadScene.ts
│   │   ├── TitleScene.ts
│   │   ├── GameScene.ts
│   │   ├── UIScene.ts
│   │   └── ResultScene.ts
│   │
│   ├── objects/            # ゲームオブジェクト(Spriteを継承するクラス)
│   │   ├── Player.ts
│   │   ├── Enemy.ts
│   │   └── Coin.ts
│   │
│   ├── managers/           # システム管理クラス(シングルトン的に使う)
│   │   ├── SoundManager.ts
│   │   └── ScoreManager.ts
│   │
│   ├── config/             # 定数・設定値・型定義
│   │   ├── constants.ts    # 速度・重力などのマジックナンバー
│   │   ├── gameConfig.ts   # Phaser.Types.Core.GameConfig
│   │   └── types.ts        # プロジェクト固有の型定義
│   │
│   └── utils/              # 汎用ユーティリティ関数
│       └── helpers.ts
│
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts

gameConfig.tsにPhaserの設定を分離する

main.ts をシンプルに保つため、Phaser Gameの設定は専用ファイルに切り出します。

// src/config/gameConfig.ts
import Phaser from 'phaser';
import { BootScene }    from '../scenes/BootScene';
import { PreloadScene } from '../scenes/PreloadScene';
import { TitleScene }   from '../scenes/TitleScene';
import { GameScene }    from '../scenes/GameScene';
import { UIScene }      from '../scenes/UIScene';
import { ResultScene }  from '../scenes/ResultScene';

export const gameConfig: Phaser.Types.Core.GameConfig = {
  type:            Phaser.AUTO,
  width:           800,
  height:          600,
  parent:          'game-container',
  backgroundColor: '#1a1a2e',

  // レスポンシブ対応
  scale: {
    mode:           Phaser.Scale.FIT,       // アスペクト比を維持してフィット
    autoCenter:     Phaser.Scale.CENTER_BOTH, // 上下左右中央揃え
    width:          800,
    height:         600,
  },

  physics: {
    default: 'arcade',
    arcade: {
      gravity: { x: 0, y: 300 },
      debug:   false,
    },
  },

  scene: [
    BootScene,
    PreloadScene,
    TitleScene,
    GameScene,
    UIScene,
    ResultScene,
  ],
};
// src/main.ts(エントリーポイントは数行だけ)
import Phaser     from 'phaser';
import { gameConfig } from './config/gameConfig';

window.addEventListener('load', () => {
  new Phaser.Game(gameConfig);
});

src/config/types.tsで型を一元管理する

// src/config/types.ts

// シーン間で受け渡すデータの型を定義しておく
export interface GameSceneData {
  level:    number;
  score?:   number;   // 継続プレイの場合は前スコアを引き継ぐ
}

export interface ResultSceneData {
  score:   number;
  level:   number;
  cleared: boolean;
}

// プレイヤーのステータス
export interface PlayerStats {
  maxHp:    number;
  hp:       number;
  speed:    number;
  jumpPower: number;
}

// セーブデータの型
export interface SaveData {
  highScore: number;
  volume:    number;
  unlockedLevels: number[];
}

ScoreManagerでスコア管理を集約する

// src/managers/ScoreManager.ts

export class ScoreManager {
  private _score:     number = 0;
  private _highScore: number = 0;
  private _combo:     number = 0;
  private _comboTimer: ReturnType<typeof setTimeout> | null = null;

  constructor() {
    // ハイスコアをlocalStorageから復元
    const saved = localStorage.getItem('highScore');
    this._highScore = saved ? parseInt(saved, 10) : 0;
  }

  // コンボ込みのスコア加算
  addScore(base: number): number {
    // コンボ倍率(1〜5倍)
    const multiplier = Math.min(this._combo + 1, 5);
    const gained     = base * multiplier;

    this._score += gained;
    this._combo++;

    // コンボタイマーをリセット(2秒以内に次のスコアがないとコンボ切れ)
    if (this._comboTimer) clearTimeout(this._comboTimer);
    this._comboTimer = setTimeout(() => {
      this._combo = 0;
    }, 2000);

    return gained; // 獲得ポイントを返す(エフェクト表示に使う)
  }

  // ゲーム終了時にハイスコアを保存
  saveHighScore(): void {
    if (this._score > this._highScore) {
      this._highScore = this._score;
      localStorage.setItem('highScore', String(this._highScore));
    }
  }

  // リセット
  reset(): void {
    this._score = 0;
    this._combo = 0;
    if (this._comboTimer) clearTimeout(this._comboTimer);
  }

  get score():     number { return this._score;     }
  get highScore(): number { return this._highScore; }
  get combo():     number { return this._combo;     }
}

scenes/objects/managers/config/ の4層に役割を分けるのが基本形です。gameConfig.ts を分離することでエントリーポイントが数行に収まり、全体の見通しが格段によくなります。

デバッグ(FPS表示・当たり判定)と最適化

開発中はFPS表示と当たり判定の可視化を常にONにしておきましょう。問題が見つかってから原因を探るより、常に状態を可視化する習慣が開発速度を上げます。

FPS表示と基本的なデバッグ設定

import { GameConfig } from 'phaser/config';
import { ArcadePhysics } from 'phaser/physics/arcade';

export const gameConfig: GameConfig = {
  // ...その他の基本設定(type, width, height, parent等)

  // ─── FPS・パフォーマンス設定 ──────────────────────
  // v4でもfpsプロパティは維持されています
  fps: {
    target: 60,
    forceSetTimeOut: false,
  },

  // コンソールへのバナー表示設定
  banner: true,

  // ─── 物理演算設定 ──────────────────────────────────
  // Phaser 4では、physicsプロパティに直接設定を記述するのではなく、
  // 特定の物理システムをモジュールとして定義する形になります
  physics: {
    arcade: {
      gravity: { x: 0, y: 300 },
      // Viteの環境変数を利用したデバッグ表示の切り替え
      debug: import.meta.env.DEV,
      // 開発中の微調整に役立つ詳細設定
      debugShowBody: true,
      debugShowVelocity: true,
      debugBodyColor: 0xff00ff,
    } as ArcadePhysics.Config,
  },
};

シーン内でFPSとデバッグ情報を表示する

import { Scene } from 'phaser/scenes';
import { Text } from 'phaser/gameobjects';
import { ArcadePhysics } from 'phaser/physics/arcade';
import { Player } from '../objects/Player';

export class GameScene extends Scene {
  private debugText!: Text;
  private player!: Player;

  create(): void {
    // ... 通常のcreate処理(Playerの生成など) ...

    // ─── デバッグテキスト(開発環境のみ表示)────────
    if (import.meta.env.DEV) {
      this.debugText = new Text(8, this.scale.height - 80, '', {
        fontSize: '13px',
        fontFamily: 'monospace',
        color: '#00ff00',
        backgroundColor: 'rgba(0,0,0,0.6)',
        padding: { x: 6, y: 4 },
      });
      
      this.debugText.setScrollFactor(0);
      this.debugText.setDepth(999); // 常に最前面
      this.add.existing(this.debugText);
    }
  }

  update(): void {
    // ... 通常のupdate処理 ...

    // ─── デバッグ情報の更新 ──────────────────────────
    if (import.meta.env.DEV && this.debugText) {
      const body = this.player.body as ArcadePhysics.Body;
      
      // Phaser 4では game.loop ではなく、シーンの time プロパティや 
      // game.fps から情報にアクセスするのがよりクリーンです
      this.debugText.setText([
        `FPS    : ${Math.round(this.game.fps.actual)}`,
        `Player : (${Math.round(this.player.x)}, ${Math.round(this.player.y)})`,
        `Vel    : (${Math.round(body.velocity.x)}, ${Math.round(body.velocity.y)})`,
        `Blocked: ${body.blocked.down ? 'Ground' : 'Air'}`,
        `Objects: ${this.children.length}`,
      ]);
    }
  }
}

パフォーマンス最適化の実践テクニック

import { Scene } from 'phaser/scenes';
import { Group } from 'phaser/gameobjects';
import { ArcadePhysics } from 'phaser/physics/arcade';
import { Image } from 'phaser/gameobjects';

export class GameScene extends Scene {
  private bullets!: Group;
  private physics!: ArcadePhysics;
  private player!: Image; // プレイヤーオブジェクト(仮定)

  create(): void {
    this.physics = new ArcadePhysics(this);

    // ─── オブジェクトプールの作成 ──────────────────────
    // v4でもGroupのコンストラクタでプールの設定が可能です
    this.bullets = new Group(this.world, {
      classType: Image,
      defaultKey: 'bullet',
      maxSize: 30,
      runChildUpdate: true,
    });

    // 物理エンジンにグループを登録
    this.physics.add.existingGroup(this.bullets);
  }

  private fireBullet(): void {
    // getFirstDead で非活性(active: false)のオブジェクトを取得
    // v4 では引数のインターフェースが整理されています
    const bullet = this.bullets.getFirstDead(false) as Image | null;

    if (!bullet) return; // プールが枯渇している

    // 再活性化と位置のリセット
    // v4 では物理ボディの操作に ArcadePhysics.Body を使用
    const body = bullet.body as ArcadePhysics.Body;
    
    bullet.setActive(true);
    bullet.setVisible(true);
    bullet.setPosition(this.player.x, this.player.y - 20);
    
    body.reset(bullet.x, bullet.y);
    body.setVelocityY(-400);
  }

  update(): void {
    // 画面外に出た弾をプールに返却(非活性化)
    this.bullets.getChildren().forEach((obj) => {
      const bullet = obj as Image;
      if (bullet.active && bullet.y < -32) {
        // v4 でも killAndHide で「プールに戻す」処理を行います
        this.bullets.killAndHide(bullet);
        
        // 物理ボディの動きを止める
        const body = bullet.body as ArcadePhysics.Body;
        body.stop();
      }
    });
  }
}

重い処理をフレーム分散させる

import { Scene } from 'phaser/scenes';

export class GameScene extends Scene {
  private frameCount: number = 0;

  constructor() {
    super('GameScene');
  }

  /**
   * update メソッド
   * @param time ゲーム開始からの経過時間 (ms)
   * @param delta 前のフレームからの経過時間 (ms)
   */
  update(time: number, delta: number): void {
    this.frameCount++;

    // 1. 毎フレーム実行(移動・入力・物理演算の補正など)
    // リアルタイム性が重要な処理
    this.handlePlayerMove();

    // 2. 10フレームに1回実行(敵AIの更新など)
    // 多少の遅延が許容される計算負荷の高い処理
    if (this.frameCount % 10 === 0) {
      this.updateEnemyAI();
    }

    // 3. 60フレームに1回実行(セーブ、ランキング更新、UIの同期など)
    // 1秒に1回(60FPS想定)程度で十分な重い処理
    if (this.frameCount % 60 === 0) {
      this.autoSave();
    }

    // カウントが大きくなりすぎるのを防ぐ(必要に応じて)
    if (this.frameCount >= 60) {
      this.frameCount = 0;
    }
  }

  private handlePlayerMove(): void {
    // プレイヤー移動ロジック
  }

  private updateEnemyAI(): void {
    // 敵の索敵や経路計算など
  }

  private autoSave(): void {
    // ローカルストレージへの書き込みなど
  }
}

タイマーの正しい使い方

// ─── setTimeout/setIntervalは使わない ────────────────

// ❌ ブラウザのsetTimeoutを使うとシーンの一時停止に追従しない
setTimeout(() => {
  this.spawnEnemy();
}, 3000);

// ✅ Phaserのtimeイベントを使う(シーン一時停止に追従する)
// 3秒後に1回だけ実行
this.time.delayedCall(3000, () => {
  this.spawnEnemy();
});

// 2秒間隔で繰り返す
this.time.addEvent({
  delay:    2000,
  callback: this.spawnEnemy,
  callbackScope: this,
  loop:     true,
});

// 5回だけ繰り返す
this.time.addEvent({
  delay:    1000,
  callback: this.showCountdown,
  callbackScope: this,
  repeat:   4,   // 初回 + 4回 = 計5回
});

よくあるパフォーマンス問題チェックリスト

症状原因対策
FPSが60から突然落ちるGCスパイク(頻繁なnew/destroy)オブジェクトプールを使う
スクロール時にカクつくsetScrollFactor 未設定のUIが多いUI要素に setScrollFactor(0) を設定
update() が重い毎フレームDOM操作や重い計算をしている処理を間引く・Tweenに移譲する
起動が遅いアセットを1つのシーンで全部読むBootScene / PreloadSceneに分散させる
メモリが増え続けるシーン切り替え時にイベントリスナーが残るshutdown イベントで必ずリスナーを解除する
音がスマホで鳴らない自動再生ポリシーに引っかかっているユーザー操作後に sound.play() を呼ぶ

イベントリスナーのメモリリーク対策

import { Scene } from 'phaser/scenes';

export class GameScene extends Scene {
  private handleResize!: () => void;

  constructor() {
    super('GameScene');
  }

  create(): void {
    const { width, height } = this.scale;

    // ─── リサイズイベントの登録 ──────────────────────
    this.handleResize = () => {
      const { width: newWidth, height: newHeight } = this.scale;
      console.log(`Resized to: ${newWidth}x${newHeight}`);
    };

    this.scale.on('resize', this.handleResize);

    // ─── シーン間イベントの登録 ──────────────────────
    const uiScene = this.scene.get('UIScene');
    
    if (uiScene) {
      // Phaser 4でも events (EventEmitter) を使用します
      uiScene.events.on('scoreUpdated', this.onScoreUpdated, this);

      // シーンが「破棄」または「停止」される際のクリーンアップを登録
      // v4では 'shutdown' イベントでリソースを解放するのが一般的です
      this.events.once('shutdown', () => {
        this.cleanup();
      });
    }
  }

  private onScoreUpdated(score: number): void {
    console.log(`Score Updated: ${score}`);
  }

  /**
   * クリーンアップ処理
   * メモリリーク(二重登録や参照残り)を防ぐために手動で解除します
   */
  private cleanup(): void {
    // スケールイベントの解除
    this.scale.off('resize', this.handleResize);

    // 他のシーンのイベント解除
    const uiScene = this.scene.get('UIScene');
    if (uiScene) {
      uiScene.events.off('scoreUpdated', this.onScoreUpdated, this);
    }
    
    console.log('GameScene resources cleaned up.');
  }

  // 補足:Phaser 4では shutdown() メソッドをオーバーライドするよりも、
  // events.on('shutdown', ...) で処理する方が、プラグイン等との競合を防げます。
}

開発中は arcade.debug: import.meta.env.DEV で当たり判定を常時可視化し、弾丸などの頻繁な生成物はオブジェクトプールで管理、タイマーは this.time を使うことで、フレームレートが安定した快適な開発体験を維持できます。

◆◇◆ 【衝撃価格】VPS512MBプラン!1時間1.3円【ConoHa】 ◆◇◆

Phaser 3からPhaser 4への移行ガイド

「既存のPhaser 3プロジェクトをPhaser 4に移行したい」という方向けに、このセクションでは破壊的変更の全体像・具体的なコードの書き換え例・よくある移行エラーとその解決策を一気に解説します。移行前にこのセクションを通読することで、「やってみたら動かなかった」という状況を最小限に抑えられます。

破壊的変更まとめ

Phaser 4の破壊的変更は「レンダラー」「フィルター/FX」「ティントシステム」「削除されたクラス」の4カテゴリに集約されます。まずこの4つを把握してから移行作業を始めましょう。

移行前にやること:影響範囲の確認

移行を始める前に、プロジェクトが以下の機能を使っているか確認してください。使っている項目が多いほど移行コストが上がります。

# プロジェクト内でv3固有APIを検索する(要確認リスト)
grep -r "setPipeline\\\\|setPostPipeline"  src/  # カスタムパイプライン
grep -r "setTintFill"                   src/  # ティント
grep -r "Geom\\\\.Point"                   src/  # 削除されたPoint
grep -r "Math\\\\.TAU"                     src/  # 数値の誤り
grep -r "BitmapMask"                    src/  # 削除されたマスク
grep -r "preFX\\\\|postFX"                 src/  # FXシステム
grep -r "Phaser\\\\.Struct"                src/  # 削除されたデータ構造
grep -r "DynamicTexture\\\\|RenderTexture" src/  # 変更された描画API

カテゴリ①:レンダラーの全面刷新

v3のPipelineシステムは完全に削除されました。パイプラインが複数の責務を持ち、WebGL状態を各パイプラインが独立して管理する設計だったため、パイプライン間での競合が発生しやすい構造でした。v4ではRenderNodeアーキテクチャが採用され、各レンダーノードが単一のレンダリングタスクを担当します。

変更点Phaser 3Phaser 4
レンダリング方式Pipeline方式RenderNode方式
カスタムレンダラーsetPipeline() / setPostPipeline()RenderConfig#renderNodes で登録
WebGLRenderer内部textureIndexes 等が存在多くの内部プロパティが削除・再編
汎用バッファgenericVertexBuffer(16MB)削除(専用バッファに移行、約5MBに削減)

影響を受けないケース: 標準的なPhaser API(Sprite・Text・Tilemapなど)のみを使っている場合は、新しいレンダラーは透過的に動作します。カスタムWebGLパイプラインを書いていなければ、このカテゴリの対応は不要です。

カテゴリ②:フィルター/FXシステムの統合

Phaser 3でFXとマスクという2つの独立したシステムだったものが、Phaser 4では「フィルター」という単一の統合システムになりました。

削除されたものPhaser 4での代替
preFX / postFXfilters プロパティ(統合フィルターシステム)
sprite.preFX.addBloom()sprite.filters.internal.add(BloomFilter)
sprite.preFX.addBlur()sprite.filters.internal.add(BlurFilter)
BitmapMask クラスMask フィルター
Bloom FX(v3)Actions として呼び出す形式に変更
Gradient FX(v3)Gradient ゲームオブジェクトに昇格

カテゴリ③:ティントシステムの変更

Phaser 3ではティントとティントフィルモードが別々で、色を設定するコマンドで誤って切り替わることがありました。Phaser 4では色とモードが分離され、6種類のモード(MULTIPLY・FILL・ADD・SCREEN・OVERLAY・HARD_LIGHT)が用意されています。

Phaser 3Phaser 4動作
sprite.setTint(0xff0000)sprite.setTint(0xff0000)ほぼ同じ(MULTIPLYモード)
sprite.setTintFill(0xff0000)削除setTint() + setTintMode(FILL) で代替
sprite.clearTint()sprite.clearTint()変更なし

ティントとは?

画像や図形に対して「特定の色を重ねて(乗算して)着色する機能」のことです。プログラムでリアルタイムに色を変えられるため、「ダメージを受けた時に赤くする」「アイテム取得時にキラキラ光らせる」といった演出によく使われます。また、デザイナーが作った素材を何パターンも用意しなくても、プログラムから「色違いキャラ」を簡単に作れる便利な機能とも言えます。

カテゴリ④:削除・変更されたクラスと定数

いくつかの小さなAPIが変更されており、把握さえすれば素早く修正できます。Geom.Pointは削除され、すべてのメソッドにVector2の直接置き換えがあります。Math.TAUはv3では誤ってPI / 2に設定されていましたが、v4では正しいPI * 2になっています。Phaser.Struct.SetPhaser.Struct.MapはネイティブのJavaScript SetMapに置き換えられました。

削除・変更されたものPhaser 4での対応
Geom.PointMath.Vector2 に置き換え
Math.TAU(誤値 PI/2)Math.TAU(正値 PI*2)or Math.PI_OVER_2
Phaser.Struct.Setネイティブ Set
Phaser.Struct.Mapネイティブ Map
BitmapMaskMask フィルター
DynamicTexture.draw() 系の複雑APIrender() を明示的に呼ぶ形式に変更
Spine 3/4 内蔵プラグインEsoteric Software公式プラグインへ移行
roundPixels(デフォルト true)v4では デフォルト false に変更

roundPixels の変更は見落としやすい点です。 ピクセルアートゲームでドットがにじんで見える場合は、gameConfigに明示的に設定してください。

// v3からの移行時に必要な場合がある
const config = {
  render: {
    roundPixels: true,  // v3の挙動を維持したい場合はtrueに
  },
};

カテゴリ⑤:Y軸座標系の変更

Phaser v4ではGL座標系を全体で採用しており、Y=0が画面の下端になっています。圧縮テクスチャを使用している場合は、Y軸が下から上に増加する向きで再圧縮が必要です。標準的な画像(PNG・JPG)は自動的に処理されるため、特別な対応は不要です。カスタムシェーダーを書いている場合は、テクスチャ座標のY軸方向が逆になっていることに注意してください。

標準API(Sprite・Tilemap・Text)だけ使っていれば影響は最小限。カスタムパイプライン・FX・ティントフィルを使っている箇所だけ重点的にチェックしましょう。

コードの書き換え例

破壊的変更のほとんどはメソッド名の変更か、削除されたクラスの代替クラスへの置き換えです。「Before → After」の形式で頭に入れておけば、移行作業は機械的に進められます。

① ティントの書き換え

// ─────────────────────────────────────
// Phaser 3(旧)
// ─────────────────────────────────────

// 通常のティント(乗算)
sprite.setTint(0xff0000);

// ベタ塗りティント
sprite.setTintFill(0xff0000);  // ← Phaser 4で削除

// ティントのクリア
sprite.clearTint();

// ─────────────────────────────────────
// Phaser 4(新)
// ─────────────────────────────────────

// 通常のティント(MULTIPLYモード、v3と同じ挙動)
sprite.setTint(0xff0000);

// ベタ塗りティント(v3のsetTintFill相当)
sprite.setTint(0xff0000);
sprite.setTintMode(Phaser.GameObjects.Components.TintMode.FILL);

// 6種類のティントモードが使える
// Phaser.GameObjects.Components.TintMode.MULTIPLY  (デフォルト)
// Phaser.GameObjects.Components.TintMode.FILL
// Phaser.GameObjects.Components.TintMode.ADD
// Phaser.GameObjects.Components.TintMode.SCREEN
// Phaser.GameObjects.Components.TintMode.OVERLAY
// Phaser.GameObjects.Components.TintMode.HARD_LIGHT

// ティントのクリア(変更なし)
sprite.clearTint();

② FX(エフェクト)の書き換え

// ─────────────────────────────────────
// Phaser 3(旧)
// ─────────────────────────────────────

// ブラーエフェクト
const blur = sprite.preFX.addBlur(1, 2, 0, 1);

// グローエフェクト
const glow = sprite.preFX.addGlow(0xffffff, 4, 0, false);

// ブルームエフェクト
const bloom = sprite.preFX.addBloom(0xffffff, 1, 1, 1, 0.5);

// シャドウエフェクト
sprite.preFX.addShadow(0, 0, 0.06, 0.7, 0x000000, 5);

// ─────────────────────────────────────
// Phaser 4(新)
// ─────────────────────────────────────

// フィルターを追加するには filters プロパティを使う
// sprite.filters.internal  ← スプライト自身に適用
// sprite.filters.external  ← スプライトの外側に適用

// ブラーフィルター
const blurFilter = sprite.filters.internal.add(
  Phaser.Filters.Blur,
  { strength: 1, passes: 2 }
);

// グローフィルター
const glowFilter = sprite.filters.internal.add(
  Phaser.Filters.Glow,
  { color: 0xffffff, outerStrength: 4 }
);

// ブルーム(v4ではActionsとして実装)
// 具体的な実装はPhaser 4の公式ドキュメントを参照

// シャドウフィルター
const shadowFilter = sprite.filters.internal.add(
  Phaser.Filters.Shadow,
  { x: 0, y: 0, decay: 0.06, power: 0.7, color: 0x000000, samples: 5 }
);

// フィルターの除去
sprite.filters.internal.remove(blurFilter);

Geom.PointVector2 の書き換え

// ─────────────────────────────────────
// Phaser 3(旧)
// ─────────────────────────────────────

// Pointの生成
const point = new Phaser.Geom.Point(100, 200);

// 座標の取得
console.log(point.x, point.y);

// Pointを使うメソッド
const distance = Phaser.Geom.Point.GetMagnitude(point);

// ─────────────────────────────────────
// Phaser 4(新)
// ─────────────────────────────────────

// Vector2に置き換える
const vec = new Phaser.Math.Vector2(100, 200);

// 座標の取得(同じ)
console.log(vec.x, vec.y);

// Vector2の同等メソッド
const magnitude = vec.length(); // GetMagnitudeの代替

// その他のVector2メソッド
vec.normalize();          // 正規化
vec.add(new Phaser.Math.Vector2(10, 20)); // 加算
vec.scale(2);             // スカラー倍
vec.clone();              // コピー

Phaser.Struct.Set/Map → ネイティブへの書き換え

// ─────────────────────────────────────
// Phaser 3(旧)
// ─────────────────────────────────────

// Phaser独自のSet(カスタムメソッドあり)
const phaserSet = new Phaser.Struct.Set<string>();
phaserSet.set('enemy1');
phaserSet.set('enemy2');
phaserSet.contains('enemy1'); // Phaser固有メソッド

// Phaser独自のMap
const phaserMap = new Phaser.Struct.Map<string, number>();
phaserMap.set('score', 100);
phaserMap.get('score');

// ─────────────────────────────────────
// Phaser 4(新)
// ─────────────────────────────────────

// ネイティブのSetに置き換える
const nativeSet = new Set<string>();
nativeSet.add('enemy1');   // set() → add()
nativeSet.add('enemy2');
nativeSet.has('enemy1');   // contains() → has()

// ネイティブのMapに置き換える(APIはほぼ同じ)
const nativeMap = new Map<string, number>();
nativeMap.set('score', 100);
nativeMap.get('score');

DynamicTexture の書き換え

// ─────────────────────────────────────
// Phaser 3(旧)
// ─────────────────────────────────────

const dt = this.textures.addDynamicTexture('myTexture', 256, 256);

// v3では自動的に描画が反映されていた
dt.draw('player', 0, 0);
dt.drawFrame('player', 'walk_01', 50, 50);

// ─────────────────────────────────────
// Phaser 4(新)
// ─────────────────────────────────────

const dt = this.textures.addDynamicTexture('myTexture', 256, 256);

// v4では描画コマンドをバッファリングし、
// render()を明示的に呼んで確定させる必要がある
dt.draw('player', 0, 0);
dt.drawFrame('player', 'walk_01', 50, 50);
dt.render();  // ← これを忘れると描画が反映されない(よくあるミス)

Math.TAU の書き換え

// ─────────────────────────────────────
// Phaser 3(旧)※ 誤った値(PI / 2)だった
// ─────────────────────────────────────

// v3のMath.TAUは誤って PI/2 = 1.5707... が入っていた
const quarterTurn = Phaser.Math.TAU; // 実際は90度(PI/2)として使われていた

// ─────────────────────────────────────
// Phaser 4(新)※ 正しい値(PI * 2)に修正
// ─────────────────────────────────────

// v4のMath.TAUは正しい PI*2 = 6.2831... (360度)
const fullCircle = Phaser.Math.TAU;  // 360度

// v3で Math.TAU を「PI/2(90度)」として使っていた場合は
// 新しい定数に置き換える
const quarterTurn = Phaser.Math.PI_OVER_2; // PI / 2 = 90度

⑦ カスタムパイプライン → RenderNodeへの書き換え

カスタムWebGLパイプラインを実装していた場合は、最も大きな移行作業が必要です。

// ─────────────────────────────────────
// Phaser 3(旧)
// ─────────────────────────────────────

class MyCustomPipeline extends Phaser.Renderer.WebGL.WebGLPipeline {
  constructor(game: Phaser.Game) {
    super({
      game,
      name:   'MyCustomPipeline',
      fragShader: `
        precision mediump float;
        varying vec2 outTexCoord;
        uniform sampler2D uMainSampler;
        void main () {
          vec4 color = texture2D(uMainSampler, outTexCoord);
          // グレースケール変換
          float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
          gl_FragColor = vec4(gray, gray, gray, color.a);
        }
      `,
    });
  }
}

// シーンでの登録・適用
this.renderer.pipelines.add('MyCustomPipeline', new MyCustomPipeline(this.game));
sprite.setPipeline('MyCustomPipeline');

// ─────────────────────────────────────
// Phaser 4(新)
// ─────────────────────────────────────

// RenderNodeとしてShaderを使うアプローチ
// Phaser 4ではShaderゲームオブジェクトとフィルターが推奨される

// フィルターを使ったグレースケール(推奨)
const grayscaleFilter = sprite.filters.internal.add(
  Phaser.Filters.ColorMatrix,
);

// ColorMatrixフィルターでグレースケール相当を実現
(grayscaleFilter as any).colorMatrix.grayscale(1);

// ──────────────────────────────────
// カスタムシェーダーが必要な場合はShaderゲームオブジェクトを使う
// (gameConfigのscene配列に登録済みであること)
// ──────────────────────────────────
// gameConfig.tsでrender nodesを登録するパターン(高度な用途)
const config: Phaser.Types.Core.GameConfig = {
  // ...
  render: {
    renderNodes: {
      // カスタムRenderNodeをここで登録する
      // 詳細はPhaser 4公式ドキュメントのRenderNode APIを参照
    },
  },
};

setTintFillsetTint + setTintModeGeom.PointVector2DynamicTexturerender() 追加、Math.TAUMath.PI_OVER_2 への置き換えが最頻出の書き換えパターンです。

よくある移行エラー

移行時のエラーは「削除されたメソッド呼び出し」「Y軸座標系の変化」「アニメーション重複登録」の3パターンにほぼ集約されます。エラーメッセージとセットで把握しておきましょう。

エラー① setTintFill is not a function

TypeError: sprite.setTintFill is not a function

原因: setTintFill() がPhaser 4で削除された。

修正:

// ❌ Phaser 3
sprite.setTintFill(0xff0000);

// ✅ Phaser 4
sprite.setTint(0xff0000);
sprite.setTintMode(Phaser.GameObjects.Components.TintMode.FILL);

エラー② Phaser.Geom.Point is not a constructor

TypeError: Phaser.Geom.Point is not a constructor

原因: Geom.Point クラスが削除された。

修正:

// ❌ Phaser 3
const p = new Phaser.Geom.Point(100, 200);

// ✅ Phaser 4
const p = new Phaser.Math.Vector2(100, 200);

エラー③ アニメーション重複エラー

Phaser.Animations.AnimationManager: Animation key already exists: player_walk

原因: anims.create() を同じキーで2回実行した。シーン再起動時に頻発する。

修正:

// ❌ ガードなし(シーン再起動のたびにエラー)
this.anims.create({ key: 'player_walk', ... });

// ✅ 存在チェックを入れる
if (!this.anims.exists('player_walk')) {
  this.anims.create({ key: 'player_walk', ... });
}

エラー④ DynamicTexture の描画が反映されない(エラーなし)

// エラーは出ないが画面に描画が反映されない

原因: DynamicTextureはPhaser 4でrender()を明示的に呼ぶ必要があります。

修正:

// ❌ Phaser 3の書き方(v4では画面に出ない)
dt.draw('player', 0, 0);

// ✅ Phaser 4(render()で確定させる)
dt.draw('player', 0, 0);
dt.render(); // ← 必須

エラー⑤ 圧縮テクスチャが上下逆になって表示される

// エラーなし。ただし画像が上下逆に表示される

原因: Phaser 4はGL座標系(Y=0が下端)を採用しており、圧縮テクスチャはY軸が下から上に増加する向きで再圧縮が必要です。

修正: TexturePackerなどの圧縮ツールで「Flip Y」オプションを有効にして再圧縮してください。PNGやJPGなどの標準画像形式は自動処理されるため対応不要です。

エラー⑥ setPipeline is not a function

TypeError: this.player.setPipeline is not a function

原因: v3のカスタムパイプラインAPIがPhaser 4で削除された。

修正の方針:

// ❌ Phaser 3(パイプラインを直接適用)
sprite.setPipeline('MyGlowPipeline');

// ✅ Phaser 4(フィルターシステムに移行)
// グロー相当であればGlowフィルターを使う
sprite.filters.internal.add(Phaser.Filters.Glow, {
  outerStrength: 4,
  color: 0xffffff,
});
// 独自シェーダーが必要な場合は公式のRenderNode APIを参照

エラー⑦ Phaser.Struct.Set is not a constructor

TypeError: Phaser.Struct.Set is not a constructor

原因: Phaser.Struct.SetPhaser.Struct.Map がPhaser 4で削除された。

修正:

// ❌ Phaser 3
const mySet = new Phaser.Struct.Set<string>();
mySet.set('value');
mySet.contains('value');

const myMap = new Phaser.Struct.Map<string, number>();

// ✅ Phaser 4(ネイティブに置き換える)
const mySet = new Set<string>();
mySet.add('value');    // set() → add()
mySet.has('value');    // contains() → has()

const myMap = new Map<string, number>();

移行作業の推奨手順まとめ

ステップ1:影響調査
  └── grep で v3固有APIの使用箇所を洗い出す

ステップ2:npm update
  └── npm install phaser@4.0.0
  └── package.json の phaser バージョンを確認

ステップ3:TypeScriptのビルドエラーを修正
  └── tsc --noEmit でコンパイルエラーを一覧表示
  └── 型エラーを上から順に修正

ステップ4:ランタイムエラーを修正
  └── npm run dev でブラウザのコンソールを確認
  └── このセクションのエラー一覧と照合して修正

ステップ5:目視確認
  └── roundPixels の挙動変化(ピクセルアートのにじみ)
  └── FXやマスクの見た目が意図通りか確認
  └── 圧縮テクスチャの上下方向を確認

ステップ6:パフォーマンス確認
  └── FPS・メモリ使用量が v3 同等以上であることを確認
  └── モバイル実機でも動作確認

移行エラーの大半は「削除メソッドの呼び出し」で、エラーメッセージを見れば原因が特定できます。tsc --noEmit でコンパイルエラーを先に潰してから、ブラウザのランタイムエラーに対処するという2段階のアプローチが最も効率的です。

よくある質問(FAQ)

Phaser 4はまだ不安定?実務で使える?

2026年現在、Phaser 4は商用利用に十分耐えうる安定性を確立しています。

初期のベータ版から大幅なリファクタリングを経て、現在はコアAPIが固定されており、破壊的変更のリスクは極めて低くなっています。特に、TypeScriptネイティブになったことで、コンパイル時に多くのバグを検知できるようになった点は、納期や品質が重視される実務において大きなアドバンテージです。広告用ミニゲーム、教育用コンテンツ、さらにはブラウザベースのMMORPGなど、多岐にわたるジャンルでの採用実績があります。

初心者でもどれくらいでゲームが作れる?

JavaScriptやTypeScriptの基礎知識がある方なら、2〜3日あれば「動くミニゲーム」のプロトタイプを完成させることが可能です。

  • 1日目: 環境構築と基本概念(Scene, Sprite, Physics)の理解。
  • 2日目: キャラクター操作の実装と、当たり判定によるゲーム性の構築。
  • 3日目: スコア表示、SE・BGMの追加、および公開。

Phaser 4は、UnityなどのGUIベースのエディタを持つエンジンとは異なり「すべてをコードで制御」します。そのため、普段VS Code等で開発しているWebエンジニアにとっては、新しいツールを覚える学習コストが低く、非常に馴染みやすいのが特徴です。

UnityやGodotと比べてどう違う?

プロジェクトの目的によって最適なエンジンは異なります。以下の比較を参考にしてください。

  • Phaser 4を選ぶべき理由:
    • 「Webへの特化」: ブラウザ上で動かすことに特化しており、軽量で読み込みが速い。
    • 「Web技術の転用」: ReactやVue、Next.jsといったモダンなWebフレームワークと組み合わせやすい。
    • 「インストール不要」: ユーザーがURLをクリックするだけで即座にプレイ開始できる。
  • Unity / Godotを選ぶべき理由:
    • 「高度な3D」: 3D表現や、リッチなライティングを多用する場合。
    • 「マルチプラットフォーム」: ブラウザ以外(Switch, PS5, Steam)へのネイティブ展開が主軸の場合。
    • 「ビジュアル開発」: プログラミングよりも、エディタ上で視覚的にステージを組みたい場合。

Phaser 4はモバイルブラウザでも重くない?

むしろ、Phaser 4はモバイル環境でのパフォーマンスが劇的に向上しています。

Tree-shakingによって、使用していない物理エンジンや機能がビルド後のJSファイルから削除されるため、初期ロード時間が短縮されます。また、WebGL 2.0やWebGPUへの対応により、近年のスマートフォンであれば60FPSを安定して維持することが可能です。低スペック端末への配慮が必要な場合は、GameConfig で描画解像度を調整するなどの最適化も容易に行えます。

コストパフォーマンスに優れた高性能なレンタルサーバー

【Hostinger】

まとめ

ここまで、Phaser 4の使い方を「最短で動かす」ことを最優先に解説してきました。

Phaser 4は2026年4月10日に正式リリースされた、HTML5ゲーム開発フレームワークの最新安定版です。WebGLレンダラーが全面刷新され、モバイルで最大16倍の性能向上・100万スプライトの一括描画など、Phaser 3から大幅に進化しています。それでいて表向きのAPIは互換性を保っているため、JavaScript・TypeScriptの知識があればすぐに始められます。

学習の進め方としては、npm create @phaserjs/game@latest で環境を作り、preload → create → update のサイクルを体で覚えることが最初の一歩です。難しく考える必要はありません。まず動かして、少しずつ機能を足していく進め方が、挫折しない最善ルートです。

重要ポイント

  • Phaser 4は正式リリース済み:2026年4月10日にv4.0.0「Caladan」がリリース。新規プロジェクトへの採用を迷う必要はなし
  • 環境構築は1コマンドnpm create @phaserjs/game@latest でVite + TypeScript構成が即座に整う
  • 基本の3ステップpreload()でアセット読み込み、create()で配置、update()で毎フレーム処理
  • 設計は最初から分けるscenes/objects/managers/config/ の4層構造を初期から採用する
  • Phaser 3からの移行:標準APIのみ使用なら移行コストは小さい。setTintFillGeom.PointDynamicTextureの3点を重点確認
  • スマホ対応はPointerで統一:マウスとタッチを同一APIで扱えるため、PC・スマホ両対応が最小コストで実現できる
  • AIツールとの相性が抜群:ClaudeやCopilotはPhaser APIを深く理解しており、詰まったときの壁打ち相手として非常に有効

フロントエンドエンジニアにとって、Phaser 4は「既存のWeb知識を活かしてゲームが作れる」という点で他のゲームエンジンにはない強みを持っています。UnityやGodotのように専用エディタを覚える必要はなく、いつものVSCode・TypeScript・npmで開発を始められます。

まずは今日、npm create @phaserjs/game@latest を1回打ってみてください。ブラウザにゲーム画面が映った瞬間、次に作りたいものが自然と浮かんでくるはずです。

タイトルとURLをコピーしました