旧プログラマーによる日曜プログラミング日記

趣味は作曲、自分で一から作りたがりのユーザIT子会社の旧プログラマーによるリハビリのための日曜プログラミング日記です。

Phaser3+typescriptでSRPGを開発してみる #009 カーソルの移動

今日は、SRPGだとお馴染みのカーソルの移動です。RPGでも使えますが、RPGは常に主人公を動かすと思います。SRPGの場合はカーソル動かして、動かしたいユニットを選ぶUIが多いように思います。

カーソルの移動

キーボード入力方法

キーボード入力は

  • 上下左右
  • それ以外

と2つ用意されています。

(1) 上下左右

いきなりですが、使い方は下記のような感じです。

GameScene.ts

export default class GameScene extends Phaser.Scene {

    /** 十字キーを管理 */
    private _cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;

    create() {
        // 上下左右キーの入力受付
        this._cursorKeys = this.input.keyboard.createCursorKeys();
    }

    update() {
         if (this._cursorKeys.left.isDown) {
        // 左キー押下時の処理
         }
        else if (this._cursorKeys.right.isDown) {
        // 右キー押下時の処理
        }
        else if (this._cursorKeys.up.isDown) {
         // 下キー押下時の処理
         }
        else if (this._cursorKeys.down.isDown) {
        // 上キー押下時の処理
        }
    }
}

ポイントですが、

  • キーの受付を保持する変数はクラスのメンバ変数にします。create()update()の両メソッドを跨ぐ変数になるためです
  • else ifで繋いでいるため、上下左右のキーのうち、複数が呼ばれても、最初のif文に引っかかった処理しか実行されません。同時押しに対応するには、elseを外してifのみにください
  • このサンプルだと、update()関数が呼ばれる都度、処理されます。言い換えると、fps(Gameオブジェクト初期化時に設定)のターム、例えば1/30sec都度、呼ばれてしまいます。ですので、押下しているときに実行する処理が少しでも重いと、CPUが追いつかなくなります。。

ということで、どうするか?というと、処理している間はフラグを付けて、実行されないようにするテクニックがあります。(追記部分のみ記載します)

GameScene.ts(一部抜粋)

            :
  private _isCursorDoing: boolean = false;
           :
  update() {
         if (this._isCursorDoing) return;
         if (this._cursorKeys.left.isDown) {
             this._isCursorDoing = true;
             // 左キー押下時の処理
             this.doleft();

             this._isCursorDoing = false;
         }
            :
    }

(2) それ以外

それ以外のキーは都度登録が必要になります。

GameScene.ts(一部抜粋)

        :
    /** 十字キー以外のキーを管理 */
     private _keys?: Map<string, Phaser.Input.Keyboard.Key> = new Map<string, Phaser.Input.Keyboard.Key>();
        :
    create() {
        :
        this._keys.set("SPACE", this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE));
        :
     }
    update() {
        :
        if (this._keys.get("SPACE").isDown) {
            // 押下時の処理

        }
        :
    }
  • create()でキー入力をaddKey()メソッドで登録、そのキー(このサンプルでは"SPACE")で呼び出して update()メソッドの中でisDownプロパティでコントロールする、という使い方です

素直に、それぞれのキーをメンバ変数として定義して、

var keySpace = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);

と一つずつ宣言して使うこともできますが、変数が多くなりすぎるから見づらいかな、、とも思い、魔が挿してMap使ってしまいましたが、どちらでも良いと思います。連想配列使う人もいますかね。

カーソルの宣言

次はカーソルですが、カーソルのクラスを作るやり方が一般的かと思います。

Cursor.ts

/**
 * カーソルを管理するクラス
 */
export class Cursor {

    /** 左上(0,0)からのx座標 */
    private _currentX: number = 0;
    /** 左上(0,0)からのy座標 */
    private _currentY: number = 0;

    /** 画面上のユニット(スプライト)*/
    private _sprite: Phaser.GameObjects.Sprite;

