/** * Where a ray meets a collider shape (#153), in f64 like the rest of the collision, so the editor run * and the played game answer alike and always about where things are now. Every shape is taken as * its rounded convex pieces (see `convex.ts`), and each piece as convex parts a ray is easy to meet: * the core polygon, a circle at each corner and a band along each edge as wide as the radius. */ import type { Vec2 } from '../model/Properties'; import { dot } from '../util/vec'; import { signedArea } from '../model/polygon'; import { type ColliderShape, piecesOf } from './collider'; import { type Rounded, roundedContact } from './convex'; export interface RayHit { /** How far along the ray it met the shape (world units). */ distance: number; /** The face it met, pointing out of the shape. */ normal: Vec2; } /** A start this deep inside a shape is inside it; on its outline it is not. */ const INSIDE = 1e-9; type Part = { kind: 'circle'; c: Vec2; r: number } | { kind: 'polygon'; points: readonly Vec2[] }; /** * Where the ray from `from` along the unit direction `dir` first meets `shape` within `length`, or * null. A shape the ray starts inside is not met – so a ray cast from the middle of the thing casting * it finds what is around, not itself; starting on its outline and running in does meet it, at 0. * Grazing a corner counts as meeting it. */ export function rayCast(shape: ColliderShape, from: Vec2, dir: Vec2, length: number): RayHit | null { const pieces = piecesOf(shape); const start: Rounded = { points: [from], r: 0 }; if (pieces.some((piece) => roundedContact(start, piece, dir).separation < -INSIDE)) return null; let best: RayHit | null = null; for (const piece of pieces) for (const part of partsOf(piece)) { const hit = part.kind === 'circle' ? rayCircle(part, from, dir, length) : rayPolygon(part.points, from, dir, length); if (hit && (!best || hit.distance < best.distance)) best = hit; } return best; } /** The parts whose union is the rounded piece. */ function partsOf(piece: Rounded): Part[] { const { points, r } = piece; if (r <= 0) return points.length >= 3 ? [{ kind: 'polygon', points }] : []; const parts: Part[] = points.map((c) => ({ kind: 'circle', c, r })); if (points.length === 2) { const [a, b] = points as [Vec2, Vec2]; const n = normalOf(a, b); if (n) { const off = { x: n.x * r, y: n.y * r }; parts.push({ kind: 'polygon', points: [ { x: a.x + off.x, y: a.y + off.y }, { x: b.x + off.x, y: b.y + off.y }, { x: b.x - off.x, y: b.y - off.y }, { x: a.x - off.x, y: a.y - off.y }, ], }); } } if (points.length >= 3) { parts.push({ kind: 'polygon', points }); const outward = signedArea(points) > 0 ? 1 : -1; points.forEach((a, i) => { const b = points[(i + 1) % points.length]!; const n = normalOf(a, b); if (!n) return; const off = { x: outward * n.x * r, y: outward * n.y * r }; parts.push({ kind: 'polygon', points: [a, b, { x: b.x + off.x, y: b.y + off.y }, { x: a.x + off.x, y: a.y + off.y }], }); }); } return parts; } /** The unit normal of ab turned the way a positive signed area turns outward; null for no length. */ function normalOf(a: Vec2, b: Vec2): Vec2 | null { const d = { x: b.x - a.x, y: b.y - a.y }; const length = Math.hypot(d.x, d.y); return length > 0 ? { x: d.y / length, y: -d.x / length } : null; } function rayCircle({ c, r }: { c: Vec2; r: number }, from: Vec2, dir: Vec2, length: number): RayHit | null { const m = { x: from.x - c.x, y: from.y - c.y }; const b = dot(m, dir); const k = dot(m, m) - r * r; const disc = b * b - k; if (disc < 0) return null; const t = -b - Math.sqrt(disc); if (t < -INSIDE || t > length) return null; const at = Math.max(0, t); return { distance: at, normal: { x: (m.x + dir.x * at) / r + 0, y: (m.y + dir.y * at) / r + 0 } }; } /** * A convex polygon, whichever way round its corners run: the ray is clipped by every edge's half * plane, and the last edge it comes in through is the one it meets. */ function rayPolygon(points: readonly Vec2[], from: Vec2, dir: Vec2, length: number): RayHit | null { const outward = signedArea(points) > 0 ? 1 : -1; let enter = -Infinity; let leave = Infinity; let normal: Vec2 | null = null; for (let i = 0; i < points.length; i++) { const a = points[i]!; const n0 = normalOf(a, points[(i + 1) % points.length]!); if (!n0) continue; const n = { x: outward * n0.x, y: outward * n0.y }; // Inside this edge's half plane: dot(n, from + t·dir − a) ≤ 0. const gap = dot(n, { x: a.x - from.x, y: a.y - from.y }); const speed = dot(n, dir); if (Math.abs(speed) < 1e-12) { if (gap < 0) return null; continue; } const t = gap / speed; if (speed < 0) { if (t > enter) { enter = t; normal = n; } } else leave = Math.min(leave, t); } if (!normal || enter > leave || enter < -INSIDE || enter > length) return null; return { distance: Math.max(0, enter), normal: { x: normal.x + 0, y: normal.y + 0 } }; }