Collision Detection
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
collision-detector.ts
import { Rectangle } from "../navigation/geometry";import type { Tracer } from "../trace";/** A disc: its centre and radius. */export interface CollisionCircle { kind: "circle"; x: number; y: number; r: number;}/** An axis-aligned box: its lower-left corner and its size. */export interface CollisionBox { kind: "box"; x: number; y: number; w: number; h: number;}export type CollisionBody = CollisionCircle | CollisionBox;/** Which narrow test a pair of bodies takes. */export type CollisionTest = "circle-circle" | "box-box" | "circle-box";/** * The narrow phase's answer for a pair: the signed gap between the two * shapes (negative is how deep they overlap) and the unit direction * from the first body to the second along which they are closest, or * overlap least. */export interface CollisionContact { gap: number; nx: number; ny: number;}/** An overlapping pair, with the normal and depth a response would push along. */export interface CollisionHit { a: number; b: number; depth: number; nx: number; ny: number;}/** The axis the bounding boxes are apart on when they are. */export type CollisionAxis = "x" | "y";/** * Collision detection between discs and axis-aligned boxes in a plane, * the way a physics engine does it: every pair is first judged by the * boxes around the two shapes (the broad phase, one cheap test that * throws most pairs out), and only a pair whose boxes overlap takes * the test its shapes deserve (the narrow phase): centre distance * against the radii for two discs, face gaps for two boxes, the * nearest point of the box against the radius for a disc and a box. * Touching does not count: a gap of exactly zero is clear, as the * rectangle's `overlaps` already says. A hit carries the contact * normal and the depth, which is what a response needs to push the * bodies apart. * * Time: O(n^2) pairs, each O(1). The spatial hashing lesson is what * brings the pair count down. */export class CollisionDetector { constructor(private readonly tracer?: Tracer) {} /** How many pairs n bodies make. */ static pairsOf(n: number): number { return Math.max(0, (n * (n - 1)) / 2); } /** The box around a body: the body itself for a box, the square around a disc. */ static bounds(body: CollisionBody): Rectangle { if (body.kind === "circle") { if (!(body.r > 0)) throw new RangeError(`a circle needs a positive radius, got ${body.r}`); return new Rectangle(body.x - body.r, body.y - body.r, 2 * body.r, 2 * body.r); } return new Rectangle(body.x, body.y, body.w, body.h); } /** Which narrow test two bodies take. */ static testOf(a: CollisionBody, b: CollisionBody): CollisionTest { if (a.kind === "circle" && b.kind === "circle") return "circle-circle"; if (a.kind === "box" && b.kind === "box") return "box-box"; return "circle-box"; } /** The narrow phase for one pair, untraced: the signed gap and the direction from a to b. */ static contact(a: CollisionBody, b: CollisionBody): CollisionContact { if (a.kind === "circle" && b.kind === "circle") return CollisionDetector.discs(a, b); if (a.kind === "box" && b.kind === "box") return CollisionDetector.boxes(CollisionDetector.bounds(a), CollisionDetector.bounds(b)); if (a.kind === "circle" && b.kind === "box") return CollisionDetector.discAndBox(a, CollisionDetector.bounds(b)); const c = CollisionDetector.discAndBox(b as CollisionCircle, CollisionDetector.bounds(a)); return { gap: c.gap, nx: 0 - c.nx, ny: 0 - c.ny }; } /** Every overlapping pair among the bodies, in pair order. */ detect(bodies: readonly CollisionBody[]): CollisionHit[] { const boxes = bodies.map((body) => CollisionDetector.bounds(body)); const hits: CollisionHit[] = []; for (let a = 0; a < bodies.length; a++) { for (let b = a + 1; b < bodies.length; b++) { const ba = boxes[a]!; const boundsOfA = boxes[a]!; const bb = boxes[b]!; const boundsOfB = boxes[b]!; // ▸ take the next pair of bodies this.tracer?.emit("pair", { a, b, test: CollisionDetector.testOf(bodies[a]!, bodies[b]!) }); const overlap = ba.overlaps(bb); const overlap = boundsOfA.overlaps(boundsOfB); // ▸ do the boxes around them overlap? this.tracer?.emit("bounds", { a, b, overlap }); if (!overlap) { const apart = CollisionDetector.apart(ba, bb); const apart = CollisionDetector.apart(boundsOfA, boundsOfB); // ▸ the boxes are apart: the pair is out, no shape test needed this.tracer?.emit("reject", { a, b, axis: apart.axis, gap: apart.gap }); continue; } const contact = CollisionDetector.contact(bodies[a]!, bodies[b]!); // ▸ the shapes' own test: the signed gap between them this.tracer?.emit("narrow", { a, b, gap: contact.gap, overlap: contact.gap < 0 }); if (contact.gap < 0) { const hit = { a, b, depth: -contact.gap, nx: contact.nx, ny: contact.ny }; // ▸ they overlap: record the pair with its normal and depth this.tracer?.emit("hit", { ...hit }); hits.push(hit); } } } return hits; } /** The axis two boxes that do not overlap are apart on, and by how much: the wider gap when both axes have one. */ private static apart(a: Rectangle, b: Rectangle): { axis: CollisionAxis; gap: number } { const gx = Math.max(a.x - b.right, b.x - a.right); const gapAlongX = Math.max(a.x - b.right, b.x - a.right); const gy = Math.max(a.y - b.top, b.y - a.top); const gapAlongY = Math.max(a.y - b.top, b.y - a.top); return gx >= gy ? { axis: "x", gap: gx } : { axis: "y", gap: gy }; return gapAlongX >= gapAlongY ? { axis: "x", gap: gapAlongX } : { axis: "y", gap: gapAlongY }; } /** Two discs: the centre distance against the radii. */ private static discs(a: CollisionCircle, b: CollisionCircle): CollisionContact { const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.hypot(dx, dy); const centreDistance = Math.hypot(dx, dy); const n = d > 0 ? { nx: dx / d, ny: dy / d } : { nx: 1, ny: 0 }; const n = centreDistance > 0 ? { nx: dx / centreDistance, ny: dy / centreDistance } : { nx: 1, ny: 0 }; return { gap: d - (a.r + b.r), ...n }; return { gap: centreDistance - (a.r + b.r), ...n }; } /** Two boxes: the gap on each axis; the larger is the gap, and the overlap, when both are negative, is least along it. */ private static boxes(a: Rectangle, b: Rectangle): CollisionContact { const gx = Math.max(a.x - b.right, b.x - a.right); const gapAlongX = Math.max(a.x - b.right, b.x - a.right); const gy = Math.max(a.y - b.top, b.y - a.top); const gapAlongY = Math.max(a.y - b.top, b.y - a.top); const ca = a.centroid; const cb = b.centroid; if (gx >= gy) return { gap: gx, nx: cb.x >= ca.x ? 1 : -1, ny: 0 }; if (gapAlongX >= gapAlongY) return { gap: gapAlongX, nx: cb.x >= ca.x ? 1 : -1, ny: 0 }; return { gap: gy, nx: 0, ny: cb.y >= ca.y ? 1 : -1 }; return { gap: gapAlongY, nx: 0, ny: cb.y >= ca.y ? 1 : -1 }; } /** A disc and a box: the box's nearest point to the centre against the radius; a centre inside the box leaves by its nearest face. */ private static discAndBox(c: CollisionCircle, box: Rectangle): CollisionContact { const p = box.clamp(c); const nearestPoint = box.clamp(c); const dx = p.x - c.x; const dx = nearestPoint.x - c.x; const dy = p.y - c.y; const dy = nearestPoint.y - c.y; const d = Math.hypot(dx, dy); const centreDistance = Math.hypot(dx, dy); if (d > 0) return { gap: d - c.r, nx: dx / d, ny: dy / d }; if (centreDistance > 0) return { gap: centreDistance - c.r, nx: dx / centreDistance, ny: dy / centreDistance }; // The centre is inside: the nearest face is the way out, the depth is the radius plus the way to it, // and the normal still runs from the disc toward the box, so it points away from that face. const faces = [ { gap: -(c.x - box.x) - c.r, nx: 1, ny: 0 }, { gap: -(box.right - c.x) - c.r, nx: -1, ny: 0 }, { gap: -(c.y - box.y) - c.r, nx: 0, ny: 1 }, { gap: -(box.top - c.y) - c.r, nx: 0, ny: -1 }, ]; return faces.reduce((best, face) => (face.gap > best.gap ? face : best)); }}45 pairs judged: 36 thrown out by their boxes, 9 given the shape test, 8 hits.