📚

phaserでゲーム開発 第八回

に公開

phaserでゲーム開発シリーズ、第八回。
前回の記事はこちら。

https://zenn.dev/k_tabuchi/articles/b1b3953a3ee561

今回は コードを役割ごとに整理し、保守性を高めるためのリファクタリング手順を解説します。
ここまでは一つのファイルに全ての処理をまとめていましたが、一定の規模を超え、管理が難しくなってきたため、責務ごとに分割していきたいと思います。

1. リファクタリング前の状態と課題

まず、GameScene.js 1 ファイルに以下の処理をすべて書いていました。

  • プレイヤー生成・入力処理
  • 敵の生成・行動制御
  • 背景や足場の生成
  • 攻撃やエフェクト処理
  • アニメーション定義

このように 1
ファイルが肥大化して責務が分散する
と、コードの見通しが悪くなり、修正時の影響範囲も把握しづらくなります。

2. ディレクトリ構成の改善

以下のように、責務ごとにフォルダを分割します。

src/
  scenes/
    GameScene.js        // シーン全体の管理
  entities/
    objects/
      Player.js         // プレイヤークラス
      Enemy.js          // 敵クラス
    effects/
      SlashEffect.js    // 斬撃エフェクト
      ExploadEffect.js  // 爆発エフェクト
    stage/
      Background.js     // 背景生成
      Platforms.js      // 足場生成
  utils/
    animations.js       // アニメーション定義用ヘルパー

改良の狙い

  • entities ...
    ゲーム内に登場するキャラクターやエフェクトをまとめる\
  • stage ... 背景や足場など環境要素をまとめる\
  • utils ...
    共通的な処理(アニメーション登録など)を切り出すことで再利用性を高める

3. クラスごとの責務分離

Player.js

  • 入力処理(カーソルキー・攻撃キー)\
  • プレイヤーキャラの生成と衝突判定\
  • 攻撃処理(斬撃エフェクト生成)\
  • update() メソッドでの移動・アニメーション制御
Player.js
export class Player {
  constructor(scene) {
    this.scene = scene;
    this.sprite = null;
    this.cursors = null;
    this.attackKey = null;

    /**
     * プレイヤーが最後に向いていた方向を記録する変数
     * 停止時やジャンプ中など、次の見た目を決めるために利用する
     */
    this.lastFacing = "right";

    /**
     * プレイヤーが「止まっているとき」に表示する静止フレームを定義
     * 各方向の歩行アニメーションの中央フレームを採用することで、自然に見える
     */
    this.idleFrame = {
      down: 1,
      left: 4,
      right: 7,
      up: 10,
    };
  }

  static preload(scene) {
    // 主人公画像をロード
    scene.load.spritesheet("player", "img/character/pipo-charachip001.png", {
      frameWidth: 32,
      frameHeight: 32,
    });
  }

  /**
   * プレイヤーキャラクターを生成し、物理演算や入力処理を設定する
   *
   * - 矢印キー入力を取得
   * - プレイヤースプライトを物理演算付きで生成
   * - 当たり判定サイズを調整(足元のみ反応するように設定)
   * - タイルマップとの衝突処理を登録
   *
   */
  create() {
    console.log("▼▼▼ [Player][create] ▼▼▼");

    const scene = this.scene;

    this.cursors = scene.input.keyboard.createCursorKeys();
    this.attackKey = scene.input.keyboard.addKey(
      Phaser.Input.Keyboard.KeyCodes.SPACE
    );

    /** プレイヤースプライトを生成(初期位置は X:40, Y:400) */
    this.sprite = scene.physics.add
      .sprite(40, 555, "player", 0)
      .setCollideWorldBounds(true); // 画面外に出ないよう制約

    // 当たり判定サイズを調整(キャラ画像32x32に対して足元16x16だけ反応させる)
    this.sprite.body.setSize(16, 16).setOffset(8, 16);

    // プレイヤーと足場レイヤーの衝突を有効化
    scene.physics.add.collider(this.sprite, scene.layer);

    console.log("▲▲▲ [Player][create] ▲▲▲");
  }

