One-way platforms, raycast and collision stay with contact data: a collider can be one-way with a side it blocks from (turning with its object, drawn thick in the scene view) and a tile can be one-way from above, one-way cells never merge with solid tiles, a contact blocks only when its normal is within 45 degrees of the blocking side, the new node Ignore one-way lets an object drop through for a while, and it all holds for Move and for dynamic bodies in Rapier, where the decision is made before each step because the hook may not touch the engine (#155); the new Raycast node answers hit, object, collider, point, normal and distance in our own f64 geometry, the same in editor run and play, and the editor run draws each ray for 0.25 s (#153); On collision stay gains an interval with its own clock per pair, and stay and enter gain the contact point next to normal and impulse (#149); fixes that every Rapier collider of a tilemap had the shape of its last cell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
947a6b1977
commit
6a31f5e0ad
33 files changed
+2064
-125
No files matched your search
@@ -108,6 +108,15 @@ export interface MoveOutcome {
|
||||
normal: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** What a ray met (#153): the object and the collider (or tilemap), where, the face and how far. */
|
||||
export interface RayOutcome {
|
||||
object: EntityHandle;
|
||||
collider: EntityHandle;
|
||||
point: { x: number; y: number };
|
||||
normal: { x: number; y: number };
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the graph does to the world goes through here, so the core stays free of runtime
|
||||
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
|
||||
@@ -147,6 +156,18 @@ export interface NodeContext {
|
||||
* and the rest of the move goes on along the surface, less what friction takes.
|
||||
*/
|
||||
moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome;
|
||||
/**
|
||||
* The first solid a ray from (x, y) along (dx, dy) meets within `length`, all in world units;
|
||||
* null for none. `ignore` and whatever is parented below it are not met.
|
||||
*/
|
||||
raycast(
|
||||
from: { x: number; y: number },
|
||||
direction: { x: number; y: number },
|
||||
length: number,
|
||||
options: { ignore: EntityHandle | null; triggers: boolean },
|
||||
): RayOutcome | null;
|
||||
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
|
||||
ignoreOneWay(target: EntityHandle, seconds: number): void;
|
||||
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
|
||||
physics?: PhysicsControl;
|
||||
log(message: string): void;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asEntity, asNumber } from '../../values';
|
||||
import { exec, num, obj, withDefault } from '../shared';
|
||||
|
||||
export const IgnoreOneWay: NodeDef = {
|
||||
type: 'IgnoreOneWay',
|
||||
label: 'Ignore one-way',
|
||||
category: 'action',
|
||||
description:
|
||||
'Lets an object fall through one-way colliders and one-way tiles for the given seconds – drop down ' +
|
||||
'from a platform. Once they are over, it still passes whatever it is inside of, and lands again on ' +
|
||||
'the next one it comes down on. Works for Move with "Collide with solids" and for dynamic rigidbodies.',
|
||||
inputs: [exec(), obj('object'), withDefault(num('seconds'), 0.25)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) ctx.ignoreOneWay(target, asNumber(inputs.seconds));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber } from '../../values';
|
||||
import { bool, exec, num, obj, withDefault } from '../shared';
|
||||
|
||||
const MISSED = {
|
||||
hit: false,
|
||||
object: null,
|
||||
collider: null,
|
||||
pointX: 0,
|
||||
pointY: 0,
|
||||
normalX: 0,
|
||||
normalY: 0,
|
||||
distance: 0,
|
||||
};
|
||||
|
||||
export const Raycast: NodeDef = {
|
||||
type: 'Raycast',
|
||||
label: 'Raycast',
|
||||
category: 'action',
|
||||
description:
|
||||
'Casts a ray from (from x, from y) along (dir x, dir y) for up to length world units and says what ' +
|
||||
'it meets first: the object, its collider (or the tilemap), where, the normal of the face (pointing ' +
|
||||
'out of it) and how far. Solid colliders and tilemap cells are met, triggers only with "Hit ' +
|
||||
'triggers", a one-way one only from the side it blocks from. What the ray starts inside is not met, ' +
|
||||
'and with "Ignore owner" neither is the owner or anything parented below it.',
|
||||
inputs: [
|
||||
exec(),
|
||||
num('fromX', 'from x'),
|
||||
num('fromY', 'from y'),
|
||||
withDefault(num('dirX', 'dir x'), 1),
|
||||
num('dirY', 'dir y'),
|
||||
withDefault(num('length'), 1000),
|
||||
],
|
||||
outputs: [
|
||||
exec(),
|
||||
bool('hit'),
|
||||
obj('object'),
|
||||
obj('collider'),
|
||||
num('pointX', 'point x'),
|
||||
num('pointY', 'point y'),
|
||||
num('normalX', 'normal x'),
|
||||
num('normalY', 'normal y'),
|
||||
num('distance'),
|
||||
],
|
||||
params: [
|
||||
{ key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true },
|
||||
{ key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false },
|
||||
],
|
||||
run: (ctx, inputs, params) => {
|
||||
const hit = ctx.raycast(
|
||||
{ x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) },
|
||||
{ x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) },
|
||||
asNumber(inputs.length),
|
||||
{
|
||||
ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null,
|
||||
triggers: asBoolean(params.triggers ?? false),
|
||||
},
|
||||
);
|
||||
if (!hit) return { next: 'exec', outputs: MISSED };
|
||||
return {
|
||||
next: 'exec',
|
||||
outputs: {
|
||||
hit: true,
|
||||
object: hit.object,
|
||||
collider: hit.collider,
|
||||
pointX: hit.point.x,
|
||||
pointY: hit.point.y,
|
||||
normalX: hit.normal.x,
|
||||
normalY: hit.normal.y,
|
||||
distance: hit.distance,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -7,8 +7,8 @@ export const OnCollisionEnter: NodeDef = {
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a collider of the owner starts overlapping another collider. While playing, ' +
|
||||
'normal x/y point from the other collider towards the own one and impulse says how hard they met ' +
|
||||
'(both 0 for a trigger).',
|
||||
'normal x/y point from the other collider towards the own one, impulse says how hard they met and ' +
|
||||
'point x/y where (all 0 for a trigger).',
|
||||
inputs: [],
|
||||
outputs: [...collisionOutputs(), ...contactOutputs()],
|
||||
params: [],
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { collisionOutputs } from '../shared';
|
||||
import { collisionOutputs, contactOutputs } from '../shared';
|
||||
|
||||
export const OnCollisionStay: NodeDef = {
|
||||
type: 'OnCollisionStay',
|
||||
label: 'On collision stay',
|
||||
category: 'event',
|
||||
description: 'Runs every frame while a collider of the owner overlaps another collider.',
|
||||
description:
|
||||
'Runs while a collider of the owner overlaps another collider: every frame, or with an interval once ' +
|
||||
'each time that long has passed since they met. While playing, normal x/y, impulse and point x/y tell ' +
|
||||
'how they touch right now (all 0 for a trigger and in the editor run).',
|
||||
inputs: [],
|
||||
outputs: collisionOutputs(),
|
||||
params: [],
|
||||
outputs: [...collisionOutputs(), ...contactOutputs()],
|
||||
params: [
|
||||
{
|
||||
key: 'interval',
|
||||
label: 'Interval (s, 0 = every frame)',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 0.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animation
|
||||
import { Emit } from './action/Emit';
|
||||
import { LoadScene } from './action/LoadScene';
|
||||
import { Log } from './action/Log';
|
||||
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
||||
import { Move } from './action/Move';
|
||||
import {
|
||||
ApplyForce,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SetVelocity,
|
||||
} from './action/physicsNodes';
|
||||
import { PlaySound } from './action/PlaySound';
|
||||
import { Raycast } from './action/Raycast';
|
||||
import { RestartGame } from './action/RestartGame';
|
||||
import { SetPosition } from './action/SetPosition';
|
||||
import { Spawn } from './action/Spawn';
|
||||
@@ -218,6 +220,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
SetVelocity,
|
||||
SetAngularVelocity,
|
||||
SetGravity,
|
||||
Raycast,
|
||||
IgnoreOneWay,
|
||||
Spawn,
|
||||
Destroy,
|
||||
Emit,
|
||||
|
||||
@@ -32,13 +32,16 @@ export const collisionOutputs = (): PortDef[] => [
|
||||
];
|
||||
|
||||
/**
|
||||
* What only the start of a contact knows: which way the other side pushes (a unit vector pointing
|
||||
* from it towards the own collider) and how hard they met. The physics engine works both out (#146).
|
||||
* How a contact touches: which way the other side pushes (a unit vector pointing from it towards the
|
||||
* own collider), how hard, and the middle of the contact points in the world. The physics engine
|
||||
* works them out (#146, #149).
|
||||
*/
|
||||
export const contactOutputs = (): PortDef[] => [
|
||||
num('normalX', 'normal x'),
|
||||
num('normalY', 'normal y'),
|
||||
num('impulse'),
|
||||
num('pointX', 'point x'),
|
||||
num('pointY', 'point y'),
|
||||
];
|
||||
|
||||
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||
|
||||
@@ -20,6 +20,8 @@ export const TILE_FPS_KEY = 'fps';
|
||||
export const TILE_PLAYBACK_KEY = 'playback';
|
||||
/** Whether a cell of this tile blocks movement in the running game (#132). */
|
||||
export const TILE_SOLID_KEY = 'solid';
|
||||
/** Whether a solid tile only blocks from above, as a platform one jumps through from below (#155). */
|
||||
export const TILE_ONE_WAY_KEY = 'oneWay';
|
||||
/** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */
|
||||
export const TILE_COLLISION_SCALE_KEY = 'collisionScale';
|
||||
export const TILE_COLLISION_SCALE_MIN = 0.1;
|
||||
@@ -49,6 +51,10 @@ export function isTileSolid(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_SOLID_KEY] === true;
|
||||
}
|
||||
|
||||
export function isTileOneWay(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_ONE_WAY_KEY] === true;
|
||||
}
|
||||
|
||||
/** The collision scale, held to the range the field offers; anything unreadable is the whole cell. */
|
||||
export function tileCollisionScale(asset: TileAsset): number {
|
||||
const scale = Number(asset.props[TILE_COLLISION_SCALE_KEY] ?? 1);
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
* autotile's cell is pieced together from four quarters of its block, picked by the neighbours – so
|
||||
* its outlines are cut along the same quarters, and each cell takes the pieces of exactly the
|
||||
* quarters it is drawn from (see `tileShape`).
|
||||
*
|
||||
* One-way tiles (#155) block only from above. They are worked out on their own, by the same rules –
|
||||
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle.
|
||||
*/
|
||||
import type { TileAsset, TilemapAsset } from './Asset';
|
||||
import { tileQuadrants } from './autotile';
|
||||
@@ -25,7 +28,7 @@ import { clipToRect, convexPieces, signedArea } from './polygon';
|
||||
import type { PropertyBag, Vec2 } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { findFrame } from './texture';
|
||||
import { isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile';
|
||||
import { isTileOneWay, isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile';
|
||||
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
import type { ColliderShape } from '../properties/collider';
|
||||
|
||||
@@ -169,13 +172,20 @@ function placed(outline: TileOutline, x: number, y: number, size: number): TileO
|
||||
return { outline: outline.outline.map(place), pieces: outline.pieces.map((piece) => piece.map(place)) };
|
||||
}
|
||||
|
||||
function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] {
|
||||
function blockingCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay: boolean,
|
||||
): CellBlock[] {
|
||||
const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
|
||||
const tileOf = (id: string) => {
|
||||
if (tiles.has(id)) return tiles.get(id)!;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
const value =
|
||||
tile && isTileSolid(tile) ? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) } : null;
|
||||
tile && isTileSolid(tile) && isTileOneWay(tile) === oneWay
|
||||
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
||||
: null;
|
||||
tiles.set(id, value);
|
||||
return value;
|
||||
};
|
||||
@@ -225,8 +235,13 @@ function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset
|
||||
* as any does. An autotile cell built only from quarters that block whole is a box too, at scale 1.
|
||||
* A cell whose only solid tiles block with outlines is left to `solidPolygons`.
|
||||
*/
|
||||
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
|
||||
return blockingCells(project, props, asset)
|
||||
export function solidCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay = false,
|
||||
): Required<SolidCell>[] {
|
||||
return blockingCells(project, props, asset, oneWay)
|
||||
.filter((cell) => cell.box !== null)
|
||||
.map(({ x, y, box }) => ({ x, y, scale: box! }));
|
||||
}
|
||||
@@ -237,9 +252,14 @@ export function solidCells(project: Project, props: PropertyBag, asset: TilemapA
|
||||
* blocks whole. A tile blocking as a box on another layer of the same cell covers the whole cell
|
||||
* already, so there the outlines are left out.
|
||||
*/
|
||||
export function solidPolygons(project: Project, props: PropertyBag, asset: TilemapAsset): CellPolygon[] {
|
||||
export function solidPolygons(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay = false,
|
||||
): CellPolygon[] {
|
||||
const polygons: CellPolygon[] = [];
|
||||
for (const cell of blockingCells(project, props, asset)) {
|
||||
for (const cell of blockingCells(project, props, asset, oneWay)) {
|
||||
if (cell.box !== null) continue;
|
||||
for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces });
|
||||
}
|
||||
@@ -346,8 +366,13 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||||
}
|
||||
|
||||
/** The blocking rectangles of a map, in cells. */
|
||||
export function solidRects(project: Project, props: PropertyBag, asset: TilemapAsset): CellRect[] {
|
||||
return mergeCells(solidCells(project, props, asset));
|
||||
export function solidRects(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay = false,
|
||||
): CellRect[] {
|
||||
return mergeCells(solidCells(project, props, asset, oneWay));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TILE_COLLISION_SCALE_MIN,
|
||||
TILE_FPS_KEY,
|
||||
TILE_KIND_KEY,
|
||||
TILE_ONE_WAY_KEY,
|
||||
TILE_PLAYBACK_KEY,
|
||||
TILE_SOLID_KEY,
|
||||
} from '../model/tile';
|
||||
@@ -262,6 +263,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
},
|
||||
// Solid is what the tile *is* – water, a wall – so it holds wherever the tile lies (#132).
|
||||
{ key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false },
|
||||
// A solid tile that only blocks from above, like a platform one jumps through (#155).
|
||||
{ key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false },
|
||||
// Below 1 a cell blocks less than its square, above 1 more – but only on the sides without a
|
||||
// blocking neighbour, so a wall of such cells stays closed (#140).
|
||||
{
|
||||
@@ -373,6 +376,20 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 },
|
||||
{ key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05 },
|
||||
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 },
|
||||
// Blocks only what comes from that side, turned with the object; the rest passes through (#155).
|
||||
{ key: 'oneWay', label: 'One-way', kind: 'boolean', default: false },
|
||||
{
|
||||
key: 'oneWayDirection',
|
||||
label: 'Blocks from',
|
||||
kind: 'enum',
|
||||
default: 'up',
|
||||
options: [
|
||||
{ value: 'up', label: 'Above' },
|
||||
{ value: 'down', label: 'Below' },
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
],
|
||||
},
|
||||
],
|
||||
rigidbody: [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
|
||||
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
|
||||
import type { PropertyBag, PropertyValue, Vec2 } from '../model/Properties';
|
||||
import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties';
|
||||
import {
|
||||
roundedContact,
|
||||
roundedContains,
|
||||
@@ -103,6 +103,59 @@ 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 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A contact normal at most this far from a one-way collider's side (45°) is a landing on it; any
|
||||
* other contact passes through (#155).
|
||||
*/
|
||||
const ONE_WAY_CONE = Math.cos(Math.PI / 4) - 1e-9;
|
||||
|
||||
/**
|
||||
* The world direction a one-way collider blocks from – its top face for the default "up", turned
|
||||
* and mirrored with it – or null for a collider that blocks from everywhere.
|
||||
*/
|
||||
export function oneWayFacing(props: PropertyBag, m: Mat): Vec2 | 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);
|
||||
return length > 0 ? { x: x / length, y: y / length } : 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: Vec2): boolean {
|
||||
return dot(normal, facing) >= ONE_WAY_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),
|
||||
pieces: shape.pieces.map((piece) => piece.map(move)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The size in the collider's own units, before any transform. */
|
||||
export function colliderSize(props: PropertyBag): { w: number; h: number } {
|
||||
// Centred like every footprint, so it reaches as far as the farthest corner on each axis.
|
||||
@@ -149,7 +202,6 @@ function reach(box: Box, n: Vec2): number {
|
||||
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. */
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 { 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[] };
|
||||
|
||||
function dot(a: Vec2, b: Vec2): number {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } };
|
||||
}
|
||||
Reference in new issue
Block a user