    /** 一番右下のx座標。setScope()でセット */
    private _maxX: number = 1;
    /** 一番右下のy座標。setScope()でセット */
    private _maxY: number = 1;
    /** 一番左上のx座標 */
    private _minX: number = 0;
    /** 一番左上のy座標 */
    private _minY: number = 0;

    /** カーソルが移動中であることを管理するフラグ */
    private _isMoving: boolean = false;
    /** 行き先のx座標 */
    private _targetX: number = 0;
    /** 行き先のy座標 */
    private _targetY: number = 0;

    /**
     * カーソルが移動中かどうかを確認
     * @returns 移動中であればtrue。停止していればfalse
     */
    public get isMoving(): boolean {
        return this._isMoving;
    }

    /**
     * カーソルが移動中フラグをセット
     * @param isMoving 移動中であればtrue、停止中であればfalse
     */
    public set isMoving(isMoving: boolean) {
        this._isMoving = isMoving;
    }

    /**
     * Phaser3のspriteを紐付け
     * @param 初期化済みのSprite
     */
    public set sprite(sprite: Phaser.GameObjects.Sprite) {
        this._sprite = sprite;
    }

    /**
     * Phaser3のspriteを取得
     * @returns Phaser.GameObjects.Sprite
     */
    public get sprite(): Phaser.GameObjects.Sprite {
        return this._sprite;
    }

    /**
     * 現在の位置をセット。
     * 最初にユニットを登場させるときに使用し、ゲーム上でユニットが移動するときにはmove()メソッドを使用
     * @param x 現在地のx座標
     * @param y 現在地のy座標
     */
    public setPosition(x: number, y: number) {
        if (x < this._maxX && x >= this._minX) {
            this._currentX = x;
        } else {
            this._currentX = this._maxX;
        }
        if (y < this._maxY && y >= this._minY) {
            this._currentY = y;
        } else {
            this._currentY = this._maxY;
        }
    }

    /**
     * Mapの広さを指定
     * @param width 幅(x座標)。maxXにセット
     * @param height 高さ(y座標)。maxYにセット
     */
    public setScope(width: number, height: number) {
        this._maxX = width;
        this._maxY = height;
    }

    /**
     * 現在のx座標を取得
     * @override
     * @returns 現在のx座標
     */
    public get currentX(): number {
        return this._currentX;
    }

    /**
     * 現在のy座標を取得
     * @override
     * @returns 現在のy座標
     */
    public get currentY(): number {
        return this._currentY;
    }

    /**
     * 移動完了と見なし、現在値座標を更新
     */
    public finishedMoving(): void {
        this._currentX = this._targetX;
        this._currentY = this._targetY;
        this._targetX = null;
        this._targetY = null;
    }

    /**
     * 指示先に移動できるかを事前に確認する
     * @param moveX x座標の移動差分(セルで指定)。右に2歩であれば+2
     * @param moveY y座標の移動差分(セルで指定)。上に3歩であれば-3
     * @returns 移動可能であればtrue。移動可能でなければfalse
     */
    public canMove(moveX: number, moveY: number): boolean {
        return (this._currentY + moveY) < this._maxY
            && (this._currentY + moveY) >= this._minY
            && (this._currentX + moveX) < this._maxX
            && (this._currentX + moveX) >= this._minX;
    }

    /**
     * ターゲットの座標を設定
     * @param targetX 
     * @param targetY 
     */
    public setMove(moveX: number, moveY: number): void {
        this._targetX = this._currentX + moveX;
        this._targetY = this._currentY + moveY;
    }

    /**
     * ターゲットのx座標を取得
     * @returns ターゲットのx座標(セル)
     */
    public get targetX(): number {
        return this._targetX;
    }

    /**
     * ターゲットのy座標を取得
     * @returns ターゲットのy座標(セル)
     */
    public get targetY(): number {
        return this._targetY;
    }
}

