TypeScriptでゲームプログラム14「コードからGame.XXXを消す」

予想できなかった問題発生

これまでのコード整理で、グローバル変数や関数はすべて消え、以下のようにGameMngのインスタンスを生成し、初期化、開始するだけになりました。

const Game = new GameMng();
// ゲーム状態の初期化
Game.InitSituation();
// ゲーム開始
Game.Start();

この中でStart()は、「ゲームを開始する」という意味合いが強いのでこのままでよいと思いますが、InitSituation()は、(各メンバのnewや、CanvasにBallを登録する処理と同様に)GameMngのコンストラクタの中で行ってしまっても良いように感じます。

そこで、以下のように修正しました。

/**
 * ゲーム全体を管理するクラス
 */
class GameMng extends GameLoop {
    /**
     * コンストラクタ
     */
    constructor() {
        super();
        this.FPS = GameParameter.FPS;

        // 部品を登録
        this.AddPart(this.Canvas);
        this.AddPart(this.Message);
        this.AddPart(this.Button1);

        // CANVAS部品を登録
        this._CanvasAddParts();

        // ゲーム状態の初期化
        this.InitSituation();
    }
}

const Game = new GameMng();
// ゲーム開始
Game.Start();

InitSituation()がコールされる場所が移動しただけです。

しかし、これをトランスパイルしてできたjavascriptを使用すると、下記のエラーを出し、正常に動作しません。

index.ts:56 Uncaught ReferenceError: Cannot access 'Game' before initialization
    at Ball.Init (index.ts:56:22)
    at GameMng.InitSituation (index.ts:633:19)
    at new GameMng (index.ts:601:14)
    at index.ts:658:14
    at index.ts:660:14
    at index.ts:660:14

原因

エラーの出ているBall.Initのコードはこうなっています。

class Ball implements IGameCanvasPart {
    /**
     * ゲーム開始時の初期化処理
     */
    public Init() {
        // フレームカウンターをランダム値として使う。
        let random = Game.Random(1, 18);     // 1~18までのランダム値

        // X座標:初期値はフレームカウンタに応じて、GameParameter.Areaの横幅の0.1~0.9の位置にランダムに変化する。
        let random2 = 0.1 * (1 + (random % 9)); // randomから0.1~0.9まで0.1刻みの値を作る。
        this._body.X = GameParameter.Area.Left + GameParameter.Area.Width * random2;
        // Y座標:初期値はGameParameter.Areaの上辺から球の半径だけ下
        this._body.Y = GameParameter.Area.Top + this._body.Radius;
        // X軸速度:初期値はランダム値が奇数なら-5、偶数なら5に変化する。
        this._VX = (random & 1) ? 5 : -5;
        // Y軸速度:初期値は0
        this._VY = 0;
    }

エラーになっているのは、ハイライトされた7行目のGame.Random(1,18)でした。

Game.Random()が実行されるまでの呼び出しを、階層に分けて記載すると下のようになる。

GameMngのコンストラクタ
  +- GameMng.InitSituation
       +- Ball.Init()
            +- Game.Random()

この場合、Game.Random()が実行された時点ではGameMngのコンストラクタの途中です。

const Game = new GameMng(); が完了していないので、グローバル変数Gameもまだ値をセットされていません。それなのにGameを参照しようとしているので、'Game' before initialization が出て言いました。

変更前のコードでは、Gameがセットされた後に Game.InitSituation() をコールしているので、エラー無く動作していた。

根本的な問題

Game.InitSituation()の実行を戻せば、問題は回避できます。しかし、解決ではなく回避です。

この不具合の根本的な原因は、各クラスの中でグローバル変数Gameを直接参照していることです。

つまりGameを別の方法で参照できるようにすれば、解決できます。

必要なクラスにGameMngへの参照を持つ

つまり、以下のようなコードにします。

lass Ball implements IGameCanvasPart {
    /**
     * コンストラクタ
     */
    constructor(private _game: GameMng) {
    }

    public Init() {
        // フレームカウンターをランダム値として使う。
        let random = this._game.Random(1, 18);     // 1~18までのランダム値
    }
}

class GameMng extends GameLoop {
    /**
     * 球
     */
    public Ball = new Ball(this);
}

Ball.Init()時には、グローバル変数のGameではなく、new Ball(this)で渡されたGameMngへの参照_gameを使うことで、この問題を発生しなくなります。

今回問題が起こったのはBall.Init()でしたが、他のクラスでも同様の不具合が発生する可能性はあります。問題がおきたところだけ修正するのは、将来の不具合の温床となるので、他のクラスも同じように更新することにしました。

ボタン1クリックハンドラーの見直し

ボタン1がクリックされたときのハンドリングは下のようなコードになっています。

/**
 * ボタン1(\<button id="button1">)を管理するクラス
 */
class Button1Mng extends ElementMng<HTMLButtonElement> {
    /**
     * コンストラクタ
     */
    constructor(private _game: GameMng) {
        super("button1", HTMLButtonElement);
        this._AsyncElement.then(element => {
            element.addEventListener("click", e => { this._ClickHandler(e) });
        });
    }