  /**
   * 攻撃モーションを実行するメソッド
   *
   * プレイヤーの最後に向いていた方向(右または左)を参照して、
   * - エフェクトを出す位置(右側 or 左側)
   * - エフェクトの角度
   * - 画像の反転(FlipX)
   * を切り替える。
   *
   * これにより、右向きの場合は右振り下ろし、左向きの場合は左振り下ろしの
   * 自然なアニメーションが表現できる。
   *
   */
  attack() {
    console.log("▼▼▼ [Player][attack] ▼▼▼");

    // エフェクトの位置オフセット(X座標用)
    let offsetX;
    // エフェクトの角度(斬撃の向き)
    let angle;
    // エフェクトの水平反転フラグ
    let flipX;

    // プレイヤーが右向きだった場合
    // - エフェクトをプレイヤーの右上に表示する
    // - 右方向に自然に見えるように角度を設定する
    // - エフェクトの画像を反転して利用する
    if (this.lastFacing === "right") {
      offsetX = 20;
      angle = 0;
      flipX = true;
    }

    // プレイヤーが左向きだった場合
    // - エフェクトをプレイヤーの左上に表示する
    // - 左方向に自然に見えるように角度を設定する
    // - エフェクトの画像は反転せずそのまま使う
    if (this.lastFacing === "left") {
      offsetX = -20;
      angle = 0;
      flipX = false;
    }

    // プレイヤーの位置を基準に、指定したオフセット位置に斬撃エフェクトを生成する
    const slash = this.scene.physics.add.sprite(
      this.sprite.x + offsetX,
      this.sprite.y - 20,
      "slash"
    );

    // 斬撃は重力の影響を受けない
    slash.body.setAllowGravity(false);

    // 当たり判定サイズ(必要に応じて調整)
    slash.body.setSize(80, 80).setOffset(20, 20);

    slash.setOrigin(0.5, 0.5);
    slash.setScale(0.5);
    slash.setAngle(angle);
    slash.setFlipX(flipX);

    // エフェクトの中心位置(回転の支点)を調整
    slash.setOrigin(0.5, 0.5);

    // エフェクトのサイズを調整(0.5倍)
    slash.setScale(0.5);

    // エフェクトの角度を設定(右向き・左向きに応じた値)
    slash.setAngle(angle);

    // エフェクトの水平反転を設定(右向き時は反転、左向き時は通常)
    slash.setFlipX(flipX);

    // 斬撃アニメーションを再生
    slash.anims.play("slash-effect", true);

    // 敵グループと斬撃の当たり判定(当たったら敵を消す)
    const hitCallback = (s, enemy) => {
      console.log("▼▼▼ [GameScene].js][attack][hitCallback] ▼▼▼");

      // 敵が既に破棄済みか確認してから destroy
      if (enemy && enemy.active) {
        // 敵消滅(必要なら被弾エフェクトをここで再生)
        // 敵の位置を基準に、指定したオフセット位置に爆発エフェクトを生成する
        const expload = this.scene.physics.add.sprite(
          enemy.x,
          enemy.y,
          "expload"
        );
        expload.anims.play("expload-effect", true);
        enemy.destroy();

        // アニメーションが終了したらエフェクトを削除してリソースを解放する
        expload.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
          expload.destroy();
        });
      }
      console.log("▲▲▲ [GameScene].js][attack][hitCallback] ▲▲▲");
    };

    // overlap を一時的に作る(斬撃1回につき1回だけ判定させたいなら、このままでOK)
    this.scene.physics.add.overlap(
      slash,
      this.scene.enemys,
      hitCallback,
      null,
      this
    );

    // アニメーションが終了したらエフェクトを削除してリソースを解放する
    slash.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
      slash.destroy();
    });

    console.log("▲▲▲ [Player][attack] ▲▲▲");
  }

  /**
   *
   */
  update() {
    if (!this.cursors) {
      return; // まだ初期化されていない
    }

    // === 接地判定 ===
    // プレイヤーが地面や他のオブジェクトに接しているかどうかを確認
    const onFloor =
      this.sprite.body.blocked.down || this.sprite.body.touching.down;

    // 左右移動
    if (this.cursors.left.isDown) {
      this.sprite.setVelocityX(-200);
      if (onFloor) {
        this.sprite.anims.play("player-walk-left", true);
      }
      this.lastFacing = "left";
    } else if (this.cursors.right.isDown) {
      this.sprite.setVelocityX(200);
      if (onFloor) {
        this.sprite.anims.play("player-walk-right", true);
      }
      this.lastFacing = "right";
    } else {
      this.sprite.setVelocityX(0);
      if (onFloor) {
        this.sprite.anims.stop();
      }
    }

    // ジャンプ
    if (this.cursors.up.isDown && onFloor) {
      this.sprite.setVelocityY(-300);
    }

    // === 空中の見た目 ===
    // 宙に浮いている間は歩行アニメーションを止め、最後に向いていた方向の静止フレームを表示
    if (!onFloor) {
      this.sprite.anims.stop();
      const idle = this.idleFrame[this.lastFacing] ?? this.idleFrame.right;
      this.sprite.setFrame(idle);
    }

    // === 攻撃 ===
    if (Phaser.Input.Keyboard.JustDown(this.attackKey)) {
      this.attack();
      return;
    }
  }
}

