TypeScriptでゲームプログラム15「バージョン2完成:全ソースコード」

ひとまず完成

まだまだ整理したいところがあるけれど、終わりが見えてきません。前回までのコード整理でひとまずの完成とします。実際の実行例を以下に示します。

全コード

ここまで作ってきた全コードを以下に示します。

/**
 * 四角形を表すクラス
 */
class Rect {
    /**
     * 四角形の中心のX座標
     */
    public X: number;
    /**
     * 四角形の中心のY座標
     */
    public Y: number;
    /**
     * 四角形の横幅
     */
    public Width: number;
    /**
     * 四角形の高さ
     */
    public Height: number;

    /**
     * コンストラクタ
     * 点(X1,Y1)と点(X2,Y2)を対角線上の頂点とする四角形を作る。
     * @param x1 1つ目の頂点のX座標
     * @param y1 1つ目の頂点のY座標
     * @param x2 2つ目の頂点のX座標
     * @param y2 2つ目の頂点のY座標
     */
    constructor(x1: number, y1: number, x2: number, y2: number) {
        this.X = (x1 + x2) / 2;
        this.Y = (y1 + y2) / 2;
        this.Width = Math.abs(x2 - x1);
        this.Height = Math.abs(y2 - y1);
    }

    /**
     * 四角形の左辺のX座標
     */
    public get Left() { return this.X - this.Width / 2; }

    /**
     * 四角形の右辺のX座標
     */
    public get Right() { return this.X + this.Width / 2; }

    /**
     * 四角形の上辺のY座標
     */
    public get Top() { return this.Y - this.Height / 2; }

    /**
     * 四角形の下辺のY座標
     */
    public get Bottom() { return this.Y + this.Height / 2; }
}

/**
 * 円を表すクラス
 */
class Circle {
    /**
    * 円の中心のX座標
    */
    public X: number;
    /**
     * 円の中心のY座標
     */
    public Y: number;
    /**
     * 円の半径
     */
    public Radius: number;

    /**
     * コンストラクタ
     * 点(x,y)を中心とする半径Rの円を作る。
     * @param x 中心のX座標
     * @param y 中心のY座標
     * @param r 中心の半径
     */
    constructor(x: number, y: number, r: number) {
        this.X = x;
        this.Y = y;
        this.Radius = Math.abs(r);
    }

    /**
     * 円の最も元も左のX座標
     */
    public get Left() { return this.X - this.Radius; }

    /**
     * 円の最も右のX座標
     */
    public get Right() { return this.X + this.Radius; }

    /**
     * 円の最も上のY座標
     */
    public get Top() { return this.Y - this.Radius; }

    /**
     * 円の最も下のY座標
     */
    public get Bottom() { return this.Y + this.Radius; }
}

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

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

/**
 * ゲームループとIGamePartのオブジェクトを管理するクラス
 */
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;

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

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

    /**
     * ゲームループ処理開始
     */
    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) * this._FPS / 1000);
        if (frame > this._FrameCounter) {
            // フレームカウンタを更新
            this._FrameCounter = frame;

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

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

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

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

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

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

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

    /**
     * Updateの後で、部品をCANVASに描画するメソッド
     * @param context 描画するCANVASのCanvasRenderingContext2D
     */
    DrawCanvas(context: CanvasRenderingContext2D): void;
}

/**
 * ゲームパラメーター
 */
class GameParameter {
    /**
     * FPS(Frame per second)
     */
    static readonly FPS = 20;

    /**
     * 球の移動計算で使う、重力加速度
     */
    static readonly gravity = 0.25;

    /**
     * 球が移動できる範囲
     */
    static readonly Area = new Rect(10, 10, 310, 310);
};

/**
 * 球を表すクラス
 */
class Ball implements IGameCanvasPart {
    /**
     * 球本体の円
     */
    private _body: Circle;

    /**
     * X座標の速度
     */
    private _VX = 0;

    /**
     * Y座標の速度
     */
    private _VY = 0;

    /**
     * コンストラクタ
     */
    constructor(private _game: GameMng) {
        // 球のサイズ10で宣言
        this._body = new Circle(0, 0, 10);
    }

    /**
     * ゲーム開始時の初期化処理
     */
    public Init() {
        // フレームカウンターをランダム値として使う。
        let random = this._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;
    }

