TypeScriptでゲームプログラム4「ラケットに球を跳ね返させる」

球の移動を変更する

ここまでのプログラムでは球はCANVAS内で勝手に跳ね返っているだけの状態です。

そこで、球はラケットに触れた時だけ上に跳ね返り、ラケットがない場所に落ちたときは跳ね返らないように変更します。(CANVAの上・左・右の壁で跳ね返るのは、これまで通り。)これだけでも壁打ちテニスのようなゲームっぽくなるでしょう。

球の状態を更新するUpdateBall()を変更すると同時に、既存コードを少し整理します。

/**
 * 球の状態更新
 */
function UpdateBall() {
    if (GameStatus == "game") {

        // 衝突を考慮しない新座標と速度を計算する。
        let x = Ball.X + Ball.VX;
        let y = Ball.Y + Ball.VY;
        let vx = Ball.VX;
        let vy = Ball.VY + 0.25;

        // ラケットとの衝突処理
        ({ y, vy } = CollisionBallAndRachket(y, x, vy));

        // 左右の壁との衝突処理
        let left = Area.X + Ball.SIZE;
        let right = Area.X + Area.Width - Ball.SIZE;
        if (x < left) {
            x = left + left - x;
            vx = - vx;
        }
        else if (x > right) {
            x = right + right - x;
            vx = - vx;
        }

        // 天井との衝突処理
        let top = Area.Y + Ball.SIZE;
        if (y < top) {
            y = top + top - y;
            vy = - 0.5 * vy;
        }

        // 床との衝突処理
        let bottom = Area.Y + Area.Height - Ball.SIZE;
        if (y > bottom) {
            GameStatus = "gameover";
            ChangeButton1Text("NEXTゲーム");
            ChangeMessage("ゲームオーバー\nボタンを押すと次のゲームを開始します。");
        }

        Ball.X = x;
        Ball.Y = y;
        Ball.VX = vx;
        Ball.VY = vy;
    }
}

壁との衝突処理を行う前に、ラケットとの衝突処理を追加しました。その処理はCollisionBallAndRachket()で行っています。

コードは変わっていますが、左右の壁や上(天井)と衝突したときの処理内容は変わっていません。

そして、下(床)と衝突したときは、跳ね返らず、ゲーム状態(GameStatus)を"gameover"に変えます。ボタンやメッセージに表示するテキストも更新しています。

ゲーム状態の"gameover"は新たに追加したものです。詳細は後述します。

ラケットとの衝突

CollisionBallAndRachket()のコードを以下に示します。

/**
 * 球がラケットに衝突したときの処理
 * @param y 球のX座標
 * @param x 球のY座標
 * @param vy 球のY軸速度
 * @returns 
 */
function CollisionBallAndRachket(y: number, x: number, vy: number) {
    let racket_left = Racket.X - Racket.Width / 2;
    let racket_right = Racket.X + Racket.Width / 2;
    let racket_top = Racket.Y - Racket.Height / 2;

    // 球がラケットに当たるとき、以下の条件を満たす。
    // - 球が下向きに移動している
    // - 球のX座標がラケットの左右の間にある。
    // - 球の最も下にある点座標(y+Ball.SIZE)とラケットの上面の距離が0以下になる
    let ball_bottom = y + Ball.SIZE;
    if (vy > 0 && racket_top <= ball_bottom) {
        if (racket_left <= x && x <= racket_right) {
            // 球がラケット上面に衝突したので、跳ね返す。
            y += (racket_top - ball_bottom);
            vy = -vy;
        }
    }
    return { y, vy };
}

今回の衝突処理は、落下してきた球がラケットの上面に当たったときのみ衝突していると判定し、球を上に跳ね返すように処理しています。

ラケットは長方形なので左右の面に球が当たることもあります。しかし、そのときは上に跳ね返ることがなく床に当たってゲームオーバーになるはずです。頑張って「衝突して跳ね返る処理」を作ってもあまり意味がありません。そのため端折っても問題ないと判断し、上面だけに限定しました。

衝突しているか否かの判定の部分を下図を用いて説明します。

図で黄緑色の四角はラケットを表し、それ以外の3つの円は球を表しています。

ラケットと赤い球のような位置関係になっている時に「衝突している」と判定します。つまり、球の最も底辺(図では黒い点にしている)の位置がラケットの上面よりも低く、また、ラケットの幅の中に納まっている時に衝突とします。

