TypeScriptでゲームプログラム10「ゲームループ処理をクラス化する」

ゲームループの管理クラス

「多くのゲームでは、一定時間ごとにゲームの状況が更新され、それをCANVASに再描画することを繰り返している。」という話はプログラムを作り始める一番最初に書きました。

そしてこれまで、window.requestAnimationFrame()とGameLoop()を使って状態更新とCANVASへの再描画を行うためのプログラムを作ってきました。

今回はこのゲームループをクラス化します。最終的には、

// ゲームループ管理
let gameLoop = new GameLoop();
// FPSをセット
gameLoop.FPS = 20;

/// その他の初期処理(球やラケットやメッセージボックスのインスタンスを作る)///

// ゲームループスタート
gameLoop.start();

のようなコードで、ゲームが始まるようにしたいと考えています。

最初のひな型として次のようになります。

/**
 * ゲームループの管理クラス
 */
class GameLoop {
    /**
     * 1秒毎のゲームフレーム数 Frame Per Second
     */
    public set FPS(value: number) {
        if (value > 0) {
            this._FPS = value;
        }
        else {
            console.log("FPSは0より大きな数値にしてください。");
        }
    }
    public get FPS() { return this._FPS; }
    /**
     * 1秒毎のゲームフレーム数 Frame Per Second
     */
    private _FPS: number;

    /**
     * コンストラクタ
     */
    constructor() {
        this._FPS = 30;
    }
}

Start()とループ処理

変更前のGameLoop()関数とほぼ処理を行う_Loop()メソッドを追加します。そして、Start()の中で、_Loop()をwindow.requestAnimationFrame()に引き渡します。

ただし、クラスメソッドをrequestAnimationFrame()の引数として直接渡すことができないので、アロー関数を仲介しています。

class GameLoop {
    /**
     * ゲームループ処理開始
     */
    public Start() {
        window.requestAnimationFrame(timestamp => this._Loop(timestamp));
    }

    /**
     * フレームカウンタ(ゲーム内の時刻1カウンタ)
     * GameLoop()が最初にコールされたときを0とし、
     * 1/FPS秒を1フレームとしてカウントする。
     */
    public get Frame() { return this._FrameCounter; }
    /**
     * フレームカウンタ(ゲーム内の時刻1カウンタ)
     * GameLoop()が最初にコールされたときを0とし、
     * 1/FPS秒を1フレームとしてカウントする。
     */
    private _FrameCounter: number | undefined;

    /**
     * _Loop()が最初にコールされたときのtimestampを保持する
     */
    private _LoopStartTime = 0;

    /**
     * ゲームループ処理本体
     * window.requestAnimationFrame()のコールバック
     * @param timestamp window.requestAnimationFrame()から渡されるタイムスタンプ
     */
    private _Loop(timestamp: number) {
        // 初めてコールされたときに、_LoopStartTimeと_FrameCounterを初期化
        if (this._FrameCounter === undefined) {
            this._FrameCounter = 0;
            this._LoopStartTime = timestamp;
        }

        // ゲームループ開始時と現在のtimestampの差から、フレーム数を計算
        let frame = Math.floor((timestamp - this._LoopStartTime) * FPS / 1000);
        if (frame > this._FrameCounter) {
            // フレームカウンタを更新
            this._FrameCounter = frame;

            // フレーム数が更新されたので、ゲームの状態を更新する。
            this._Update();

            // フレーム数が更新されたので、ゲームの状態を表示する。
            this._Show();
        }

        // ゲームの状況更新、再描画のために、requestAnimationFrame()に再度実行する。
        window.requestAnimationFrame(timestamp => this._Loop(timestamp));
    }

    /**
     * ゲームの状態を更新する。
     */
    private _Update() {
        // ゲームの状況を更新する。
        UpdateGameSituation();
        // メッセージボックスを更新する。
        Message.Update();
        // ボタン1を更新する。
        Button1.Update();
    }

    /**
     * ゲームの状態を表示する。
     */
    private _Show() {
        // 描画を行う。
        DrawGameCanvas();
        // メッセージボックスを表示する。
        Message.Show();
        // ボタン1を表示する。
        Button1.Show();
    }
}

その他にも変更点はあります。

変更前は、フレームカウンタ―や最初にコールされたときの時間を保持するために変数FrameCounterとGameLoopStartTimeを使っていましたが、同等のクラスメンバに置き換えてました。これらは他の場所で誤って変更されるのは良くないので、クラス化したことでより安全なコードになりました。

また、「ゲーム状態を更新」と「ゲーム状態を表示」をそれぞれメソッド化しました。

関係ない処理を追い出す

しかし、Message.Update()やMessage.Show()などがGameLoopクラスの中で直接コールされているのは、コードの管理上は良くありません。何か新しいゲーム要素が増えるたびにGameLoopを更新し続けなければならないからです。

このような処理をクラスのコードから追い出すには、おおむね2つの方法が考えられます。

  1. イベントを発行し、イベントハンドラ(イベントリスナ)でMessage.Update()やMessage.Show()を実行する。
  2. Update()とShow()をメソッドに持つオブジェクトをGameLoopに登録し、GameLoop._Update()と_Show()で実行する。

ゲーム部品のインターフェースを追加

今回は2番目の方法を採用することにしました。

GameLoopクラスの中で_Update()や_Show()を行うオブジェクトを「ゲーム部品」と定義します。それを表すInterfaceを以下に示します。

/**
 * ゲームの部品を表すインターフェイス
 */
interface IGamePart {
    /**
     * ゲームフレームが更新されたときに、部品の状態を更新するメソッド
     */
    Update(): void;

    /**
     * Updateの後で、部品の状態をプレイヤーに見せるための表示するメソッド
     */
    Show(): void;
}

MessageMngもButton1MngもすでにUpdate()/Show()メソッドを持っているので、IGamePartの実装にすることは簡単です。

class MessageMng implements IGamePart {
  // 中身は変更なし
}
class Button1Mng implements IGamePart {
  // 中身は変更なし。
}

そしてGameLoopがIGamePartを保持し、Update()やShow()を実行するように変更します。

class GameLoop {

    /**
     * ゲーム部品のリスト
     */
    private _partlist: IGamePart[] = [];

    /**
     * ゲーム部品を登録する
     * @param part ゲーム部品
     */
    public AddPart(part: IGamePart) {
        this._partlist.push(part);
    }

    /**
     * ゲームの状態を更新する。
     */
    private _Update() {
        // ゲーム部品を更新する。
        this._partlist.forEach(part=>part.Update());
    }

    /**
     * ゲームの状態を表示する。
     */
    private _Show() {
        // ゲーム部品を表示する。
        this._partlist.forEach(part=>part.Show());
    }
}

最後に、gameLoop.Start()する前にMessageとButton1を登録します。UpdateGameSItuation()とDrawGameCanvasは、とりあえず無名クラスを使ってみました。

let gameLoop = new GameLoop();
gameLoop.FPS = 20;
let tmp = new class implements IGamePart {
    Update(): void {
        // ゲームの状況を更新する。
        UpdateGameSituation();
    }
    Show(): void {
        // 描画を行う。
        DrawGameCanvas();
    }
}();
gameLoop.AddPart(tmp);
gameLoop.AddPart(Message);
gameLoop.AddPart(Button1);
gameLoop.Start();

コメント