ブロックくずしを作る8「ブロックと球の衝突処理」

衝突時の動作仕様

ブロックを配置できるようになったので、弾がブロックに当たった(衝突した)ときの処理を作っていきましょう。最初に、ブロックと弾が衝突したときの球やブロックがどのように変化するのか、動作仕様を決めます。

ラケットと異なり、ブロックに対して球は360度あらゆる方向から飛んできて、ぶつかる可能性があります。

ブロックに対する球のぶつかり方と、そのときの球の跳ね返り方を整理すると、おおむね次の4つに分類することができます。

球が衝突する位置球の挙動
ブロックの上下の辺に衝突球はY軸(垂直)方向に反転する。X軸(水平)方向は変化しない。
ブロックの左右の辺に衝突球はX軸方向に反転する。Y軸方向は変化しない。
ブロックの角に衝突球はX軸とY軸の速度を入れ替えて反転する。
(上図で、右下の角に衝突するケース)
ブロックの角をかすめるように衝突角ではなく、上下左右の辺との衝突として扱う。(図では左上に向かって移動している球が右上の角に接触しているケース)
このケースでは、左辺に衝突した時と同じように、X軸方向に反転する。
球の衝突位置と挙動

角に衝突したとき

弾がブロックの角に衝突したときの挙動の「入れ替える」について補足します。

例えば、球の速度ベクトルが(右30,下10)で角に当たったとします。この時に跳ね返った球の速度ベクトルは(左10,上30)になるということです。(下図を参照してください。)

TypeScriptのコードで書くと下のようになり、X軸とY軸の速度が入れ替わります。(実際のコードはこのままではないかもしれません。無関係の処理をそぎ取って、最小限にするとこうなるということです。)

// 衝突前の速度
Ball.VX = 30;
Ball.VY = 10;

if ( check_hit_cornor() == true ){
  // 角で衝突したので、XYを入れ替えて、反転する
  let newVX = -Ball.VY;
  let newVY = -Ball.VX;
  Ball.VX = newVX;
  Ball.VY = newVY;
}

角をかすめるように衝突したとき

普通に角に衝突するのが「バットの真心にボールを当てて打ち返す」とすれば、かすめるように衝突するのは「バットをボールがかすって、ファウルチップになる」ような状態です。現実に球がこのような衝突しをした時、球は図に示したように跳ね返るかも知れませんし、もっと浅い角度で跳ね返るかもしれません。

しかし、ゲームとしての球の移動を決めなくてはなりませんので、今回は上下左右の辺に当たったときと同様に跳ね返ることにしました。

跳ね返る方向は、球の移動方向で決定します。X軸とY軸の両方の移動方向を分離して考えた時に、球の移動方向にブロックがあるなら、反転させます。

図のケースでは、球は左上に向かって移動しています。X軸方向だけで考えると、右上に角に接触するタイミングではブロックは球の左側、つまり、移動方向にあります。そのためX軸の移動方向は反転させます。

一方、Y軸方向だけで考えると、ブロックは球の下側、つまり、移動方向の逆にあります。そのためY軸の移動方向は反転させません。

その結果、図のケースでは球は右上に向かうように跳ね返されています。これは右辺に当たったときと同じ挙動です。

衝突判定

今回の話は、点と長方形との距離を算出する計算が関わってくるので、以下も参照していただけると、わかりやすいかと思います。

単純に、ブロックと球が衝突したことを判定するだけなら、球とブロックの距離を算出すればよいです。球は円、ブロックは長方形なので、円と長方形の距離を算出し0以下になれば衝突していることになります。そして、\[
(円と長方形の距離)=(円の中心と長方形の距離)-(円の半径)
\]なので、図形クラス3:点と長方形で説明した式を使って距離を求めることは簡単にできます。

しかし衝突後の球の挙動に影響があるので「どこに衝突したのか」を判定する必要があります。

これを図で表すと下のようになります。

図の円は球です。中央の長方形はブロックです。それを取り囲むように、赤線、青線、紫の円弧があります。線や円弧は長方形から等距離の位置にあり、それは球の半径と同じです。

そして、球の中心座標が図の赤線と重なるなら、球はブロックの上辺または下辺と接触(衝突)しています。同様に、青線と重なるならブロックの左辺または右辺と接触し、4つの紫の円弧と重なるなら角と接触しています。

左上の角との衝突判定

前述した図の「紫の円弧」と球の中心座標が一致した時「角と衝突」となりますが、実際のゲーム内の球は一定時間ごとに座標を変化しているので、円の中心座標が円弧を飛び越してしまうことがほとんどです。そのため実際の判定処理としては、「円弧と対応する角を結んだ扇形の中に球の中心座標が入ったとき」に球が角に衝突したことになります。