    /**
     * 状態更新
     */
    public Update() {
        if (this._game.Status == "game") {
            let N = 10;
            for (let i = 0; i < N; i++) {

                // 速度を更新する。(下向きに加速する)
                this._VY += GameParameter.gravity / N;

                // 衝突を考慮せずに、座標と速度を更新する。
                this._body.X += this._VX / N;
                this._body.Y += this._VY / N;

                // ラケットとの衝突処理
                this._CollisionRachket();

                // 左右の壁との衝突処理
                if (this._VX < 0 && this._body.Left < GameParameter.Area.Left) {
                    // X座標速度を反転する。
                    this._VX = -this._VX;
                    // 得点を加算
                    this._game.Score++;
                }
                else if (this._VX > 0 && this._body.Right > GameParameter.Area.Right) {
                    // X座標速度を反転する。
                    this._VX = -this._VX;
                    // 得点を加算
                    this._game.Score++;
                }

                // 天井との衝突処理
                if (this._VY < 0 && this._body.Top < GameParameter.Area.Top) {
                    // Y座標速度を半分にして反転する。
                    this._VY *= -0.5;
                    // 得点を加算
                    this._game.Score += 3;
                }

                // 床との衝突処理
                if (this._body.Bottom > GameParameter.Area.Bottom) {
                    this._game.Status = "gameover";
                }
            }
        }
    }

    /**
     * ラケットとの衝突処理
     */
    private _CollisionRachket() {
        let racket_rect = this._game.Racket.Rect;

        // 球がラケットに当たるとき、以下の条件を満たす。
        // - 球が下向きに移動している
        // - 球の最も下(_body.Bottom)がラケットの上面(racket_rect.Top)より下になる
        // - 球のX座標がラケットの左右の間にある。
        if (this._VY > 0 && racket_rect.Top < this._body.Bottom) {
            if (racket_rect.Left <= this._body.X && this._body.X <= racket_rect.Right) {
                // ラケットの幅を5つに分割し、球が当たった位置に応じて跳ね返り方を変える。
                // 1.ラケットの左側10%の範囲に当たったとき
                //   Y軸の速度を0.8倍にして反転する。
                //   X軸の速度を左向きに5~10にする。
                // 2.ラケットの右側10%の範囲に当たったとき
                //   Y軸の速度を0.8倍にして反転する。
                //   X軸の速度を右向きに5~10にする。
                // 3.ラケットの中央40%の範囲に当たったとき
                //   Y軸の速度を1.2倍にして反転する。
                //   X軸の速度が5を超えるときは、1つ減らす。
                // 4.上記以外
                //   Y軸の速度を0.9倍にして反転する。
                let dx = (this._body.X - racket_rect.Left) / racket_rect.Width;
                if (dx < 0.1) {                     // 左側10%
                    this._VY *= -0.8;
                    this._VX = this._game.Random(-10, -5);     // 左向きに5~10
                }
                else if (0.9 < dx) {                // 右側10%
                    this._VY *= -0.8;
                    this._VX = this._game.Random(5, 10);       // 右向きに5~10
                }
                else if (0.3 <= dx && dx <= 0.7) {  // 中央40%
                    this._VY *= -1.2;
                    if (this._VX > 5) { this._VX--; }
                    else if (this._VX < -5) { this._VX++; }
                }
                else {
                    this._VY *= -0.9;
                }
            }
        }
    }

    /**
     * CANVASへ描画する
     * @param context 
     */
    public DrawCanvas(context: CanvasRenderingContext2D) {
        context.fillStyle = "yellow";
        context.beginPath();
        context.arc(this._body.X, this._body.Y, this._body.Radius, 0, 2 * Math.PI);
        context.closePath();
        context.fill();
    }
}

/**
 * ラケットを表すクラス
 */
class Racket implements IGameCanvasPart {
    /**
     * ラケット本体の四角
     */
    private _body: Rect;

    /**
     * ラケット本体の四角
     */
    public get Rect() { return this._body; }

    /**
     * コンストラクタ
     */
    constructor(private _game: GameMng) {
        this._body = new Rect(0, 0, 40, 10);
    }

    /**
     * ゲーム開始時の初期化処理
     */
    public Init() {
        // X座標:初期値はGameParameter.Areaの横幅の真ん中
        this._body.X = GameParameter.Area.X;
        // Y座標:初期値はGameParameter.Areaの下辺からラケットの縦幅の半分だけ上
        this._body.Y = GameParameter.Area.Bottom - this._body.Height / 2
        // 幅は40
        this._body.Width = 40;
    }

    /**
     * 状態更新
     */
    public Update() {
        let left = GameParameter.Area.Left + this._body.Width / 2;
        let right = GameParameter.Area.Right - this._body.Width / 2;

        // ラケットの移動先と現在値との距離
        let distance = this._game.Canvas.Mouse.X - this._body.X;
        // 距離に応じて、X軸の変化速度を変更する。
        let vx = distance / 5;

        // ラケットを移動する。
        let x = this._body.X + vx;
        if (x < left) {
            x = left;
        }
        else if (x > right) {
            x = right;
        }
        this._body.X = x;

        // ラケットの幅をScoreに応じて短くする。
        let width = 40 - 2 * Math.floor(this._game.Score / 20);
        if (width < 10) {
            width = 10;
        }
        this._body.Width = width;
    }

