phaserでゲーム開発 第五回

に公開

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

https://zenn.dev/k_tabuchi/articles/491a66403580fd

今回はプレイヤーキャラに攻撃モーションを与えてみたいと思います。
一瞬で消えるためスクショとれなかったので動画にしてYoutubeにあげてみました。
https://youtu.be/rjUDg2H8LUc

全体像

まずは、今回のコードの全文です。

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

    /** レイヤー */
    #layer;

    /** 移動速度 */
    #runSpeed = 100;

    /** ジャンプ力 */
    #jumpSpeed = 300;

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

    /** 入力キー */
    #cursors;

    /** 攻撃キー */
    #attackKey;

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

    /**
     * アセットの事前読み込みを行う
     * - 画像や音声などをロードする
     * 
     * @override
     */
    preload() {
        this.load.setBaseURL('/assets/');

        this.load.image('sky', 'img/bg7.jpg');

        // 足場画像をロード
        this.load.spritesheet('ground', 'img/map_chip/base.png', {
            frameWidth: 16,
            frameHeight: 16
        });

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

        // 斬撃エフェクトをロード
        this.load.spritesheet('slash', 'img/effect/pipo-btleffect001.png', {
            frameWidth: 120,
            frameHeight: 120,
        });
    }

    /**
     * シーン初期化時に呼ばれる
     * 
     * @override
     */
    create() {
        this.createBackground();
        this.createPlatforms();
        this.createPlayer();
        this.createAttackEffect();
    }

    /**
     * 攻撃モーションを実行するメソッド
     *
     * プレイヤーの最後に向いていた方向(右または左)を参照して、
     * - エフェクトを出す位置(右側 or 左側)
     * - エフェクトの角度
     * - 画像の反転(FlipX)
     * を切り替える。
     *
     * これにより、右向きの場合は右振り下ろし、左向きの場合は左振り下ろしの
     * 自然なアニメーションが表現できる。
     */
    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.add.sprite(this.#player.x + offsetX, this.#player.y - 20, 'slash');

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

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

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

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

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

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



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

            // 使用するフレーム番号(0〜4までの5コマ)
            frames: this.anims.generateFrameNumbers('slash', { start: 0, end: 4 }),

            // フレームの再生速度(24fps → なめらかな動き)
            frameRate: 24,

            // 繰り返し回数
            // 0 → 1回のみ再生して終了
            // -1 → 無限ループになるが、今回は不要なので0を指定
            repeat: 0,
        });
    }

    /**
     * 背景を作成してゲーム全体に配置する
     */
    createBackground() {
        const bg = this.add.image(0, 0, 'sky').setOrigin(0, 0);
        bg.setDisplaySize(this.sys.game.config.width, this.sys.game.config.height);
    }

    /**
     * 足場(タイルマップ)を作成して衝突判定を設定する
     */
    createPlatforms() {
        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 = this.make.tilemap({
            data: mapData,
            tileWidth: 16,
            tileHeight: 16
        });

        const tiles = map.addTilesetImage('ground');
        this.#layer = map.createLayer(0, tiles, 0, 0);

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

    /**
     * プレイヤーキャラクターを生成し、物理演算や入力処理を設定する
     *
     * - 矢印キー入力を取得
     * - プレイヤースプライトを物理演算付きで生成
     * - 当たり判定サイズを調整(足元のみ反応するように設定)
     * - タイルマップとの衝突処理を登録
     *
     * @private
     */
    createPlayer() {
        /** 矢印キー入力オブジェクト */
        this.#cursors = this.input.keyboard.createCursorKeys();

        // 攻撃キー(スペース)
        this.#attackKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);


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

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

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


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

        // プレイヤーが「止まっているとき」に表示する静止フレームを定義
        // - 各方向の歩行アニメーションの中央フレームを採用することで、自然に見える
        this.idleFrame = {
            down: 1,   // 下向き(0〜2フレームの中央)
            left: 4,   // 左向き(3〜5フレームの中央)
            right: 7,  // 右向き(6〜8フレームの中央)
            up: 10,    // 上向き(9〜11フレームの中央)
        };

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

    }

    /**
     * 毎フレーム呼ばれる更新処理
     * - プレイヤー操作や物理演算などを記述する
     * 
     * @param {number} [time] 経過時間(ミリ秒)
     * @param {number} [delta] 前フレームからの経過時間(ミリ秒)
     * @override
     */
    update(time, delta) {
        // === 接地判定 ===
        // プレイヤーが地面や他のオブジェクトに接しているかどうかを確認
        const onFloor = this.#player.body.blocked.down || this.#player.body.touching.down;

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

        // === 左右移動 ===
        if (this.#cursors.left.isDown) {
            // ←キーを押している間は左へ移動
            this.#player.setVelocityX(-this.#runSpeed);

            // 地面にいる場合のみ「歩くアニメーション」を再生
            if (onFloor) {
                this.#player.anims.play('player-walk-left', true);
            }
            this.lastFacing = 'left';
        } else if (this.#cursors.right.isDown) {
            // →キーを押している間は右へ移動
            this.#player.setVelocityX(this.#runSpeed);

            // 地面にいる場合のみ「歩くアニメーション」を再生
            if (onFloor) {
                this.#player.anims.play('player-walk-right', true);
            }
            this.lastFacing = 'right';
        } else {
            // 左右キーを押していないときは静止
            this.#player.setVelocityX(0);

            // 地面に立っているなら歩行アニメーションを止めて「待機用の静止フレーム」を表示
            if (onFloor) {
                this.#player.anims.stop();
                const idle = this.idleFrame[this.lastFacing] ?? this.idleFrame.right;
                this.#player.setFrame(idle);
            }
        }

        // === ジャンプ ===
        // ↑キーが押され、かつ接地しているときのみジャンプ可能
        if (this.#cursors.up.isDown && onFloor) {
            this.#player.setVelocityY(-this.#jumpSpeed);
        }

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

preload()

エフェクトのアニメーションが入っているpng(チップタイル形式)をロードしています。
frameWidthとframeHeightは画像のサイズを調べて、それをコマ数で割った値を指定します。

今回使用した画像はこれです。縦1コマで120px、横5コマで600pxあるので、
縦 120 / 1 = 120
横 600 / 5 = 120
を指定しています。

   /**
     * アセットの事前読み込みを行う
     * - 画像や音声などをロードする
     * 
     * @override
     */
    preload() {
        ~中略~

        // 斬撃エフェクトをロード
        this.load.spritesheet('slash', 'img/effect/pipo-btleffect001.png', {
            frameWidth: 120,
            frameHeight: 120,
        });
    }

createAttackEffect()

エフェクトアニメーションの定義をします。
これでただの横並びの画像がアニメになります。
パラパラマンガと同じ仕組み。
と言われても昭和時代の人にしか伝わらないかもしれませんが。

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

            // 使用するフレーム番号(0〜4までの5コマ)
            frames: this.anims.generateFrameNumbers('slash', { start: 0, end: 4 }),

            // フレームの再生速度(24fps → なめらかな動き)
            frameRate: 24,

            // 繰り返し回数
            // 0 → 1回のみ再生して終了
            // -1 → 無限ループになるが、今回は不要なので0を指定
            repeat: 0,
        });
    }

