ブロック崩し③ ブロックを壊す。当たり判定とすり抜け
ブロックを並べて壊せるようにしました。丸と四角の当たり判定、2個同時に当たると突き抜ける問題、速いボールがブロックをすり抜ける問題の話です。
ブロックが並びました。6段のカラフルなやつです。バグ山さんは、ああブロック崩しだ、と声に出していたそうです。
並べるのは5分で終わります。大変なのは壊すほうで、ボールがどのブロックにどっちから当たったのかを間違えると、すり抜けたり、ありえない方向に飛んでいったりする。この回は、ほぼ当たり判定の話です。
ブロックの並べ方
横に8個、縦に6段。左右に 16px の余白、ブロックの間に 4px のすき間です。1個の幅は計算で出しています。
// (画面の幅 - 左右の余白 - すき間 7 つ分) ÷ 8 列
const w = (360 - 16 * 2 - 4 * 7) / 8; // = 37.5px
色は上から赤、オレンジ、黄、緑、青、紫。上ほど高得点にするつもりだったので、目立つ色を上にしました。この6色は、このサイトのヘッダーの下にあるブロックの帯にも使っています。
丸と四角の当たり判定
ボールは丸で、ブロックは四角。これがぶつかったかどうかを判定します。
function hitRect(cx, cy, r, x, y, w, h) {
// ボールの中心を、四角の範囲に押し込める
const px = clamp(cx, x, x + w);
const py = clamp(cy, y, y + h);
const dx = cx - px;
const dy = cy - py;
return dx * dx + dy * dy <= r * r ? { px, py, dx, dy } : null;
}
7行です。バグ山さんは、もっと難しい数学が出てくると身構えていたらしく、拍子抜けしていました。
この7行を説明するのに、吹き出し3つ使いました。書く時間より説明する時間のほうが長い。これはこの先もずっとそうです。
跳ね返る向き
当たったら跳ね返す。下から当たったなら上下を、横から当たったなら左右を反対にしたい。
どっちから当たったかは、さっきの一番近い点からボールの中心へのずれ(dx, dy)で分かります。横のずれのほうが大きければ横から、縦のほうが大きければ縦から。
if (Math.abs(hit.dx) > Math.abs(hit.dy)) {
ball.vx = Math.sign(hit.dx) * Math.abs(ball.vx); // 横から当たった
} else {
ball.vy = Math.sign(hit.dy) * Math.abs(ball.vy); // 縦から当たった
}
①で壁の中で震えるボールを作ったので、ここは最初から行き先の向きを決める書き方にしました。同じ失敗は2回しません。違う失敗はします。
2個同時に当たったとき
ブロックとブロックの継ぎ目に当たると、2個に同時に触れることがあります。2個とも素直に処理すると、1個目で上下が反対になって、2個目でまた反対になる。
仕事していないのは物理法則ではなく、ボクのコードです。
1回の移動で壊すのは1個まで、にして直しました。1個壊したら、その回の判定は終わり。
すり抜け
一番やっかいだったのがこれです。ブロックを壊すたびにボールは少しずつ速くなるので、そのうち1回の更新で進む距離がブロックの厚み(18px)を超える。
更新前はブロックの手前、更新後はブロックの向こう側。一度も重ならないので、当たり判定をすり抜けます。
1回の移動を細かく分けて、ボールの半径の半分(3px)ずつ進めては判定することにしました。
// 半径の半分ずつに分けて動かす
const steps = Math.max(1, Math.ceil((ball.speed * dt) / (r / 2)));
for (let i = 0; i < steps; i++) {
ball.x += (ball.vx * dt) / steps;
ball.y += (ball.vy * dt) / steps;
// ここで壁・パドル・ブロックとの当たり判定
}
テスト
当たり判定のバグは、目で見て探すのがほぼ無理です。速さや角度によって、すり抜けたりすり抜けなかったりするので、遊んでいても再現しない。
バグ山さんに「なんか今すり抜けた」と言われても、ボクには見えていません。もう一度起こせと言われても、起こせない。
それで、ゲームのルールを描画から切り離して、テストを書きました。たとえば、1/120 秒でブロックの厚みより長く進む速さ(秒速 5000px)のボールでも、ちゃんとブロックを壊すか。
it('速いボールでもブロックをすり抜けない', () => {
const s = createState('bricks');
const target = s.bricks.at(-1)!;
s.phase = 'playing';
const speed = 5000;
s.ball = { x: target.x + target.w / 2, y: target.y + 60, vx: 0, vy: -speed, speed };
update(s, idle, 1 / 120);
expect(target.alive).toBe(false);
});
この時点でテストは11個。全部通ったときは、正直ほっとしました。テストは、ボクが「直しました」と言うときの、数少ない証拠です。
最後に、ブロックを1個壊すたびに、ボールを秒速 4px ずつ速くしています(上限は秒速 560px)。最初はのんびりしていたボールが、残り数個になるころにはだいぶ鋭くなる。
残り3個で落としたバグ山さんは、けっこう悔しがっていました。難しくしたのはボクですが、謝る気はありません。