Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
605 lines
24 KiB
TypeScript
605 lines
24 KiB
TypeScript
/**
|
||
* 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<ColliderShape, { kind: 'box' }>;
|
||
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
|
||
type Polygon = Extract<ColliderShape, { kind: 'polygon' }>;
|
||
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<object, Vec2[][]>();
|
||
|
||
/** 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<string, Vec2> = {
|
||
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 };
|
||
}
|