    /**
     * ボタンクリックイベントハンドラ
     */
    protected _ClickHandler(e: MouseEvent) {
        switch (this._game.Status) {
            case "game":
                this._game.Status = "stop";
                break;
            case "stop":
                this._game.Status = "game";
                break;
            case "start":
            case "gameover":
                this._game.Status = "game";
                this._game.InitSituation();
                break;
        }
    }
}

ボタン1のクリックによりゲーム状態が、「停止」や「再開」などに変化するのだから、ここ(Button1Mngの中)でStatusが変化しているのは、それほど問題ではありません。

しかし、ここにInitSituation()があるのは少し問題を感じます。ゲームが開始または再開するときに、球やラケットや点数を初期化するのは、球や点数の都合であって、ボタン1が管理することではないはずです。

以上の理由ことから、GameMng.Button1_Clicked()メソッドを作成し、それをコールする形に変更します。

class Button1Mng extends ElementMng<HTMLButtonElement> {
    /**
     * コンストラクタ
     */
    constructor(private _game: GameMng) {
        super("button1", HTMLButtonElement);
        this._AsyncElement.then(element => {
            element.addEventListener("click", e => { this._game.Button1_Clicked() });
        });
    }
}

class GameMng extends GameLoop {
    /**
     * Button1がクリックされたときのイベントハンドラ
     * ButtonMngの中で使用する。
     */
    public Button1_Clicked() {
        // ボタン1がクリックされたことで、ゲームの状態が変化する
        switch (this.Status) {
            case "game":
                // 現在実行中 -> 停止に変化
                this.Status = "stop";
                break;
            case "stop":
                // 現在事項中 -> 実行に変化
                this.Status = "game";
                break;
            case "start":
            case "gameover":
                // 現在開始 or ゲームオーバー -> 球やラケットなどの状態を初期化して、実行に変化
                this.InitSituation();
                this.Status = "game";
                break;
        }
    }
}

ボタン1のUdpate()の見直し

同様に、Button1.Update()にはStatusを参照するコードがあります。

class Button1Mng extends ElementMng<HTMLButtonElement> {
    /**
     * メッセージボックスを更新する。
     */
    public Update() {
        switch (this._game.Status) {
            case "start":
                this._text = "スタート";
                break;
            case "game":
                this._text = "ストップ";
                break;
            case "stop":
                this._text = "スタート";
                break;
            case "gameover":
                this._text = "NEXTゲーム";
                break;
        }
    }
}

これも、GameMngにメソッドを作り、それをコールするべきでしょうか?

そうしても悪くはありませんが、現在のままでも良いと判断しました。

このコードはゲーム状態(Status)に応じて表示するテキストを決定しています。ボタンの動作を決定するコードなので、この場所にあるのは適切でしょう。

コメント