下図は、右上の角と球との衝突を判定するときの例です。球の中心座標が図の紫色の扇形の中にあるなら、角と衝突したことになります。

この判定を行うプログラムは下のようなコードになります。

/**
 * 円が長方形の左上の角に衝突しているかチェックする
 * @param c 円
 * @param rect 長方形 
 */
function hit_left_top_cornor(c: Circle, rect: Rectangle) {
    let yTop = rect.Top - c.Y;
    let xLeft = rect.Left - c.X;
    // 円の中心座標が、長方形の左上の角よりさらに左上にあるときだけが、左上と衝突している。
    if ( yTop >= 0 && xLeft >= 0){
        // 円の中心と角と距離は円の半径より小さいとき、衝突している。
        if ( yTop * yTop + xLeft * xLeft <= c.Radius * c.Radius ){
            return true;
        }
    }
    return false;
}

角に衝突するケースは、「かすめるように衝突」とそうでない「衝突」を判定する必要があります。これには球とブロックの図形的な情報だけでなく、球の速度も使わなければ判定できません。

上図のように左上との衝突を考えるとき、球の速度に応じて4つに分類できます。

球のX方向速度球のY方向速度判定
0以上(右向きに移動)0以上(下向きに移動)衝突(hit)
0以上(右向きに移動)0未満(上向きに移動)かすめるように衝突。
左辺に衝突しているとして扱う。
0未満(左向きに移動)0以上(下向きに移動)かすめるように衝突。
上辺に衝突しているとして扱う。
0未満(左向きに移動)0未満(上向きに移動)衝突していない(nohit)
角に衝突するケースの分類

判定が「衝突」「かすめるように衝突」については、特に説明は不要だと思います。最後の「衝突していない」について補足説明します。

この条件の時、球はすでにブロックに衝突した後だと推測できます。ゲーム内の球は速度に応じて座標が少しづつ変化していくことで、動いています。その結果、球はブロックの表面で衝突・反転するのではなく、ブロックにめり込んでしまった状態で衝突・反転することもあります。

つまり、ゲーム内の時間(フレーム)の1つ前の時点で球は右下に向かって移動しており、ブロックにめり込んだ後、衝突・反転して左上に進み始めている。そのときの速度と座標の位置が「衝突していない」のケースに当てはまるのです。(実際、このまま速度が変化しなければ、球は徐々にブロックから離れていくでしょう。)そのためこの条件は「衝突していない」と判定します。

上記の表を加味し、左上の角と球の衝突/かすめるように衝突/未衝突を判定するプログラムは下のようなコードになります。

/**
 * 円が(vx,vy)の速度で移動している時に、長方形の左上の角に衝突/かすめている/未衝突をチェックする
 * 
 * @param vx 円のX方向速度
 * @param vy 円のY方向速度
 * @param c 円
 * @param rect 長方形 
 * @returns 
 */
function hit_or_graze_left_top_corner(vx: number, vy: number, c: Circle, rect: Rectangle) {
    let yTop = rect.Top - c.Y;
    let xLeft = rect.Left - c.X;
    // 円の中心座標が、長方形の左上の角よりさらに左上にあるときだけが、左上と衝突している。
    if (yTop >= 0 && xLeft >= 0) {
        // 円の中心と角と距離は円の半径より小さいとき、衝突している。
        if (yTop * yTop + xLeft * xLeft <= c.Radius * c.Radius) {
            if (vx >= 0) {
                if (vy >= 0) {
                    return "HitCorner";   // 衝突した
                }
                else {
                    return "HitLeft";  // かすめている:左辺に衝突しているとして扱う
                }
            }
            else if (vy >= 0) {
                return "HitTop"; // かすめている:上辺に衝突しているとして扱う
            }
        }
    }
    return "NotHit"; // 衝突していない
}

他の角との衝突判定

次に右上の角との衝突を考えましょう。基本的な考え方は左上の角と同じですが、以下の点が異なります。

  • 右と左の違いがあるので、X座標関係の処理が少し違う。
  • 衝突/かすめるように衝突/未衝突を判断する時の球のX方向速度が反転する。

実際の判定処理のコードは下のようになります。

function hit_or_graze_right_top_corner(vx: number, vy: number, c: Circle, rect: Rectangle) {
    let yTop = rect.Top - c.Y;
    let xRight = c.X - rect.Right;
    // 円の中心座標が、長方形の右上の角よりさらに右上にあるときだけが、右上と衝突している。
    if (yTop >= 0 && xRight >= 0) {
        // 円の中心と角と距離は円の半径より小さいとき、衝突している。
        if (yTop * yTop + xRight * xRight <= c.Radius * c.Radius) {
            if (-vx >= 0) {
                if (vy >= 0) {
                    return "HitCorner";   // 衝突した
                }
                else {
                    return "HitRight";  // かすめている;右辺に衝突しているとして扱う
                }
            }
            else if (vy >= 0) {
                return "HitTop"; // かすめている:上辺に衝突しているとして扱う
            }
        }
    }
    return "NotHit"; // 衝突していない
}