青い球の場合はラケットの上面よりも高い位置に球があるので衝突していません。(球が下方向の移動中ならば、数フレーム後で衝突することになるでしょう。)

緑の玉の場合は、球の底辺(黒い点)がラケットの幅の外にあるので衝突していません。このケースは現実ならラケットの角に当たっていることになるのかもしれません。しかし「角に当たる=ラケットの芯で当たっていないので、球を綺麗に上に向かって跳ね返すことはできない」と見なし、衝突していないことにしました。

跳ね返り処理

衝突して球が跳ね返るなら、球の速度は反転します。さらに、球がラケット上面よりも下に移動する分だけ、本来は上に移動しているはずなので、Y座標も修正する必要があります。

これらの処理を21-22行目で行っている。

ゲーム状態の追加

コード修正が前後しましたが、ゲーム状態として新たに"start"と"gameover"を追加します。

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

document.addEventListener("DOMContentLoaded", () => {
    let button1 = document.getElementById("button1");
    if (button1) {
        button1.addEventListener("click", () => {
            switch (GameStatus) {
                case "game":
                    GameStatus = "stop";
                    ChangeButton1Text("スタート");
                    ChangeMessage("ゲームポーズ中\nボタンを押すと再開します。");
                    break;
                case "stop":
                    GameStatus = "game";
                    ChangeButton1Text("ストップ");
                    ChangeMessage("");
                    break;
                case "start":
                case "gameover":
                    GameStatus = "game";
                    InitGameSituation();
                    ChangeButton1Text("ストップ");
                    ChangeMessage("");
                    break;
            }
        })
    }
});

"start"はゲームの開始時の状態を表し、GameStatusの初期値です。

”gameover"はゲームオーバー指定状態を表します。

これら2つは"stop"とほぼ同じように動作します。"stop"と異なる点は、InitGameSituation()をコールしてゲームの状態を初期化することです。

"start"と"gameover"に違いはありませんが、次の理由から別々に分けている。

  • ゲームスタートとゲームオーバーを同じ状態値で管理するのは良くない。
  • 将来的に異なる処理をするようになる可能性が高い。

初期化処理の見直し

球とラケットの状態(初期座標や速度)を初期化する処理はこれまでは、window.requestAnimationFrame(GameLoop)の直前で行なっていました。

ゲームオーバー後の次のゲームを開始する時も初期化処理が必要なので、関数化しました。

/**
 * ゲーム状態を初期化する。
 */
function InitGameSituation() {
    // フレームカウンターをランダム値として使う。
    let random = FrameCounter ?? 0;

    // ボールの座標、速度を初期化する。
    // X座標:初期値はフレームカウンタに応じてランダムに変化
    Ball.X = Area.X + Area.Width * 0.1 * (1 + (random % 9));
    // Y座標:初期値はAreaの上辺から球のSIZEだけ下
    Ball.Y = Area.Y + Ball.SIZE;
    // X軸速度:初期値はフレームカウンタが奇数なら-5、偶数なら5に変化する。
    Ball.VX = (random & 1) ? 5 : -5;
    // Y軸速度:初期値は0
    Ball.VY = 0;

    // ラケットの座標を初期化する。
    // X座標:初期値はAreaの横幅の真ん中
    Racket.X = Area.X + Area.Width / 2;
    // Y座標:初期値はAreaの下辺からラケットの縦幅の半分だけ上
    Racket.Y = Area.Y + Area.Height - Racket.Height / 2
}


InitGameSituation();
window.requestAnimationFrame(GameLoop);

これまでは球の位置や速度はずっと同じでしたが、それではゲームをやりなおしても毎回同じことを繰り返すばかりです。そこで球のX座標と、移動する向きをランダムに変化するように変更しました。

ランダム値としてはフレームカウンターを使っています。この値はゲーム開始後に一定時間ごとにカウントアップしており、プレイヤーがいつボタンを押してゲームを開始するのかによってランダムに値が変化します。少なくともプレイヤーが人間である限り、毎回同じ位置・速度で球が移動し始めることはないはずです。

もちろん、ランダム値としてMath.random()を使っても良いです。

実際の例

今回のコードにより、「ラケットで球を跳ね返すだけ」というとても単純なものではあるがゲームらしくなったはずです。

これまでの例だと球の移動がゆっくりゲームとしては簡単すぎたので、今回の例からFPSを8から20に上げています。

ボタンを押すとゲームを開始します。

コメント