Enemy.js

  • 敵キャラの生成と初期化\
  • 当たり判定や物理設定\
  • update() メソッドでランダム移動を実装
export class Enemy {
  constructor(scene, sprite) {
    this.scene = scene;
    this.sprite = sprite;
  }

  static preload(scene) {
    // 敵キャラ画像をロード
    scene.load.spritesheet("enemy", "img/character/pipo-charachip019b.png", {
      frameWidth: 32,
      frameHeight: 32,
    });
  }

  initialize() {
    // 敵の当たり判定サイズを調整(中心の30x30)
    this.sprite.body.setSize(30, 30).setOffset(1, 1);

    // 物理特性の設定
    this.sprite.setCollideWorldBounds(true);
    this.sprite.setBounce(0.2);
    this.sprite.setVelocity(Phaser.Math.Between(-100, 100), 20);

    // 敵キャラと足場レイヤーの衝突を有効化
    this.scene.physics.add.collider(this.sprite, this.scene.layer);

    // 敵キャラとプレイヤーの衝突を有効化
    this.scene.physics.add.collider(this.sprite, this.scene.player);
  }

  /**
   * 毎フレームの更新処理
   */
  update() {
    if (!this.sprite) return;

    // 2秒に1回くらい方向転換
    if (Phaser.Math.Between(0, 100) < 2) {
      const speed = 100;
      const dir = Phaser.Math.Between(0, 1);
      switch (dir) {
        case 0:
          this.sprite.setVelocity(speed, 0);
          this.sprite.anims.play("enemy-walk-right", true);
          break;
        case 1:
          this.sprite.setVelocity(-speed, 0);
          this.sprite.anims.play("enemy-walk-left", true);
          break;
      }
    }
  }
}

Background.js

  • 背景画像のロードと表示
export class Background {
  /**
   *
   * @param {import("../../Scene/GameScene.js").GameScene} scene
   */
  static preload(scene) {
    // 背景画像をロード
    scene.load.image("sky", "img/bg7.jpg");
  }

  /**
   * 背景を作成してゲーム全体に配置する
   *
   * @param {import("../../Scene/GameScene.js").GameScene} scene
   */
  create(scene) {
    console.log("▼▼▼ [GameScene].js][createBackground] ▼▼▼");

    const bg = scene.add.image(0, 0, "sky").setOrigin(0, 0);
    bg.setDisplaySize(
      scene.sys.game.config.width,
      scene.sys.game.config.height
    );

    console.log("▲▲▲ [GameScene].js][createBackground] ▲▲▲");
  }
}

Platforms.js

  • 足場をタイルマップ形式で生成\
  • 衝突判定をシーンに登録
Platforms.js
export class Platforms {
  /**
   *
   * @param {import("../../Scene/GameScene.js").GameScene} scene
   */
  static preload(scene) {
    // 足場画像をロード
    scene.load.spritesheet("ground", "img/map_chip/base.png", {
      frameWidth: 16,
      frameHeight: 16,
    });
  }

  /**
   * 足場(タイルマップ)を作成して衝突判定を設定する
   *
   * @param {import("../../Scene/GameScene.js").GameScene} scene
   */
  create(scene) {
    console.log("▼▼▼ [Platforms][create] ▼▼▼");

    const mapData = [];

    // 1~36行目までは空白
    for (let y = 0; y < 36; y++) {
      mapData[y] = new Array(50).fill(-1);
    }

    // 足場ブロックを配置
    mapData[2].fill(368, 0, 50);
    mapData[15].fill(368, 10, 30);
    mapData[22].fill(368, 20, 30);
    mapData[22].fill(368, 40, 70);
    mapData[30].fill(368, 20, 50);

    // 最下段(36行目)は全面にブロックを敷く
    mapData[36] = new Array(50).fill(368);

    // タイルマップを生成
    const map = scene.make.tilemap({
      data: mapData,
      tileWidth: 16,
      tileHeight: 16,
    });

    const tiles = map.addTilesetImage("ground");
    scene.layer = map.createLayer(0, tiles, 0, 0);

    // -1 以外を衝突対象に設定
    scene.layer.setCollisionByExclusion([-1]);

    console.log("▲▲▲ [Platforms][create] ▲▲▲");
  }
}

animations.js

  • 斬撃・爆発などのアニメーションをまとめて登録する static
    メソッドを定義