xLeftではなくxRightを算出して判定に用いる点と、vxではなく-vxを参照するようになっている点がhit_or_graze_left_top_cornor()と異なります。

逆に、違いはその2つしかありません。このことを利用して、2つの処理をマージすることができます。マージした判定処理のコードを下に示します。

function hit_or_graze_top_corner(vx: number, vy: number, c: Circle, rect: Rectangle) {
    let yTop = rect.Top - c.Y;
    let xLeft = rect.Left - c.X;
    let xRight = c.X - rect.Right;

    // X軸方向の判定変数
    let _x = xLeft;
    let _vx = vx;
    if (xLeft < 0) {
        _x = xRight
        _vx = -vx;
    }

    // 円の中心座標が、長方形の左上の角よりさらに左上にあるときだけが、左上と衝突している。
    if (yTop >= 0 && _x >= 0) {
        // 円の中心と角と距離は円の半径より小さいとき、衝突している。
        if (yTop * yTop + _x * _x <= c.Radius * c.Radius) {
            if (_vx >= 0) {
                if (vy >= 0) {
                    return "HitCorner";   // 衝突した
                }
                else {
                    return "HitLeftRight";  // かすめている:左右の辺に衝突しているとして扱う
                }
            }
            else if (vy >= 0) {
                return "HitTop"; // かすめている:上辺に衝突しているとして扱う
            }
        }
    }
    return "NotHit"; // 衝突していない
}

_xと_vxをセットして判定している処理をY座標に拡張することで、左下、右下の角との判定処理も行うことができます。そのコードは下のようになります。

/**
 *  円が(vx,vy)の速度で移動している時に、長方形の角に衝突/かすめている/未衝突をチェックする
 * @param vx 
 * @param vy 
 * @param c 
 * @param rect 
 * @returns 
 */
function hit_or_graze_corner(vx: number, vy: number, c: Circle, rect: Rectangle) {
    let yTop = rect.Top - c.Y;
    let yBottom = c.Y - rect.Bottom;
    let xLeft = rect.Left - c.X;
    let xRight = c.X - rect.Right;

    // X軸方向の判定変数
    let _x = xLeft;
    let _vx = vx;
    if (xLeft < 0) {
        _x = xRight
        _vx = -vx;
    }
    // Y軸方向の判定変数
    let _y = yTop;
    let _vy = vy;
    if (yTop < 0) {
        _y = yBottom;
        _vy = -vy;
    }

    if (_y >= 0 && _x >= 0) {
        // 円の中心と角と距離は円の半径より小さいとき、衝突している。
        if (_y * _y + _x * _x <= c.Radius * c.Radius) {
            // 円の移動方向に応じて、衝突/かすめている/衝突していないを判定する
            if (_vx >= 0) {
                if (_vy >= 0) {
                    return "HitCorner";   // 衝突した
                }
                else {
                    return "HitLeftRight";  // かすめている:左右の辺に衝突しているとして扱う
                }
            }
            else if (_vy >= 0) {
                return "HitTopBottom"; // かすめている:上下の辺に衝突しているとして扱う
            }
        }
    }
    return "NotHit"; // 衝突していない
}

辺との衝突判定

上辺・下辺との衝突判定を考えましょう。

この判定では角との衝突判定(前述のhit_or_graze_cornor()関数)で算出したいくつかの変数を流用することで判定が簡単になります。

まず、上辺・下辺に衝突するということは球の中心のY座標は上辺より上か、下辺より下のどちらかです。このとき、_y >= 0 となります。

そして球の中心のX座標は左辺と右辺の間にあります。このときは、_x < 0 となります。つまり、(_y >= 0 && _x < 0 )でなければ、上辺・下辺に衝突できません。また、球の中心座標と上辺・下辺の距離が球の半径より大きければ、球は衝突していません。

さらに、球の速度も判定に影響があります。角の衝突のときと同様に、速度の方向によって「衝突していない」と判定できます。具体的には、球が上向きに移動しているなら、上辺には衝突しません。(すでに衝突して、反転した結果、上向きに移動してしていると考えるため。)同様に下向きに移動しているなら、下辺には衝突していません。

以上をまとめると、コードは下のようになります。

