- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
102 lines
4.9 KiB
TypeScript
102 lines
4.9 KiB
TypeScript
/**
|
|
* Collider geometry in world units. Pure functions shared by the editor (drawing, gizmos)
|
|
* and the runtime (CollisionSystem), so both agree on what "overlaps" means.
|
|
*/
|
|
import type { PropertyBag, Vec2 } from '../model/Properties';
|
|
import { defaultsFor } from './PropertySchema';
|
|
|
|
const DEFAULTS = defaultsFor('collider');
|
|
|
|
export type ColliderShape =
|
|
| { kind: 'box'; x: number; y: number; w: number; h: number }
|
|
| { kind: 'circle'; x: number; y: number; r: number };
|
|
|
|
type Box = Extract<ColliderShape, { kind: 'box' }>;
|
|
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
|
|
|
|
/** A movement axis in the plane (`z` is draw order, not geometry). */
|
|
export type PlaneAxis = 'x' | 'y';
|
|
|
|
/** The shape described by collider properties, centred on `centre` (a world position). */
|
|
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
|
|
if (props.shape === 'circle') return { kind: 'circle', x: centre.x, y: centre.y, r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) };
|
|
return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? DEFAULTS.width)), h: Math.max(0, Number(props.height ?? DEFAULTS.height)) };
|
|
}
|
|
|
|
/** Whether the collider only reports overlaps (true) or also blocks movement (false). */
|
|
export function isTrigger(props: PropertyBag): boolean {
|
|
return Boolean(props.isTrigger ?? DEFAULTS.isTrigger);
|
|
}
|
|
|
|
/** Footprint of a collider shape (used for culling and the selection gizmo). */
|
|
export function colliderSize(props: PropertyBag): { w: number; h: number } {
|
|
const shape = colliderShape(props, { x: 0, y: 0 });
|
|
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
|
|
}
|
|
|
|
/** Whether a point lies inside a shape (used for pointer hit tests). */
|
|
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
|
|
if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
|
|
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
|
|
}
|
|
|
|
/** Whether two shapes overlap. Shapes that merely touch along an edge do not overlap. */
|
|
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
|
|
if (a.kind === 'box' && b.kind === 'box') {
|
|
return Math.abs(a.x - b.x) * 2 < a.w + b.w && Math.abs(a.y - b.y) * 2 < a.h + b.h;
|
|
}
|
|
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);
|
|
// Closest point of the box to the circle centre, then a plain distance check.
|
|
const cx = Math.max(box.x - box.w / 2, Math.min(circle.x, box.x + box.w / 2));
|
|
const cy = Math.max(box.y - box.h / 2, Math.min(circle.y, box.y + box.h / 2));
|
|
return (circle.x - cx) ** 2 + (circle.y - cy) ** 2 < circle.r * circle.r;
|
|
}
|
|
|
|
/**
|
|
* The centre distance along `axis` at which the two shapes exactly touch, given their
|
|
* current offset on the other axis. Only meaningful when the shapes overlap on that other
|
|
* axis (otherwise they cannot collide by moving along `axis`); returns 0 when they do not.
|
|
*/
|
|
export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAxis): number {
|
|
const other: PlaneAxis = axis === 'x' ? 'y' : 'x';
|
|
const offset = Math.abs(a[other] - b[other]);
|
|
if (a.kind === 'box' && b.kind === 'box') {
|
|
return offset * 2 < extent(a, other) + extent(b, other) ? (extent(a, axis) + extent(b, axis)) / 2 : 0;
|
|
}
|
|
if (a.kind === 'circle' && b.kind === 'circle') {
|
|
const r = a.r + b.r;
|
|
return offset < r ? Math.sqrt(r * r - offset * offset) : 0;
|
|
}
|
|
const { box, circle } = boxAndCircle(a, b);
|
|
const halfOther = extent(box, other) / 2;
|
|
const halfAxis = extent(box, axis) / 2;
|
|
// Circle centre within the box's span on the other axis: the flat side is hit.
|
|
if (offset <= halfOther) return halfAxis + circle.r;
|
|
// Otherwise the corner is the closest point: a circle-vs-point solution.
|
|
const corner = offset - halfOther;
|
|
return corner < circle.r ? halfAxis + Math.sqrt(circle.r * circle.r - corner * corner) : 0;
|
|
}
|
|
|
|
/**
|
|
* How far `mover` has to be pushed back against its travel direction `dir` (±1) along
|
|
* `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when
|
|
* `mover` approached from the far side, i.e. it did not overlap before its move.
|
|
*/
|
|
export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: PlaneAxis, dir: 1 | -1): number {
|
|
if (!shapesOverlap(mover, obstacle)) return 0;
|
|
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
|
|
}
|
|
|
|
/** The mixed pair (one box, one circle) sorted by kind. */
|
|
function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } {
|
|
return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a };
|
|
}
|
|
|
|
function extent(box: Box, axis: PlaneAxis): number {
|
|
return axis === 'x' ? box.w : box.h;
|
|
}
|