Animation.js
export class Animations {
  /**
   * アニメーションをまとめて登録する。
   * @param {*} scene
   */
  static registerAnimations(scene) {
    this.#registerSlashEffectAnimation(scene);
    this.#registerExploadEffectAnimation(scene);
    this.#registerEnemyAnimation(scene);
    this.#playerAnimation(scene);
  }

  /**
   * 斬撃エフェクト用のアニメーションを事前に作成するメソッド
   *
   * preload() でロードしたスプライトシート(slash)を基に、
   * 「slash-effect」という名前のアニメーションを登録する。
   *
   * - 使用するフレームは 0 〜 4 の合計 5枚。
   * - フレームレートは 24(1秒間に24コマの速度で再生)。
   * - repeat を 0 に設定しているため、1回だけ再生して停止する。
   *
   * このメソッドを create() 内で呼び出しておくことで、
   * 攻撃時に slash.anims.play('slash-effect') を実行できるようになる。
   */
  static #registerSlashEffectAnimation(scene) {
    scene.anims.create({
      key: "slash-effect",
      frames: scene.anims.generateFrameNumbers("slash", { start: 0, end: 4 }),
      frameRate: 24,
      repeat: 0,
    });
  }

  /**
   * 爆発エフェクト用のアニメーションを事前に作成するメソッド
   *
   * preload() でロードしたスプライトシート(slash)を基に、
   * 「slash-effect」という名前のアニメーションを登録する。
   *
   * - 使用するフレームは 0 〜 4 の合計 5枚。
   * - フレームレートは 24(1秒間に24コマの速度で再生)。
   * - repeat を 0 に設定しているため、1回だけ再生して停止する。
   *
   * このメソッドを create() 内で呼び出しておくことで、
   * 攻撃時に slash.anims.play('slash-effect') を実行できるようになる。
   */
  static #registerExploadEffectAnimation(scene) {
    scene.anims.create({
      key: "expload-effect",
      frames: scene.anims.generateFrameNumbers("expload", { start: 0, end: 7 }),
      frameRate: 24,
      repeat: 0,
    });
  }

  /**
   *   敵キャラクターのアニメーションを登録する
   *   それぞれのキー(down/left/right/up)に対応した歩行アニメーションを定義
   * - frames: スプライトシートの何番フレームを使うか(連番指定)
   * - frameRate: 1秒間に何枚切り替えるか(数値が大きいほど速く動く)
   * - repeat: -1 にすると無限ループで再生
   *
   * @param {*} scene
   */
  static #registerEnemyAnimation(scene) {
    scene.anims.create({
      key: "enemy-walk-down",
      frames: scene.anims.generateFrameNumbers("enemy", { start: 0, end: 2 }),
      frameRate: 8,
      repeat: -1,
    });
    scene.anims.create({
      key: "enemy-walk-left",
      frames: scene.anims.generateFrameNumbers("enemy", { start: 3, end: 5 }),
      frameRate: 8,
      repeat: -1,
    });
    scene.anims.create({
      key: "enemy-walk-right",
      frames: scene.anims.generateFrameNumbers("enemy", { start: 6, end: 8 }),
      frameRate: 8,
      repeat: -1,
    });
    scene.anims.create({
      key: "enemy-walk-up",
      frames: scene.anims.generateFrameNumbers("enemy", { start: 9, end: 11 }),
      frameRate: 8,
      repeat: -1,
    });
  }

  /**
   *  プレイヤーのアニメーションを登録する
   * - それぞれのキー(down/left/right/up)に対応した歩行アニメーションを定義
   * - frames: スプライトシートの何番フレームを使うか(連番指定)
   * - frameRate: 1秒間に何枚切り替えるか(数値が大きいほど速く動く)
   * - repeat: -1 にすると無限ループで再生
   *
   * @param {*} scene
   */
  static #playerAnimation(scene) {
    scene.anims.create({
      key: "player-walk-down",
      frames: scene.anims.generateFrameNumbers("player", { start: 0, end: 2 }),
      frameRate: 8,
      repeat: -1,
    });
    scene.anims.create({
      key: "player-walk-left",
      frames: scene.anims.generateFrameNumbers("player", { start: 3, end: 5 }),
      frameRate: 8,
      repeat: -1,
    });
    scene.anims.create({
      key: "player-walk-right",
      frames: scene.anims.generateFrameNumbers("player", { start: 6, end: 8 }),
      frameRate: 8,
      repeat: -1,
    });
    scene.anims.create({
      key: "player-walk-up",
      frames: scene.anims.generateFrameNumbers("player", { start: 9, end: 11 }),
      frameRate: 8,
      repeat: -1,
    });
  }
}

4. GameScene のシンプル化

