/** * Collider geometry in world units, shared by the editor and the runtime so both agree on what * "overlaps" means. A box is oriented: it turns and grows with the body it sits on. A capsule and a * polygon (#151) carry their points in the world already, and a polygon its convex pieces. */ import { type FrameOutline, frameOutline } from '../model/frameOutline'; import { angleOf, apply, type Mat, scaleOf } from '../model/matrix'; import { convexPieces, pointsOf, signedArea } from '../model/polygon'; import type { Project } from '../model/Project'; import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties'; import { dot } from '../util/vec'; import { roundedContact, roundedContains, roundedExit, roundedExtent, type Contact, preferredOnTie, type Rounded, } from './convex'; import { defaultsFor, ONE_WAY_ANGLE_DEFAULT, ONE_WAY_ANGLE_MAX } from './PropertySchema'; const DEFAULTS = defaultsFor('collider'); /** Overlaps this shallow count as touching, so two rotated boxes at rest do not jitter. */ const EPSILON = 1e-9; export type ColliderShape = | { kind: 'box'; x: number; y: number; w: number; h: number; angle: number } | { kind: 'circle'; x: number; y: number; r: number } /** The segment `a`–`b` grown by `r`. */ | { kind: 'capsule'; x: number; y: number; angle: number; a: Vec2; b: Vec2; r: number } /** * `outline` as drawn; `pieces` what collides – none when the outline is no polygon (see * `polygonProblem`). A frame's outline (#193) may be several rings: then `rings` holds them all, * `outline` the first. Read them through `outlinesOf`. */ | { kind: 'polygon'; x: number; y: number; angle: number; outline: Vec2[]; rings?: Vec2[][]; pieces: Vec2[][]; }; type Box = Extract; type Circle = Extract; type Polygon = Extract; type Classic = Box | Circle; /** Every outline a polygon is drawn with – one, or a frame's several (#193). */ export function outlinesOf(shape: Polygon): Vec2[][] { return shape.rings ?? [shape.outline]; } /** Where a polygon collider keeps its corners (#151). */ export const COLLIDER_POINTS_KEY = 'points'; /** Which frame a collider of the shape "Frame collision" wears (#193): a frame id, as a sprite's texture holds it. */ const COLLIDER_FRAME_KEY = 'frame'; /** Whether the collider wears a frame's outline (#193), which only the project can resolve. */ function wearsFrame(props: PropertyBag): boolean { return props.shape === 'frame'; } /** The frame a collider names, looked up in `project` – null for another shape or a dangling id. */ export function colliderFrame(project: Project | null, props: PropertyBag): FrameOutline | null { return wearsFrame(props) ? frameOutline(project, textOf(props[COLLIDER_FRAME_KEY] ?? '')) : null; } /** The four corners of a centred `w` × `h` box, clockwise on screen. */ function boxRing(w: number, h: number): Vec2[] { return [ { x: -w / 2, y: -h / 2 }, { x: w / 2, y: -h / 2 }, { x: w / 2, y: h / 2 }, { x: -w / 2, y: h / 2 }, ]; } /** A polygon's pieces in its own units, cut once per stored list rather than on every test. */ const piecesCache = new WeakMap(); /** The corners of a polygon collider in its own units – the stored ones, or the default triangle. */ export function colliderPoints(props: PropertyBag): Vec2[] { return pointsOf(props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY]); } function localPieces(value: PropertyValue | undefined): Vec2[][] { const key = typeof value === 'object' ? value : null; const known = key && piecesCache.get(key); if (known) return known; const pieces = convexPieces(pointsOf(value)); if (key) piecesCache.set(key, pieces); return pieces; } /** The capsule in a `w` × `h` box: rounded along the longer side, the radius half the shorter one. */ function capsuleCore(w: number, h: number): { a: Vec2; b: Vec2; r: number } { if (h >= w) return { a: { x: 0, y: -(h - w) / 2 }, b: { x: 0, y: (h - w) / 2 }, r: w / 2 }; return { a: { x: -(w - h) / 2, y: 0 }, b: { x: (w - h) / 2, y: 0 }, r: h / 2 }; } /** * The shape as it sits in the world: sized by the transform's scale, turned (and mirrored) with it. * A collider of the shape "Frame collision" (#193) needs its frame from `colliderFrame`. Without one it is drawn * as the frame's box, or its own width × height, and collides with nothing. */ export function colliderShape(props: PropertyBag, m: Mat, frame?: FrameOutline | null): ColliderShape { const scale = scaleOf(m); if (props.shape === 'circle') return { kind: 'circle', x: m.tx, y: m.ty, r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * scale, }; const w = Math.max(0, Number(props.width ?? DEFAULTS.width)); const h = Math.max(0, Number(props.height ?? DEFAULTS.height)); if (props.shape === 'capsule') { const core = capsuleCore(w, h); return { kind: 'capsule', x: m.tx, y: m.ty, angle: angleOf(m), a: apply(m, core.a), b: apply(m, core.b), r: core.r * scale, }; } if (props.shape === 'polygon') { const value = props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY]; return { kind: 'polygon', x: m.tx, y: m.ty, angle: angleOf(m), outline: pointsOf(value).map((p) => apply(m, p)), pieces: localPieces(value).map((piece) => piece.map((p) => apply(m, p))), }; } if (wearsFrame(props)) { const worn = frame && frame.pieces.length > 0; const rings = worn ? frame.rings : [frame ? boxRing(frame.w, frame.h) : boxRing(w, h)]; const placed = rings.map((ring) => ring.map((p) => apply(m, p))); return { kind: 'polygon', x: m.tx, y: m.ty, angle: angleOf(m), outline: placed[0] ?? [], rings: placed, pieces: worn ? frame.pieces.map((piece) => piece.map((p) => apply(m, p))) : [], }; } return { kind: 'box', x: m.tx, y: m.ty, w: w * scale, h: h * scale, angle: angleOf(m) }; } /** Whether the collider only reports overlaps (true) or also blocks movement (false). */ export function isTrigger(props: PropertyBag): boolean { return Boolean(props.isTrigger ?? DEFAULTS.isTrigger); } /** The side a one-way collider blocks from, in its own axes (y points down). */ const ONE_WAY_SIDES: Record = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 }, }; /** * The world direction a one-way shape blocks from, and `cone`, the cosine of its angle: a contact * whose normal lies within that angle of the direction is a landing, any other passes through. */ export interface OneWaySide extends Vec2 { cone: number; } /** The angle held to 0 … 90°, anything unreadable the default. */ export function oneWayAngle(value: PropertyValue | undefined): number { const angle = Number(value ?? ONE_WAY_ANGLE_DEFAULT); if (typeof value === 'boolean' || !Number.isFinite(angle)) return ONE_WAY_ANGLE_DEFAULT; return Math.min(ONE_WAY_ANGLE_MAX, Math.max(0, angle)); } /** The cone of an angle in degrees, a hair wide so a normal exactly on its edge still lands. */ export function oneWayCone(angle: number): number { return Math.cos((angle * Math.PI) / 180) - 1e-9; } /** * The world direction a one-way collider blocks from – its top face for the default "up", turned * and mirrored with it – with its cone, or null for a collider that blocks from everywhere. */ export function oneWayFacing(props: PropertyBag, m: Mat): OneWaySide | null { if (!(props.oneWay ?? DEFAULTS.oneWay)) return null; const side = ONE_WAY_SIDES[textOf(props.oneWayDirection ?? DEFAULTS.oneWayDirection)] ?? ONE_WAY_SIDES.up!; const x = m.a * side.x + m.c * side.y; const y = m.b * side.x + m.d * side.y; const length = Math.hypot(x, y); const cone = oneWayCone(oneWayAngle(props.oneWayAngle)); return length > 0 ? { x: x / length, y: y / length, cone } : null; } /** Whether a contact with that normal (towards the mover) lands on the side a one-way shape blocks from. */ export function landsOn(normal: Vec2, facing: OneWaySide): boolean { return dot(normal, facing) >= facing.cone; } /** The shape with every point moved by `move` and turned by `turn` more – a body carrying it moved rigidly. */ export function movedShape(shape: ColliderShape, move: (p: Vec2) => Vec2, turn: number): ColliderShape { const centre = move(shape); switch (shape.kind) { case 'circle': return { ...shape, ...centre }; case 'box': return { ...shape, ...centre, angle: shape.angle + turn }; case 'capsule': return { ...shape, ...centre, angle: shape.angle + turn, a: move(shape.a), b: move(shape.b) }; case 'polygon': return { ...shape, ...centre, angle: shape.angle + turn, outline: shape.outline.map(move), ...(shape.rings && { rings: shape.rings.map((ring) => ring.map(move)) }), pieces: shape.pieces.map((piece) => piece.map(move)), }; } } /** The size in the collider's own units, before any transform; a worn frame (#193) is its own size. */ export function colliderSize(props: PropertyBag, frame?: FrameOutline | null): { w: number; h: number } { if (wearsFrame(props) && frame) return { w: frame.w, h: frame.h }; // Centred like every footprint, so it reaches as far as the farthest corner on each axis. if (props.shape === 'polygon') { const points = colliderPoints(props); return { w: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.x)), 0), h: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.y)), 0), }; } if (props.shape === 'circle') { const d = Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * 2; return { w: d, h: d }; } return { w: Math.max(0, Number(props.width ?? DEFAULTS.width)), h: Math.max(0, Number(props.height ?? DEFAULTS.height)), }; } function axesOf(box: Box): [Vec2, Vec2] { const cos = Math.cos(box.angle); const sin = Math.sin(box.angle); return [ { x: cos, y: sin }, { x: -sin, y: cos }, ]; } /** A world point in the box's own frame: origin at its centre, axes along its sides. */ function toBoxFrame(box: Box, p: Vec2): Vec2 { const [ux, uy] = axesOf(box); const d = { x: p.x - box.x, y: p.y - box.y }; return { x: dot(d, ux), y: dot(d, uy) }; } /** Half of the box's projection onto the unit direction `n`. */ function reach(box: Box, n: Vec2): number { const [ux, uy] = axesOf(box); return (box.w / 2) * Math.abs(dot(ux, n)) + (box.h / 2) * Math.abs(dot(uy, n)); } /** * How wide the shape is along the unit direction `n` – a turned box measures its own slant. A * polygon measures its thinnest piece: that is what a step along `n` must not jump over. */ export function shapeExtent(shape: ColliderShape, n: Vec2): number { if (shape.kind === 'circle') return shape.r * 2; if (shape.kind === 'box') return reach(shape, n) * 2; const pieces = piecesOf(shape); return pieces.length ? Math.min(...pieces.map((piece) => roundedExtent(piece, n))) : 0; } /** * The shape as rounded convex pieces (see `convex.ts`): a box its corners, a circle its centre and * a capsule its segment, each grown by its radius, a polygon its pieces. */ export function piecesOf(shape: ColliderShape): Rounded[] { switch (shape.kind) { case 'box': { const [ux, uy] = axesOf(shape); const corner = (sx: number, sy: number) => ({ x: shape.x + (sx * shape.w * ux.x + sy * shape.h * uy.x) / 2, y: shape.y + (sx * shape.w * ux.y + sy * shape.h * uy.y) / 2, }); return [{ points: [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)], r: 0 }]; } case 'circle': return [{ points: [{ x: shape.x, y: shape.y }], r: shape.r }]; case 'capsule': return [{ points: [shape.a, shape.b], r: shape.r }]; case 'polygon': return shape.pieces.map((points) => ({ points, r: 0 })); } } /** The area the shape covers, in world units squared. */ export function shapeArea(shape: ColliderShape): number { switch (shape.kind) { case 'box': return shape.w * shape.h; case 'circle': return Math.PI * shape.r * shape.r; case 'capsule': return ( Math.PI * shape.r * shape.r + 2 * shape.r * Math.hypot(shape.b.x - shape.a.x, shape.b.y - shape.a.y) ); case 'polygon': return shape.pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece)), 0); } } /** Box and circle keep their own exact tests; everything else goes through the rounded pieces. */ function isClassic(shape: ColliderShape): shape is Classic { return shape.kind === 'box' || shape.kind === 'circle'; } export function shapeContains(shape: ColliderShape, point: Vec2): boolean { if (!isClassic(shape)) return piecesOf(shape).some((piece) => roundedContains(piece, point)); if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r; const local = toBoxFrame(shape, point); return Math.abs(local.x) * 2 <= shape.w && Math.abs(local.y) * 2 <= shape.h; } /** Distance from a point to an axis-aligned box centred at the origin; 0 inside. */ function distanceToBox(p: Vec2, hw: number, hh: number): number { const dx = Math.max(Math.abs(p.x) - hw, 0); const dy = Math.max(Math.abs(p.y) - hh, 0); return Math.hypot(dx, dy); } /** Shapes that merely touch along an edge do not count as overlapping. */ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean { if (!isClassic(a) || !isClassic(b)) { const others = piecesOf(b); return piecesOf(a).some((p) => others.some((q) => roundedContact(p, q, { x: 0, y: 0 }).separation < -EPSILON), ); } if (a.kind === 'box' && b.kind === 'box') { // Separating axes: the sides of both boxes. Apart along any one of them is apart. const centres = { x: a.x - b.x, y: a.y - b.y }; for (const n of [...axesOf(a), ...axesOf(b)]) if (reach(a, n) + reach(b, n) - Math.abs(dot(centres, n)) <= EPSILON) return false; return true; } if (a.kind === 'circle' && b.kind === 'circle') { const r = a.r + b.r; return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < r * r; } const { box, circle } = boxAndCircle(a, b); return distanceToBox(toBoxFrame(box, circle), box.w / 2, box.h / 2) < circle.r - EPSILON; } /** * How two shapes meet: `separation` is the gap between them (negative when they overlap, then its * size is the shortest way out) and `normal` the unit direction from `obstacle` towards `mover` that * gap is measured along – the face that was hit, or the direction from a hit corner. Where the * shapes give no direction (centres on top of each other) it runs against `travel`. */ export function contactBetween( mover: ColliderShape, obstacle: ColliderShape, travel: Vec2, ): { separation: number; normal: Vec2 } { if (!isClassic(mover) || !isClassic(obstacle)) return piecesContact(mover, obstacle, travel); const against = (n: Vec2, offset: number): Vec2 => { const sign = offset !== 0 ? Math.sign(offset) : dot(n, travel) > 0 ? -1 : 1; return { x: sign * n.x + 0, y: sign * n.y + 0 }; }; if (mover.kind === 'box' && obstacle.kind === 'box') { const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y }; let best = { separation: -Infinity, normal: { x: 0, y: 0 } }; for (const n of [...axesOf(mover), ...axesOf(obstacle)]) { const offset = dot(centres, n); const separation = Math.abs(offset) - reach(mover, n) - reach(obstacle, n); const normal = against(n, offset); // On a tie (a corner on a corner) the face the travel runs into more squarely wins. if ( separation > best.separation + EPSILON || (separation > best.separation - EPSILON && dot(normal, travel) < dot(best.normal, travel)) ) best = { separation: Math.max(separation, best.separation), normal }; } return best; } if (mover.kind === 'circle' && obstacle.kind === 'circle') { const d = { x: mover.x - obstacle.x, y: mover.y - obstacle.y }; const length = Math.hypot(d.x, d.y); const normal = length > 0 ? { x: d.x / length, y: d.y / length } : against(unit(travel), 0); return { separation: length - mover.r - obstacle.r, normal }; } const { box, circle } = boxAndCircle(mover, obstacle); const [ux, uy] = axesOf(box); const p = toBoxFrame(box, circle); const hw = box.w / 2; const hh = box.h / 2; // Local direction from the box towards the circle, and the gap along it. let dir: Vec2; let separation: number; if (Math.abs(p.x) > hw || Math.abs(p.y) > hh) { const d = { x: p.x - clamp(p.x, hw), y: p.y - clamp(p.y, hh) }; const length = Math.hypot(d.x, d.y); dir = { x: d.x / length, y: d.y / length }; separation = length - circle.r; } else { // The centre is inside: out through the nearer face. const sign = mover.kind === 'circle' ? 1 : -1; const local = { x: sign * dot(travel, ux), y: sign * dot(travel, uy) }; const byX = hw - Math.abs(p.x) <= hh - Math.abs(p.y); dir = byX ? { x: p.x !== 0 ? Math.sign(p.x) : -Math.sign(local.x) || 1, y: 0 } : { x: 0, y: p.y !== 0 ? Math.sign(p.y) : -Math.sign(local.y) || 1 }; separation = -(byX ? hw - Math.abs(p.x) : hh - Math.abs(p.y)) - circle.r; } const world = { x: dir.x * ux.x + dir.y * uy.x, y: dir.x * ux.y + dir.y * uy.y }; // `world` runs from the box to the circle; the normal runs from the obstacle to the mover. const sign = mover.kind === 'circle' ? 1 : -1; return { separation, normal: { x: sign * world.x + 0, y: sign * world.y + 0 } }; } /** An axis-aligned rectangle in world units, as `shapeBounds` reports it. */ export interface ShapeBounds { left: number; top: number; right: number; bottom: number; } /** * The smallest world rectangle the shape fits in – what a broad phase looks at before working out * an exact contact (#184). A turned box measures its slant, a capsule and a circle their radius, a * polygon its outline (its pieces never reach beyond it). */ export function shapeBounds(shape: ColliderShape): ShapeBounds { const around = (x: number, y: number, hw: number, hh: number) => ({ left: x - hw, top: y - hh, right: x + hw, bottom: y + hh, }); switch (shape.kind) { case 'circle': return around(shape.x, shape.y, shape.r, shape.r); case 'box': return around(shape.x, shape.y, reach(shape, { x: 1, y: 0 }), reach(shape, { x: 0, y: 1 })); case 'capsule': return { left: Math.min(shape.a.x, shape.b.x) - shape.r, top: Math.min(shape.a.y, shape.b.y) - shape.r, right: Math.max(shape.a.x, shape.b.x) + shape.r, bottom: Math.max(shape.a.y, shape.b.y) + shape.r, }; case 'polygon': return boundsOfPoints(outlinesOf(shape).flat()); } } function boundsOfPoints(points: readonly Vec2[]): ShapeBounds { const bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }; for (const p of points) { bounds.left = Math.min(bounds.left, p.x); bounds.top = Math.min(bounds.top, p.y); bounds.right = Math.max(bounds.right, p.x); bounds.bottom = Math.max(bounds.bottom, p.y); } return bounds; } /** The rectangle grown by `pad` on every side. */ export function grownBounds(b: ShapeBounds, pad: number): ShapeBounds { return { left: b.left - pad, top: b.top - pad, right: b.right + pad, bottom: b.bottom + pad }; } /** Whether two rectangles share any area or edge – touching counts, so nothing near is dropped. */ export function boundsMeet(a: ShapeBounds, b: ShapeBounds): boolean { return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom; } /** How deep two shapes overlap by the measure `shapesOverlap` uses: 0 when they only touch. */ export function overlapDepth(separation: number): number { return separation < -EPSILON ? -separation : 0; } function clamp(v: number, half: number): number { return Math.min(half, Math.max(-half, v)); } function unit(v: Vec2): Vec2 { const length = Math.hypot(v.x, v.y); return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 1, y: 0 }; } /** * How far `mover` has to travel along `direction` until it just clears `obstacle`; 0 when they are * apart already. This is what stops a moving object at a wall: it is pushed back against its * travel by exactly this much. */ export function exitDistance(mover: ColliderShape, obstacle: ColliderShape, direction: Vec2): number { const length = Math.hypot(direction.x, direction.y); if (length === 0 || !shapesOverlap(mover, obstacle)) return 0; const d = { x: direction.x / length, y: direction.y / length }; if (!isClassic(mover) || !isClassic(obstacle)) { // Every overlapping pair of pieces has to come clear; once out, a pair stays out along the line. const others = piecesOf(obstacle); let exit = 0; for (const p of piecesOf(mover)) for (const q of others) exit = Math.max(exit, roundedExit(p, q, d)); return exit; } if (mover.kind === 'box' && obstacle.kind === 'box') return boxesExit(mover, obstacle, d); if (mover.kind === 'circle' && obstacle.kind === 'circle') { const sep = { x: mover.x - obstacle.x, y: mover.y - obstacle.y }; const r = mover.r + obstacle.r; const along = dot(sep, d); return -along + Math.sqrt(Math.max(0, along * along - dot(sep, sep) + r * r)); } const { box, circle } = boxAndCircle(mover, obstacle); // Seen from the box: the circle's centre travels along `d` (or against it when the box moves). const [ux, uy] = axesOf(box); const sign = mover.kind === 'circle' ? 1 : -1; const v = { x: sign * dot(d, ux), y: sign * dot(d, uy) }; const start = toBoxFrame(box, circle); return circleExit(start, v, box.w / 2, box.h / 2, circle.r); } /** * The travel along `d` at which the first separating axis opens – for two boxes that is the exact * moment they stop overlapping. Along an axis the boxes may be moving apart or, when the mover is * already past the obstacle's centre, deeper in and out the far side. */ function boxesExit(mover: Box, obstacle: Box, d: Vec2): number { const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y }; let best = Infinity; for (const n of [...axesOf(mover), ...axesOf(obstacle)]) { const speed = dot(d, n); if (Math.abs(speed) < 1e-12) continue; const span = reach(mover, n) + reach(obstacle, n); const offset = dot(centres, n); const apart = offset === 0 || Math.sign(speed) === Math.sign(offset); const t = (apart ? span - Math.abs(offset) : span + Math.abs(offset)) / Math.abs(speed); if (t < best) best = t; } return Number.isFinite(best) ? best : 0; } /** * A circle's centre starting at `start` (inside the box grown by `r`) moving along `v` in the box's * frame: the travel at which it is `r` away from the box. The distance to a box is convex along a * line, so the crossing is found by bisection – exact for sides and corners alike. */ function circleExit(start: Vec2, v: Vec2, hw: number, hh: number, r: number): number { const outside = (t: number) => distanceToBox({ x: start.x + v.x * t, y: start.y + v.y * t }, hw, hh) - r; let hi = 1; for (let i = 0; i < 64 && outside(hi) <= 0; i++) hi *= 2; let lo = 0; for (let i = 0; i < 60; i++) { const mid = (lo + hi) / 2; if (outside(mid) <= 0) lo = mid; else hi = mid; } return hi; } /** * The contact of two shapes made of pieces: the pair of pieces that overlaps deepest (or lies * closest); on a tie see `preferredOnTie`. A shape without pieces – a * polygon that is none – meets nothing. */ function piecesContact(mover: ColliderShape, obstacle: ColliderShape, travel: Vec2): Contact { let best: Contact = { separation: Infinity, normal: { x: 0, y: 0 } }; const others = piecesOf(obstacle); for (const p of piecesOf(mover)) for (const q of others) { const contact = roundedContact(p, q, travel); if ( contact.separation < best.separation - EPSILON || (contact.separation < best.separation + EPSILON && preferredOnTie(contact.normal, best.normal, travel)) ) best = { separation: Math.min(contact.separation, best.separation), normal: contact.normal }; } return best; } function boxAndCircle(a: Classic, b: Classic): { box: Box; circle: Circle } { return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a }; }