このCursorクラスですが、サンプルのようにspriteオブジェクトを委譲変数で持つやり方と、Spriteクラスを継承するやり方と2通り考えられそうです。私は継承よりもDI (Dependency Injection)の方が好きなので、このブログでは、委譲変数タイプで実装します。

そしてこのCursorのオブジェクトを移動させるメソッドを紹介します。メインクラスであるGameSceneの中で実装します。

GameScene.ts

import { Cursor } from "../../view/phaser/Cursor";


export default class GameScene extends Phaser.Scene {

    /** 十字キーを管理 */
    private _cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;
    /** カーソルの画像のキー */
    private static CURSOR_IMAGE_KEY: string = "IMAGE_CURSOR";
    /** カーソルオブジェクト */
    private _cursor?: Cursor;
    /** カーソル移動幅 */
    private static MOVE_TARGET_INTERVAL_STEP_PX = 2;

    /** 1セルあたりの解像度(px) */
    public static CELL_PX = {
        WIDTH: 32,
        HEIGHT: 32
    };

    /** マップの開始位置(Offset) 左上が(0,0)  */
    private static MAP_POSITION = {
        X: 20,
        Y: 40
    };
  
    preload() {
        // カーソルの画像ファイルをロード
        this.load.image(GameScene.CURSOR_IMAGE_KEY, "/images/system/Cursor.png");
    }

   create() {
        // カーソル処理取得(上下左右キーで待ち受け)
        this.initializeCursor();
    };

    update() {
        // カーソルが動いている最中は、入力を受け付けず、決められたピクセル分移動する
        if (this._cursor.isMoving) {
            this.updateCursorPosition();
            return;
        }
        // カーソルが動いていない時には、入力を受け付ける
        this.setCursorMoving();
    }

    /**
     * カーソルを設定
     */
    private initializeCursor(): void {
        this._cursor = new Cursor();
        this._cursor.setScope(640, 480);  // 横:640px、縦:480px分なら動いて良しとする
        this._cursor.setPosition(0, 0); // 初期ポジションは左上

        this._cursor.sprite = this.add.sprite(
            GameScene.calPosition(this._cursor.currentX, GameScene.CELL_PX.WIDTH, GameScene.MAP_POSITION.X),
            GameScene.calPosition(this._cursor.currentY, GameScene.CELL_PX.HEIGHT, GameScene.MAP_POSITION.Y),
            GameScene.CURSOR_IMAGE_KEY);
        this._cursor.sprite.setDepth(10);

        // 上下左右キーのカーソル受付開始
        this._cursorKeys = this.input.keyboard.createCursorKeys();
    }

    /**
     * カーソルの画像(sprite)が動き始めることをセット
     */
    private setCursorMoving(): void {

        // 移動座標に変換
        if (this._cursorKeys.left.isDown) {
            this._cursor.setMove(-1, 0);
        }
        else if (this._cursorKeys.right.isDown) {
            this._cursor.setMove(1, 0);
        }
        else if (this._cursorKeys.up.isDown) {
            this._cursor.setMove(0, -1);
        }
        else if (this._cursorKeys.down.isDown) {
            this._cursor.setMove(0, 1);
        } else {
            return;
        }

        // フラグをセット
        this._cursor.isMoving = true;
        console.log("CURSOR TARGET = (%d, %d)", this._cursor.targetX, this._cursor.targetY);
    }

