ブロックくずしを作る2「キャンバスの管理クラス」

CANVASの管理クラスを追加

まずはCANVASを管理するためのクラスを追加しましょう。関連するTypeScriptのコードは「TypeScriptでゲームプログラム」から少し変更を加えいます。

ElementControlクラス

/**
 * \<div id="xxxx">や\<button id="yyyy">のような、
 * id付のHTML要素を管理するためのクラスのテンプレート
 */
export abstract class ElementControl<T extends HTMLElement> {
    /**
     * 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, reject) => {
            document.addEventListener("DOMContentLoaded", () => {
                let element = document.getElementById(id);
                if (element instanceof typecheck) {
                    this._element = element;
                    resolve(element);
                }
                else {
                    let typeName = this._getTypeName(typecheck);
                    reject(new ReferenceError(`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 "";
        }
    }
}

ElementControlは「TypeScriptでゲームプログラム」のElementMngクラスから、”implements IGamePart"分を取り除いたものになっています。このクラスはゲーム部品としてだけでなく、もっと汎用的に使うことができそうだったので、IGamePartを取り除きました。

また、idに一致するHTML要素が見つからなかったときは、Promiseのrejectでエラーイベントを返すようにしています。

GameCanvasクラス

import { ElementControl } from "../html/ElementControl"
import { IGameCanvasPart } from "./GameCanvasPart";
import { IGamePart } from './GameLoop';

/**
 * ゲームで使用する\<canvas>を管理するためのクラス
 */
export class GameCanvas extends ElementControl<HTMLCanvasElement> implements IGamePart {
    /**
     * マウスの情報
     */
    public get Mouse() {
        return {
            /**
             * CANVAS上のX座標
             */
            X: this._mouseCanvasX,
            /**
             * CANVAS上のY座標
             */
            Y: this._mouseCanvasY,
        }
    }
    /**
     * マウスのCANVAS上のX座標
     */
    private _mouseCanvasX = 0;
    /**
     * マウスのCANVAS上のY座標
     */
    private _mouseCanvasY = 0;

    /**
     * コンストラクタ
     * @param id id名
     */
    constructor(id: string) {
        super(id, 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部品のリスト
     */
    protected _partlist: IGameCanvasPart[] = [];

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

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

    /**
     * CANVASの部品を描画する。
     */
    Show(): void {
        if (this._element) {
            let context = this._element.getContext("2d");
            if (context && this.Width > 0 && this.Height > 0) {
                // 画面のクリア
                context.clearRect(0, 0, this.Width, this.Height);
                // ゲーム部品を表示する。
                this._partlist.forEach(part => part.DrawCanvas(context!));
            }
        }
    }

    /**
     * CANVASの幅
     */
    public get Width() {
        return this._element?.width ?? 0;
    }

    /**
     * CANVASの高さ
     */
    public get Height() {
        return this._element?.height ?? 0;
    }
}

GameCanvasは「TypeScriptでゲームプログラム」のGameCanvasMngクラスとほぼ同じです。

継承の親クラスがElementControlに変わり、IGamePartが含まれなくなったので、implementsしています。

またconstructorの引数としてid名を指定するように変えました。これでもしキャンバスを複数使うゲームを作りたいなら、idを変えてnewすればよいだけになります。

この部分を変えたのは、仮にキャンバスを複数取り扱うようになったとしてもクラスの内部コードはまったく同じになることが予想されたからです。

たとえば、球やラケットを描くキャンバス1と、得点や残機を描くキャンバス2を用意し、それぞれにGameCanvas1クラスとGameCanvas2クラスを作ったとします。この場合でもそれぞれのクラスの中身はほとんど違いがありません。球やラケット、得点、残機を管理するIGameCanvasPartの実装クラスのコードを変えることで違いが生じるようにプログラムを構成しているからです。

id名以外にキャンバスのコードが変わることないなら、それぞれにクラスを作るより、今回のGameCanvasクラスを作るほうが無駄がありません。

GameCanvasPartクラス

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

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

/**
 * IGameCanvasPartの汎用的な実装クラス
 */
export class GameCanvasPart implements IGameCanvasPart {
    /**
     * Update()が実行されるタイミングで発生するイベントハンドラ
     */
    public onUpdate: (() => void) | undefined;

    Update(): void {
        if (this.onUpdate) {
            this.onUpdate();
        }
    }

    /**
     * DrawCanvas()が実行されるタイミングで発生するイベントハンドラ
     */
    public onDrawCanvas: ((context: CanvasRenderingContext2D) => void) | undefined;
    
    DrawCanvas(context: CanvasRenderingContext2D): void {
        if (this.onDrawCanvas) {
            this.onDrawCanvas(context);
        }
    }
}

IGameCanvasPartインターフェイスは「TypeScriptでゲームプログラム」とまったく同じです。

そして、その汎用的な実装クラスとして、GameCanvasPartを追加しました。

TypeScriptでゲームプログラム」では、CANVASの背景を描画するために、以下のような無名クラスを作っていました。

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

GameCanvasPartを使えば、このような無名クラスを作らなくても済みます。以下にコード例を示します。

        let background = new GameCanvasPart();
        background.onDrawCanvas = (context => {
            // 実際の描画処理
            // 背景を黒に
            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);

ここまでをテストする

簡単なコードを書いて、ここまでのクラスが機能しているかちょっとテストしてみます。

import { GameLoop } from './game/GameLoop';
import { GameCanvas } from './game/GameCanvas';
import { GameCanvasPart } from './game/GameCanvasPart';

export class GameMng extends GameLoop {
    /**
     * id="gameCanvas"のCANVAS
     */
    public Canvas = new GameCanvas("gameCanvas");

    /**
     * コンストラクタ
     */
    constructor() {
        super();
        this.AddPart(this.Canvas);

        let tmp = new GameCanvasPart();
        let second = 0;
        tmp.onUpdate = () => {
            second = (this.Frame ?? 0) / this.FPS;
        };
        tmp.onDrawCanvas = context => {
            let w = this.Canvas.Width;
            let h = this.Canvas.Height;
            let r = w / 3;
            for (let i = 0; i <= 240; i += 30) {
                let x = w / 2 + r * Math.cos((second * 360 + i ) / 180 * Math.PI);
                let y = h / 2 + r * Math.sin((second * 360 + i ) / 180 * Math.PI);
                context.fillStyle = `rgb(${i+15},${i+15},0)`;
                context.beginPath();
                context.arc(x, y, 10, 0, Math.PI * 2);
                context.closePath();
                context.fill();
            }
        };
        this.Canvas.AddPart(tmp);
    }
}

これはテストコードなので、let tmp = new GameCanvasPart()以降のコードは将来的には消します。

このコードでは、Frrame数を秒単位に変換してsecondに格納し、secondの値に応じて黄色い円がぐるぐると回るようなアニメーションを行っています。コードの実行例を下に示します。

SCORE:10000
メッセージ

コメント