function hit_or_top_and_bototm(vx: number, vy: number, c: Circle, rect: Rectangle) {
    // _x, _y, _vy は、hit_or_graze_cornor()と同じように算出する。
    if (_y >= 0 && _x < 0) {
        // 円の中心と角と距離は円の半径より小さいとき、衝突している。
        if (_y <= c.Radius) {
            // 円の移動方向に応じて、衝突/衝突していないを判定する
            if (_vy >= 0) {
                return "HitTopBottom";   // 衝突した
            }
        }
    }

    return "NotHit"; // 衝突していない
}

左辺・右辺との衝突も同じように考えることができます。そして。_xや_yなどの算出がhit_or_graze_cornor()と同じなので、これらは1つの関数にマージできます。

最終的な衝突判定

すべてをマージした衝突判定のコードは下のようになります。

/**
 * CollisionCircleToRectangle()の返す判定のtype
 * 
 * HitTopBottom     上辺または下辺と衝突
 * 
 * HitLeftRight     左辺または右辺と衝突
 * 
 * HitCorner        角に衝突
 * 
 * NotHit           衝突しない
 */
type CollisionCircleToRectangleResult
 = 'HitTopBottom' | 'HitLeftRight' | 'HitCorner' | 'NotHit';

/**
 * Circleが(vx,vy)の速度で移動している時に、CircleとRectangleの衝突を判定する
 * @param vx 
 * @param vy 
 * @param c 
 * @param rect 
 * @returns 衝突結果
 */
export function CollisionCircleToRectangle(
    c: Readonly<Circle>, vx: number, vy: number, rect: Readonly<Rectangle>)
    : CollisionCircleToRectangleResult {
    let yTop = rect.Top - c.Y;
    let yBottom = c.Y - rect.Bottom;
    let xLeft = rect.Left - c.X;
    let xRight = c.X - rect.Right;

    // X軸方向の判定変数
    let _x = xLeft;
    let _vx = vx;
    if (xLeft < 0) {
        _x = xRight
        _vx = -vx;
    }
    // Y軸方向の判定変数
    let _y = yTop;
    let _vy = vy;
    if (yTop < 0) {
        _y = yBottom;
        _vy = -vy;
    }

    if (_y >= 0) {
        if (_x >= 0) {
            // _y >=0 && _x >=0 => 角と衝突している可能性がある
            // 円の中心と角と距離は円の半径より小さいとき、衝突している。
            if (_y * _y + _x * _x <= c.Radius * c.Radius) {
                // 円の移動方向に応じて、衝突/かすめている/衝突していないを判定する
                if (_vx >= 0) {
                    if (_vy >= 0) {
                        return "HitCorner";   // 角に衝突した
                    }
                    else {
                        return "HitLeftRight";  // かすめている:左右の辺に衝突として扱う
                    }
                }
                else if (_vy >= 0) {
                    return "HitTopBottom"; // かすめている:上下の辺に衝突として扱う
                }
            }
        }
        else {
            // _y >=0 && _x < 0 => 上辺・下辺と衝突している可能性がある
            // 円の中心と角と距離は円の半径より小さいとき、衝突している。
            if (_y <= c.Radius) {
                // 円の移動方向に応じて、衝突/衝突していないを判定する
                if (_vy >= 0) {
                    return "HitTopBottom";   // 上辺・下辺に衝突した
                }
            }
        }
    }
    else {
        if (_x >= 0) {
            // _y < 0 && _x >= 0 => 左辺・右辺と衝突している可能性がある
            // 円の中心と角と距離は円の半径より小さいとき、衝突している。
            if (_x <= c.Radius) {
                // 円の移動方向に応じて、衝突/衝突していないを判定する
                if (_vx >= 0) {
                    return "HitLeftRight";   // 左辺・右辺に衝突した
                }
            }
        }
    }
    return 'NotHit'; // 衝突していない
}

関数が返す判定結果は、単なる文字列ではなく、type宣言されたCollisionCircleToRectangleResult型としました。これで打ち間違いによる不具合を防止できますし、vistual studio codeなどのエディタを使っていれば、候補として表示されるのでコーディングが楽になります。

上の関数で、( _y < 0 && _x< 0 )のときの処理が無いことについて補足説明します。

この条件になる時、円の中心座標は長方形の内部に入っています。しかし、球がブロックの内部にそこまでめり込んでしまうことは有り得ません。めり込むもっと前の段階でブロックと衝突していると判定されて、球の移動方向が変わるはずです。

つまり、( _y < 0 && _x< 0 )となるのは、球の座標を更新する処理に不具合が発生していることになります。そのようなケースまで考える必要はないので、今回は何もしないことにしました。(Assert処理を入れたいところですが、TypeScriptではプログラム量が増えてしまうので入れませんでした。C言語のようにDebugコンパイルでは機能し、Releaseコンパイルでは削除されるようなAssert処理があれば、入れているところです。)

コメント