    /**
     * update()内での移動。update()が実行される都度、カーソルが移動中であれば実行されるメソッド
     */
    private updateCursorPosition(): void {
        // ターゲット(ゴール)のx座標/y座標と違う場合、spriteをターゲットの方に近くなるように移動
        var targetXPixel = GameScene.calPosition(this._cursor.targetX, GameScene.CELL_PX.WIDTH, GameScene.MAP_POSITION.X);
        var targetYPixel = GameScene.calPosition(this._cursor.targetY, GameScene.CELL_PX.HEIGHT, GameScene.MAP_POSITION.Y);

        // 現在値、左側にいる場合は、右にINTERVAL_STEP_PXピクセル分移動
        if (targetXPixel > this._cursor.sprite.x) {
            this._cursor.sprite.x += GameScene.MOVE_TARGET_INTERVAL_STEP_PX;
        }
        // 現在値、右側にいる場合は、左にINTERVAL_STEP_PXピクセル分移動
        else if (targetXPixel < this._cursor.sprite.x) {
            this._cursor.sprite.x -= GameScene.MOVE_TARGET_INTERVAL_STEP_PX;
        }
        // 現在値、上側にいる場合は、下にINTERVAL_STEP_PXピクセル分移動
        else if (targetYPixel > this._cursor.sprite.y) {
            this._cursor.sprite.y += GameScene.MOVE_TARGET_INTERVAL_STEP_PX;
        }
        // 現在値、下側にいる場合は、下にINTERVAL_STEP_PXピクセル分移動
        // else ifで繋いでいるので、x方向が終わったら、y方向に進む
        else if (targetYPixel < this._cursor.sprite.y) {
            this._cursor.sprite.y -= GameScene.MOVE_TARGET_INTERVAL_STEP_PX;
        }

        // 近くなったら到着とみなす処理を実行
        // (CELL幅が移動幅(INTERVAL)で割り切れない時に、行ったり来たりしないようにする対応)
        if ((Math.abs(targetXPixel - this._cursor.sprite.x) < GameScene.MOVE_TARGET_INTERVAL_STEP_PX)
            && Math.abs(targetYPixel - this._cursor.sprite.y) < GameScene.MOVE_TARGET_INTERVAL_STEP_PX) {
            this._cursor.sprite.x = targetXPixel;
            this._cursor.sprite.y = targetYPixel;

            // movingUnitを初期化
            this._cursor.finishedMoving();
            this._cursor.isMoving = false;
        }
    }

    /**
     * オブジェクトの座標を計算(センタリング機能付き)
     * @param cellPosition セルポジション
     * @param interval セル間隔
     * @param intercept 描画領域のオフセット
     * @returns px
     */
    public static calPosition(cellPosition: number, interval: number, intercept: number) {
        return (cellPosition + 0.5) * interval + intercept;
    }
}

仕組みは下記の通りです。

  • update()が呼ばれるごとにMOVE_TARGET_INTERVAL_STEP_PX変数分(このサンプルでは2ピクセル)ずつカーソルを移動する
  • 上下左右キーを1度受け付けたら、1セル分(32px、CELL_PX.WIDTHCELL_PX.HEIGHTで定義)動くまでは、スルーします。それをCursorクラスのメンバ変数isMovingで制御しています。
  • calPosition()メソッドは、セル位置の座標を指定するstaticなメソッドです。セル幅(interval)とマップを表示する位置(intercept)を指定するのですが、画像ファイルのポジションは、中心の座標を指定する必要がありますので、計算式の方で、セル位置+0.5分、ずらして指定しています

Tween使うやり方もありそうで、そっちの方がスムーズに動きました。そのやり方は#011で試してみたいと思います。

まとめ

本日は、Phaser3でカーソルの移動がようやくできるようになりました、というご報告でした。

update()の動きがわかってくれば、そこまで難しくないのかなと思います。ということで、次回はupdate()メソッドについて、もう少し整理してみます。

mtmusic34.hatenablog.com

過去の日記

タイトル 記載内容
#001 プロローグ Phaser3とは
#002 環境設定 インストール・ビルド環境設定
#003 はじめてのPhaser3 初回稼働・キャンバス表示
#004 マップ作成 タイルセットの読込、画面手動作成、Sceneクラスのライフサイクル
#005 Tiledの利用 Tiledによる画面マップJSON作成、マルチレイヤーの背景画面
#006 JSONファイルの読込 データのpreload
#007 ファイルのキャッシュ ゲームデータの管理、シングルトンパターン
#008 データモデル(ユニット編) SRPGに必要なデータ・管理方法