attack()

プレイヤーキャラクタとエフェクトの位置関係や画像の向きを調整して、準備できたらエフェクトアニメを表示します。
表示が終わったらアニメーションを消します。
オフセットは基準点がズラす、というような意味。
キャラクターの向きに合わせて反転させるためにフリップでエフェクトの向きを合わせています。

    /**
     * 攻撃モーションを実行するメソッド
     *
     * プレイヤーの最後に向いていた方向(右または左)を参照して、
     * - エフェクトを出す位置(右側 or 左側)
     * - エフェクトの角度
     * - 画像の反転(FlipX)
     * を切り替える。
     *
     * これにより、右向きの場合は右振り下ろし、左向きの場合は左振り下ろしの
     * 自然なアニメーションが表現できる。
     */
    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.add.sprite(this.#player.x + offsetX, this.#player.y - 20, 'slash');

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

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

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

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

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

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

createPlayer()

phaserでは十字キーはcreateCurorKeys()で使用できるようになるのですが、なぜかスペースキーは別に登録しないといけません。
そのため、keyboard.addKeyとしています。

スペースキーが押されたら、攻撃エフェクトが発動するようにしています。

~~中略~~

        // 攻撃キー(スペース)
        this.#attackKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);

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


まとめ

今回の修正のポイントは以下のようになります。

  • アセットの読み込み

    • preload() で斬撃用スプライトシート(slash)を読み込む
    • フレームサイズ(幅・高さ)を正しく指定する
  • アニメーションの事前登録

    • createAttackEffect() で「slash-effect」というキーを登録
    • 使用フレーム(0〜4)、フレームレート(24fps)、再生回数(1回のみ)を設定
  • 攻撃メソッドの作成

    • attack() でプレイヤーの向きに応じて位置・角度・FlipXを切り替える
    • プレイヤー位置を基準にスプライトを生成し、アニメーションを再生
    • 再生完了イベントでスプライトを破棄し、リソースを解放
  • 入力処理との連動

    • createPlayer() でスペースキーを攻撃キーとして登録
    • update() 内で JustDown(this.#attackKey) を検知し、attack() を実行
  • ポイント

    • 「ロード → 登録 → 再生 → 破棄」の流れを正しく実装する
    • プレイヤーの向きに合わせてオフセットや反転を調整し、自然な動きを表現する
    • 攻撃ごとにスプライトを生成して破棄することでリソース管理を簡潔にする

株式会社ONE WEDGE

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

Discussion