    /**
     * CANVASへ描画する
     * @param context 
     */
    public DrawCanvas(context: CanvasRenderingContext2D) {
        context.fillStyle = "yellowgreen";
        context.fillRect(this._body.Left, this._body.Top, this._body.Width, this._body.Height);
    }
}

/**
 * \<div id="xxxx">や\<button id="yyyy">のような、
 * id付のHTML要素を管理するためのクラスのテンプレート
 */
abstract class ElementMng<T extends HTMLElement> implements IGamePart {
    /**
     * HTML要素のID名
     */
    protected _id;
    /**
     * _idに対するHTML要素
     */
    protected _element: T | undefined;

    /**
     * _elementを使った処理を必須のときに、
     * _elementがセットされるまで待つためのPromise。
     * 次のように使う
     * 
     * this._AsyncElement.then(element => { elementを使った処理})
     */
    protected _AsyncElement: Promise<T>;

    /**
     * コンストラクタ
     * 
     * 本クラスは継承して使うことを前提としているので、継承時の注意
     * 
     * 継承で、\<div id="SCORE">に対する管理クラスを作る時には、
     * そのコンストラクタ内で
     * 
     * super("SCORE",HTMLDivElement);
     * 
     * と記載すること。
     * @param id ID名
     * @param typecheck 型チェックするためにHTMLElementの派生クラスを指定する
     */
    constructor(id: string, typecheck: new (...args: any) => T) {
        this._id = id;
        this._AsyncElement = new Promise<T>(resolve => {
            document.addEventListener("DOMContentLoaded", () => {
                let element = document.getElementById(id);
                if (element instanceof typecheck) {
                    this._element = element;
                    resolve(element);
                }
                else {
                    let typeName = this._getTypeName(typecheck);
                    console.log(`id="${id}"の${typeName}要素がありません。`)
                }
            });
        });
    }

    /**
     * typecheckに対応するクラス名が HTML*Element に一致するときに、*の部分の文字列を返す。
     * @param typecheck HTMLElementの派生クラスのコンストラクタ
     * @returns 文字列
     */
    private _getTypeName(typecheck: new (...args: any) => T) {
        let match = typecheck.toString().match(/HTML(.*)Element/);
        if (match) {
            return match[1];
        }
        else {
            return "";
        }
    }

    public abstract Update(): void;
    public abstract Show(): void;
}

/**
 * ゲームCANVAS(\<canvas id="game">)を管理するクラス
 */
class GameCanvasMng extends ElementMng<HTMLCanvasElement> {
    /**
     * マウスの情報
     */
    public get Mouse() {
        return {
            /**
             * CANVAS上のX座標
             */
            X: this._mouseCanvasX,
            /**
             * CANVAS上のY座標
             */
            Y: this._mouseCanvasY,
        }
    }
    /**
     * マウスのCANVAS上のX座標
     */
    private _mouseCanvasX = 0;
    /**
     * マウスのCANVAS上のY座標
     */
    private _mouseCanvasY = 0;

    /**
     * コンストラクタ
     */
    constructor() {
        super("game", HTMLCanvasElement);
        this._AsyncElement.then(element => {
            element.addEventListener("mousemove", (ev) => {
                let p = this._MouseToCanvas(ev);
                this._mouseCanvasX = p.X;
                this._mouseCanvasY = p.Y;
            });
        });
    }

    /**
     * マウス座標をevから取得し、CANVAS座標に変換する
     * @param ev 
     * @returns CANVAS座標
     */
    private _MouseToCanvas(ev: MouseEvent) {
        // このメソッドは_elementに対するマウスイベント発生時にコールされるので、
        // _elementは必ず存在する。
        let canvas = this._element!;
        let style = window.getComputedStyle(canvas);
        let paddingLeft = Number(style.paddingLeft.replace("px", ""));
        let paddingTop = Number(style.paddingTop.replace("px", ""));
        let paddingRight = Number(style.paddingRight.replace("px", ""));
        let paddingBottom = Number(style.paddingBottom.replace("px", ""));
        let styleWidth = canvas.clientWidth - paddingLeft - paddingRight;
        let styleHeight = canvas.clientHeight - paddingTop - paddingBottom;
        let x = (ev.offsetX - paddingLeft) * canvas.width / styleWidth;
        let y = (ev.offsetY - paddingTop) * canvas.height / styleHeight;
        return { X: x, Y: y }
    }

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

