136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
/**
|
||
* 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 } };
|
||
}
|