TypeScriptでゲームプログラム5「複雑な要素を追加する」

追加要素

落ちてくる球をずっと打ち返すだけではやや物足りません。そこで次の要素を足してみましょう。

  1. 得点
  2. 難易度調整

得点

ゲームには「繰り返して遊ぶことによって、ゲームの操作やルールに慣れ、上達していると感じられる」ことが重要です。得点(スコア)は上達を実感する目安の1つでしょう。

得点のつけ方はおいおい考えるとして、まずはプログラム的に得点を保持し、表示する部分を作ります。

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

/**
 * ゲームの状況を更新する
 */
function UpdateGameSituation() {
    UpdateRacket();
    UpdateBall();
    if (GameStatus == "game") {
        ChangeMessage(
            "スコア:" + Score
        );
    }
}

得点は変数Scoreに保持します。そして別の部分で0に初期化したり、加算されたりします。(該当コードは後で示します。)

Scoreの表示は簡易的にメッセージへの出力としました。(もちろん、CANVASにScoreを表示するよりも、こちらの方が簡単だったから、そうしたのです。)

その表示は、UpdateGameSituation()の中で行っています。ただし、表示はゲーム状態が"game"のときのみにします。それ以外のときは、ゲームが停止していたり、ゲームオーバーになっていたりしていて、別のメッセージがでています。UpdateGameSituation()でScoreを表示してしまうと、適切なメッセージが出なくなってしまいます。

ゲームを複雑にする

このゲームプログラムは、球をラケットで打ち返し、跳ね返ってきた球を再びラケットで打ち返します。それをずっと続けて球を撃ち返せなかったときにゲームオーバーになります。つまり、「ラケットで球を打ち返せる」か「空振りする」かの2択しかなく、単純です。

しかし、現実のテニスや卓球ならば「球を打ち返せたが、威力が足りない」とか「見当違いの方向に打ち返した」のような状況もあるはずです。それを再現してみましょう。

そこで、ラケットに弾が当たった位置に応じて、球の跳ね返り方を変えることにします。

/**
 * 球がラケットに衝突したときの処理
 * @param x 球のY座標
 * @param y 球のX座標
 * @param vx 球のX軸速度
 * @param vy 球のY軸速度
 * @returns 
 */
function CollisionBallAndRachket(x: number, y: number, vx: 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);
            // ラケットの幅を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 = (x - racket_left) / Racket.Width;
            if (dx < 0.1) {
                vy = -0.8 * vy;
                vx = Random(-10, -5);
            }
            else if (0.9 < dx) {
                vy = -0.8 * vy;
                vx = Random(5, 10);
            }
            else if (0.3 <= dx && dx <= 0.7) {
                vy = -1.2 * vy;
                if (vx > 5) { vx--; }
                else if (vx < 5) { vx++; }
            }
            else {
                vy = -0.9 * vy;
            }
        }
    }
    return { y, vx, vy };
}

球がラケットの中央に当たるとジャストミートして、コントロールよく、そして、力強く打ち返すことができるだ。そして中央から外れる程、力が載りにくくなり、コントロールが悪くなっていくだろう…。

そのようなイメージで今回の跳ね返り処理を作成しました。詳細はコード中のコメントに書いてある通りである。

Random(-10,-5)は球の状態の初期化処理で行ったように、FrameCounterを用いて速度を-10から-5の範囲でランダム値を返します。以下にコードを示します。

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

得点の加算

ラケットで球が跳ね返る処理を変更したことに対応して、次のように得点をつけることにしました。

  • 球が上の壁に当たると+3点
  • 球が左右の壁に当たると+1点

ラケットで球を打ち返せなければ、そこでゲームオーバーです。ということは、ラケットの端で球を跳ね返すのは「ギリギリセーフ」という感じがします。ラケットの中央で球を跳ね返すのが安定した上手なプレイです。

そのような思考により上のような点数付けが最適だと考えました。

ラケットの端で球を跳ね返すと、球は上ではなく横に向かって移動するようになります。その結果、上の壁に当たりにくくなります。逆にラケットの中央で球を跳ね返すと、上の壁に当たりやすくなります。そこで、上の壁に当たる回数を得点にしました。

また、球の左右の移動速度が速くなるとラケットで打ち返すことが難しくなります。それを打ち返すことができることも上手なプレイと言えないこともありません。そして左右の移動速度が早ければ、左右の壁に当たる回数も増える。そこで、左右の壁に当たる回数も得点にした。

ただし、左右の移動速度が増えるのは、ラケットの左右の端で跳ね返したことが原因なので「上手なプレイ」の度合いは減ります。そこで左右の壁に当たったときの得点は、上の壁に当たった時より低くなるようにしました。

それぞれの得点が3倍も異なるのは、何度かテストプレイをした結果、この値が良いと感じたからです。

得点方式が決まったので、いよいよコーディングです。

上や左右の壁との衝突は、UpdateBall()で行っているので、そこにScoreを加算する処理を追加します。

/**
 * 球の状態更新
 */
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, vx, vy } = CollisionBallAndRachket(x, y, vx, vy));

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

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

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

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

また、ゲームオーバー時の得点が見れないのはさみしいので、Scoreを表示するように変更しました。

難易度調整

多くのゲームは「最初は簡単、徐々に難しくなっていく」という仕組みになっていることが多いです。このプログラムでもそういった調整を行うことにしましょう。

いくつかの案を考えましたが、今回はScoreが増えると徐々にラケットの幅が小さくなるようにしました。ラケットが小さくなることで、球が打ち返しにくくなり難易度が上がっていきます。

これはラケットの状態が変化するのだから、UpdateRacket()の中で処理するように更新しました。

/**
 * ラケットの状態更新
 */
function UpdateRacket() {

    // ラケットの移動処理は従来と同じため、記載を省略する

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

コードにあるように、Scoreが増えるとラケットの幅は減っていきます。ただし幅が0になってしまとゲームが成り立たないので、最低でも10までしか減らないようにしています。(ただし、私のプレイが下手なので、デバッグも兼ねて何度も遊んでいますがラケット幅が10になるほどの得点に達したことがありません。)

忘れてはなりませんが、ゲームオーバー後の次のゲームを始めるときにはラケットの幅が戻す必要がある。このためInitGameSituation()の中で幅を40に戻しています。(簡単なので、コードの記載は省略します。)

実際の例

今回の変更により、壁打ちテニスゲームとしてはひとまず完成です。

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

コメント