    /**
     * CANVASの部品を描画する。
     */
    public Show() {
        if (this._element) {
            let width = this._element.width;
            let height = this._element.height;
            let context = this._element.getContext("2d");
            if (context && width > 0 && height > 0) {
                let _context = context;
                // ゲーム部品を表示する。
                this._partlist.forEach(part => part.DrawCanvas(_context));
            }
        }
    }

    /**
     * CANVAS部品のリスト
     */
    protected _partlist: IGameCanvasPart[] = [];

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

/**
 * メッセージボックス(\<div id="message">)を管理するクラス
 */
class MessageMng extends ElementMng<HTMLDivElement> {
    /**
     * innerTextに表示するテキスト
     */
    private _text = "";

    /**
     * コンストラクタ
     */
    constructor(private _game: GameMng) {
        super("message", HTMLDivElement);
    }

    /**
     * メッセージボックスを更新する。
     */
    public Update() {
        switch (this._game.Status) {
            case "start":
                this._text = "ボタンを押すとゲームを開始します。";
                break;
            case "game":
                this._text = "スコア:" + this._game.Score;
                break;
            case "stop":
                this._text = "ゲームポーズ中\nボタンを押すと再開します。";
                break;
            case "gameover":
                this._text = "ゲームオーバー\n" +
                    "あなたのスコアは" + this._game.Score + "でした。\n" +
                    "ボタンを押すと次のゲームを開始します。";
                break;
        }
    }

    /**
     * メッセージボックスを表示する。
     */
    public Show() {
        if (this._element) {
            this._element.innerText = this._text;
        }
    }
}

/**
 * ボタン1(\<button id="button1">)を管理するクラス
 */
class Button1Mng extends ElementMng<HTMLButtonElement> {
    /**
     * innerTextに表示するテキスト
     */
    private _text = "";

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

    /**
     * メッセージボックスを更新する。
     */
    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;
        }
    }

    /**
     * メッセージボックスを表示する。
     */
    public Show() {
        if (this._element) {
            this._element.innerText = this._text;
        }
    }
}

/**
 * ゲームの状況を表すtype
 * start - ゲーム開始
 * stop - ゲーム停止中
 * game - ゲーム実行中
 * gameover - ゲームオーバー
 */
type GameStatus_t = 'start' | 'stop' | 'game' | 'gameover';

/**
 * ゲーム全体を管理するクラス
 */
class GameMng extends GameLoop {
    /**
     * ゲームの状況
     */
    public Status: GameStatus_t = "start";

    /**
     * ゲームのスコア(点数)
     */
    public Score = 0;

    /**
     * 球
     */
    public Ball = new Ball(this);

    /**
     * ラケット
     */
    public Racket = new Racket(this);

    /**
     * id="game"のCANVAS
     */
    public Canvas = new GameCanvasMng();

    /**
     * id="button1"のBUTTON
     */
    public Button1 = new Button1Mng(this);

    /**
     * id="messgae"のDIV
     */
    public Message = new MessageMng(this);

    /**
     * コンストラクタ
     */
    constructor() {
        super();
        this.FPS = GameParameter.FPS;

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

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

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

    /**
     * Canvas部品を登録する
     */
    private _CanvasAddParts() {
        let background = new class implements IGameCanvasPart {
            Update(): void { }
            DrawCanvas(context: CanvasRenderingContext2D): void {
                // 実際の描画処理
                // 背景を黒に
                context.fillStyle = "black";
                context.fillRect(0, 0, context.canvas.width, context.canvas.height);

                // GameParameter.Areaの範囲を白で描画
                context.strokeStyle = "white";
                let area = GameParameter.Area;
                context.strokeRect(area.Left, area.Top, area.Width, area.Height);

            }
        };
        this.Canvas.AddPart(background);
        this.Canvas.AddPart(this.Racket);
        this.Canvas.AddPart(this.Ball);
    }

    /**
     * ゲームの状態を初期化する
     */
    public InitSituation() {
        // ボールの座標、速度を初期化する。
        this.Ball.Init();

        // ラケットの座標と幅を初期化する。
        this.Racket.Init();

        // ゲームスコアのクリア
        this.Score = 0;
    }

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

    /**
     * Frameの値を使って、minとmaxの間の整数乱数値を得る。
     * @param min 
     * @param max 
     * @returns 
     */
    public Random(min: number, max: number) {
        let frame = this.Frame ?? 0;
        if (max < min) {
            console.log('error: max:{max} min:{min}')
        }
        let diff = max - min + 1;
        return (frame % diff) + min;
    }
}

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

コメント