リファクタリング後の GameScene.js
では、シーン全体の進行管理だけを記述します。

  • preload() ... 各クラスの preload() を呼び出すだけ
  • create() ... 背景・足場・プレイヤー・敵を初期化
  • update() ... player.update()enemy.update() を呼ぶだけ

こうすることで、シーンの流れが明確になり、可読性と保守性が大きく向上します。

修正後、このようにかなりスッキリしました。

GameScene.js
import { Player } from "../entities/objects/Player.js";
import { Enemy } from "../entities/objects/Enemy.js";
import { SlashEffect } from "../entities/effects/SlashEffect.js";
import { ExploadEffect } from "../entities/effects/ExploadEffect.js";
import { Background } from "../entities/stage/Background.js";
import { Platforms } from "../entities/stage/Platforms.js";
import { Animations } from "../utils/animations.js";

/**
 * ゲームのメインシーンを表すクラス
 *
 * @extends Phaser.Scene
 */
export class GameScene extends Phaser.Scene {
  /** レイヤー */
  layer;

  /** プレイヤー */
  player;

  /** 入力キー */
  cursors;

  /** 攻撃キー */
  attackKey;

  /** 敵キャラクタ */
  enemys;

  /**
   * シーンのコンストラクタ
   * - シーンキーを "GameScene" として登録する
   */
  constructor() {
    super("GameScene");
  }

  /**
   * アセットの事前読み込みを行う
   * - 画像や音声などをロードする
   *
   * @see https://docs.phaser.io/api-documentation/class/scene
   */
  preload() {
    this.load.setBaseURL("/assets/");

    Background.preload(this);
    Platforms.preload(this);
    Player.preload(this);
    Enemy.preload(this);
    SlashEffect.preload(this);
    ExploadEffect.preload(this);
  }

  /**
   * シーン初期化時に呼ばれる
   *
   * @see https://docs.phaser.io/api-documentation/class/scene
   */
  create() {
    this.physics.world.gravity.y = 300;
    Animations.registerAnimations(this);

    const background = new Background();
    background.create(this);

    const platforms = new Platforms();
    platforms.create(this);

    this.player = new Player(this);
    this.player.create();

    this.enemys = this.physics.add.group();

    // 一定時間ごとに敵を追加するタイマーイベント
    this.time.addEvent({
      delay: 2000,
      callback: this.spawnEnemy,
      callbackScope: this,
      loop: true,
    });
  }

  /**
   * 毎フレーム呼ばれる更新処理
   * - プレイヤー操作や物理演算などを記述する
   *
   * @param {number} [time] 経過時間(ミリ秒)
   * @param {number} [delta] 前フレームからの経過時間(ミリ秒)
   * @override
   *
   * @see https://docs.phaser.io/api-documentation/class/scene
   */
  update(time, delta) {
    this.player.update();

    this.enemys.children.iterate((enemySprite) => {
      if (!enemySprite) return;

      // Enemy クラスのラッパーを持たせている場合
      if (enemySprite.controller) {
        enemySprite.controller.update();
      }
    });
  }

  /**
   * 敵を生成する。
   *
   * @returns
   */
  spawnEnemy() {
    // 画面に存在できる敵の最大数
    const maxEnemies = 5;

    // すでに出ている敵が maxEnemies 以上なら生成しない
    if (this.enemys.countActive(true) >= maxEnemies) {
      return;
    }

    const x = Phaser.Math.Between(100, 700); // 出現位置Xをランダムに
    const y = 50; // 上から落ちてくるように
    const enemySprite = this.enemys.create(x, y, "enemy");

    // Enemy クラスに初期化を委譲
    const enemy = new Enemy(this, enemySprite);
    enemy.initialize();

    // Sprite に Enemy インスタンスを紐づけておく
    enemySprite.controller = enemy;
  }
}

5. 今後の拡張を考慮する

  • ステージ追加 → scenes/ に新しいシーンを作成\
  • 新キャラクターやエフェクト追加 → entities/
    に追加するだけで管理可能\
  • Animations クラスを拡張 → 共通的に使えるアニメーションを一元管理

まとめ

  • 責務ごとにクラスを分割することでコードが整理される
  • GameScene.js をシンプルに保つことで見通しが良くなる
  • 将来の拡張に対応しやすい構造が作れる

株式会社ONE WEDGE

【Serverlessで世の中をもっと楽しく】 ONE WEDGEはServerlessシステム開発を中核技術としてWeb系システム開発、AWS/GCPを利用した業務システム・サービス開発、PWAを用いたモバイル開発、Alexaスキル開発など、元気と技術力を武器にお客様に真摯に向き合う価値創造企業です。
https://onewedge.co.jp

Discussion