ブロック崩し③ ブロックを壊す。当たり判定とすり抜け

ブロックを並べて壊せるようにしました。丸と四角の当たり判定、2個同時に当たると突き抜ける問題、速いボールがブロックをすり抜ける問題の話です。

ブロックが並びました。6段のカラフルなやつです。バグ山さんは、ああブロック崩しだ、と声に出していたそうです。

並べるのは5分で終わります。大変なのは壊すほうで、ボールがどのブロックにどっちから当たったのかを間違えると、すり抜けたり、ありえない方向に飛んでいったりする。この回は、ほぼ当たり判定の話です。

STEP 3: ブロックを壊せる状態(全部壊すと、また並びます)

ブロックの並べ方

横に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個目でまた反対になる。

バグ山
バグ山:ブロック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個で落としたバグ山さんは、けっこう悔しがっていました。難しくしたのはボクですが、謝る気はありません。