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

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

Phaser3+typescriptでSRPGを開発してみる #010 各メソッドのコールと状態遷移

今回は、ちょいとブレイクタイムして、今まで調査してきたものを踏まえて、Phaser3のSceneクラスの振る舞いと状態遷移を整理したいと思います。(ネタの調査・試行が追いつかなくなってきたのが、バレますね。。)

あまりにデバッグがやりづらかったので、デバッグモードも作ってみましたので、ご紹介させていただきます。

各メソッドのコールと状態遷移

各メソッドのコール

改めてですが、Phaser3は下記のような構造を取っています。

Phaser.Game
    Phaser.Scene
         init
         preload
         create
         update

Gameクラスが全体を管理するクラスで、Game内の各画面・場面を定義するのがSceneです。ですので、Gameに対してSceneは1:多です。使い方としては、Sceneクラスを継承した子クラスを定義して使うのが、一番良さそうです。

そのSceneクラスでは予め、initpreloadcreateupdateの各メソッドが定義されています。それぞれの役割は下表の通りです(#004のときの使い回しですみません。。)

関数名 概要
init preloadに先駆けて呼ばれるメソッド。どんな場合に使うのかはもう少し確認が必要
preload アセット(画像・音楽・データ等)を呼び出すときに使用。メモリ上にデータをキャッシュ
create シーンスタート時にゲームオブジェクトを作るときに使用
update シーンに対して更新を掛けるときに使用。FPSの指定タイミング(描画間隔)で呼び出される

図に書くとこのような呼ばれ方になります。

f:id:mtmusic34:20210714224630p:plain
Sceneクラスのライフサイクル

updateの実装パターン

ですので、preloadcreateは普通にコーディングすれば良いのですが、updateは、1秒間に何度も呼ばれるメソッド、実質、無限ループみたいに捉える必要があります。

そのため、updateの処理の中で、FPSよりも長く掛かる非同期メソッドがあると、次のupdateが呼ばれるまでに終わり切らず、CPUがすごい頑張ってしまい熱暴走しますし、UI側もスローダウンして、入力への反応ができなくなります。結果、Webサーバを強制終了する羽目になります。

そのためupdateの典型的な実装パターンは下記のようになります。

  • 何か処理中(特に非同期処理の場合)は、以降のロジックを飛ばして、不要な計算をなるべく排除する
  • そのため処理中は、クラスのメンバー変数を定義し、フラグをもたせることが必要になります
  • update()メソッドで実行する処理は、その瞬間の状態を書けば良いです。1/n秒単位に呼ばれて、繋げると動いてるように見えます。例えば、等速運動させたいときには、位置を前回対比+○○にする、といった書き方で良いです。 (「動画は静止画の集まり」の原理と一緒ですね)
export class GameScene extends Phaser.Scene {

    // メンバー変数を用意し、どこからでもアクセスできるようにする
    private _flag1: boolean = false;
    private _flag2: GameUnit = null;

    update () {
        // フラグがオンの時は、以降の処理を実行しない
        if (this._flag1) return;
        if (this._flag2) {
            // 処理を実行
            this.moveUnit (this_.flag2);
            return;
         };

        // キャラを動かすとき、フラグをオンにする
        if(readyMove) {
             this._flag2 = this.getSelectedUnit();
        }
    }

    // 実際の処理
    moveUnit(unit: GameUnit) {
        // ユニットを右に10pxずつ移動
        unit.sprite.x += 10;

        // 処理が終わったらフラグをオフに戻す
        if(unit.sprite.x === 300) {
            this._flag2 = null;
        }
    }
}


実コードではなくイメージが伝わるように書いてみましたが、状態を管理するメンバー変数を用意して、update()の最初の段階で判定してあげて、以降の処理を実行しないみたいなのが必要かなと思います。

【応用】デバッグモード

この原理を利用して、デバッグモードを作ってみました。単に、その瞬間の状態を表示するものです。

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
    };

    /** デバッグ表示領域 */
    private __debugDisplayArea?: Phaser.GameObjects.Container;
    /** デバッグ表示内容 */
    private __debugText: Phaser.GameObjects.Text[] = Array<Phaser.GameObjects.Text>(10);
    /** デバッグ内の文字スタイル */
    private __debugStyle: object = {
        fontSize: '14px',
        color: 'white',
        wordWrap: {
            width: 720,
            useAdvancedWrap: true
        }
    }
    /** デバッグ表示インターバル */
    private __debugInterval: number = 100;

    create() {

        // デバッグモードの表示
        this.startDebugMode();

        // カーソル処理取得
        this.initializeCursor();
    }

    update() {

        // デバッグモード実行
        this.debugModeOn();


        // カーソル処理
        if (this._cursor.isMoving) {
            this.updateCursorPosition();
            return;
        }

        this.setCursorMoving();
    }

    /**
     * カーソルを設定
     */
    private initializeCursor(): void {
        this._cursor = new Cursor();
        this._cursor.setScope(640, 480);
        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()内での移動
     */
    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;
    }

    /**
     * デバッグモードを開始(create()から呼び出し)
     */
    private startDebugMode(): void {
        this.__debugDisplayArea = this.add.container(550, 350);

        for (var i = 0; i < 10; i++) {
            this.__debugText[i] = this.add.text(0, -300 + 20 * i, "", this.__debugStyle);
        }
        this.__debugDisplayArea.add([...this.__debugText]);
        this.__debugDisplayArea.setVisible(true);
    }

    /**
     * デバッグモード(update()から呼び出し)
     */
    private debugModeOn(): void {
        if (this.__debugInterval === 0) {
            this.__debugText[0].setText("cursor.position = (" + this._cursor.currentX + "," + this._cursor.currentY + ")");
            this.__debugDisplayArea.setVisible(true);
            this.__debugInterval = 50;
        } else {
            this.__debugInterval--;
        }
    }

今度は実アプリからそのまま引用してきました。Cursorクラスは#009のものを使ってみてください。

このようにすると、デバッグ画面を表示できます。

f:id:mtmusic34:20210717145123p:plain
デバッグモード

カーソルを動かすと、たまに数字が更新されます。このたまに、というのも重要で、毎回メソッドを呼ばれると負荷が重いので、このサンプルでは__debugIntervalメンバ変数で管理して、updateメソッドが100回呼び出される都度としています。

この真っ暗な画面だけでは、、と思うので、その他のモジュールも組み合わせた、ただいま開発中のSRPGの画面も表示しちゃいます。

f:id:mtmusic34:20210717150710p:plain
開発中の画面

まとめ

今回は、Phaser.Sceneクラスで定義されている各メソッド、とりわけupdate()について詳しく考察しました。

次回は、ユニットを動かす、にチャレンジしますが、まずは、Phaser.GameObject.Tweenクラスを試します。

過去の日記

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