Files
feuerwerk/src/runtime/systems/solids.ts
T
adminandClaude Opus 5.5 275e50af01 Let a tile report a touch instead of blocking it (#160)
A tile could be solid, one-way and scaled, but not report without
blocking. Water, lava, spikes or a goal zone needed a trigger object
of their own laid over the map. Now a tile has a switch Trigger, and
the collision events name the tile and the cell they met.

- A tile has the switch "Trigger (reports, never blocks)" under
  Solid. Its cells hold nothing up: no Move, no falling body, no
  ray. They raise On collision enter, stay and exit with the tilemap
  as other.
- The three events have the outputs tile, cell x and cell y. Without
  a tile, tile is empty and the cell is -1, -1.
- The three events have the input per cell. Off, the default, gives
  one event per area: cells of the same tile that touch side by
  side. On gives one event per cell.
- The scene view outlines trigger cells in yellow like a trigger
  collider, merged to rectangles but never with solid cells.
- Is touching with the map counts trigger tiles, and Set tile works
  while the game runs.
- Both systems find the touches with the same overlap check in
  runtime/systems/tileTriggers.ts, not through the physics engine.

Decided here:

- Trigger wins over Solid. A tile with both does not block, as with
  a collider.
- Water beside lava is two areas, and so are two ponds that touch
  only at a corner.
- An area names the cell nearest to the centre of the collider, and
  on exit the cell it named last.
- The solid cells of a map still do not name their tile.
- Normal, impulse and point are 0, as with a trigger collider.
- Collision scale and outlines shape a trigger cell like a solid
  one. One-way, friction and bounciness have no effect.

Tests:
- Gate on stein2: 205 files, 3104 tests (3067 + 37), exit 0
- e2e 39,40: 106 checks. e2e 1-8,39-43: 490 checks. Both on
  melektaus-atx. No full run, the sum of 1648 (1645 + 3) is
  reckoned and CI measures it.
- 37 unit tests in tests/core/tile-triggers.test.ts, nearly all
  against both systems
- Three e2e checks with a real mouse in block 40l-iv
- 24 unit counter-probes fail tests. Two did not at first, tests
  were added for them.
- Two browser counter-probes fail their checks
- Two screenshots looked at (yellow outline, the node with per cell)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:28:39 +02:00

699 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { dot } from '../../core/util/vec';
import { findAssetOf } from '../../core/model/Project';
import { liveTilemap, type TileEdits } from '../../core/model/liveTilemap';
import { identityToken } from '../../core/model/tilemapMemo';
import {
type CellPolygon,
cellPolygonShape,
type CellRect,
cellRectShape,
cellsMeet,
cellsWithin,
oneWayAngles,
solidPolygons,
solidRects,
type TileMaterial,
tileMaterials,
} from '../../core/model/tilemapCollision';
import { parentChain } from '../../core/model/walk';
import {
type CollisionFilter,
collisionFilterOf,
layersMeet,
maskHas,
} from '../../core/model/collisionLayers';
import {
boundsMeet,
type ColliderShape,
colliderShape,
contactBetween,
exitDistance,
grownBounds,
isTrigger,
landsOn,
movedShape,
oneWayCone,
oneWayFacing,
type OneWaySide,
overlapDepth,
shapeBounds,
type ShapeBounds,
shapeExtent,
shapesOverlap,
} from '../../core/properties/collider';
import { rayCast } from '../../core/properties/ray';
import { colliderMaterial } from '../../core/properties/rigidbody';
import type { Entity } from '../Entity';
import type { World } from '../World';
/** Upper bound for the sub-steps of a single sweep, so a huge delta cannot stall a frame. */
const MAX_STEPS = 256;
/** A move is swept this many times at most: into a contact, along it, into the next one, and on. */
const MAX_PASSES = 4;
/** Deepening an overlap by no more than this is rounding, not moving in. */
const DEEPER = 1e-9;
/** A normal this close to square with an axis does not stand against a move along it. */
const SQUARE = 1e-6;
/** What the broad phase adds to the reach of a move, so rounding never drops a shape (#184). */
const REACH_SLACK = 1e-6;
/**
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
* collider or a placed tilemap, which takes part as a whole with its solid cells (#132).
* `normal` points from `b` towards `a`, `impulse` is how hard they met and `point` where; they are
* only known where the physics engine saw the contact begin (#146), and zero elsewhere.
*/
export interface CollisionPair {
a: Entity;
b: Entity;
normal?: Vec2;
impulse?: number;
point?: Vec2;
/** Set where `b` is a map and `a` overlaps a trigger tile of it (#160) – a pair of its own, next to the map's. */
tile?: TileContact;
}
/**
* Which trigger tile of a map a collider overlaps (#160). A pair stands either for one cell or for a
* whole area of the tile; an area names the cell of it nearest to the collider.
*/
export interface TileContact {
tileId: string;
cell: { x: number; y: number };
area: string;
perCell: boolean;
}
/** What a pair is known by from one tick to the next. */
export function pairKey({ a, b, tile }: CollisionPair): string {
const key = `${a.id}|${b.id}`;
if (!tile) return key;
return `${key}|${tile.tileId}|${tile.perCell ? `cell ${tile.cell.x},${tile.cell.y}` : `area ${tile.area}`}`;
}
/**
* What a move ran into: the collider or tilemap in the way, the world direction into it (against
* the contact normal), and how far the whole move wanted to go that way (world units).
*/
export interface Blocked {
by: Entity;
along: Vec2;
distance: number;
}
/**
* What `Move` with "Collide with solids" reports: which of the object's own axes something stood
* against, whether it hit anything, and the normal of the first contact (world, pointing away from
* what was hit; zero without a hit).
*/
export interface MoveResult {
blockedX: boolean;
blockedY: boolean;
hit: boolean;
normal: Vec2;
}
export const NOT_BLOCKED: MoveResult = Object.freeze({
blockedX: false,
blockedY: false,
hit: false,
normal: Object.freeze({ x: 0, y: 0 }),
});
/**
* A shape of a collider or tilemap, and for a one-way one the world direction it blocks from (#155):
* only a contact whose normal lies within its angle of it stops anything (#159).
*/
export interface SolidShape {
shape: ColliderShape;
facing: OneWaySide | null;
/** A tilemap cell's surface (#193); a collider's is read off its own props (see `surfaceOf`). */
surface?: TileMaterial;
}
/** A shape in the mover's way and the collider or tilemap it belongs to. */
interface Obstacle extends SolidShape {
body: Entity;
/** Which layers it is on and meets (#152) – the same for every shape of one tilemap. */
filter: CollisionFilter;
}
/** One of the mover's own colliders, with the layers it may meet. */
interface Mover {
collider: Entity;
filter: CollisionFilter;
}
/** What a ray met (#153): the collider or tilemap, where, the face's normal and how far along the ray. */
export interface RayResult {
collider: Entity;
point: Vec2;
normal: Vec2;
distance: number;
}
/**
* Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked,
* and only what sits on a layer of `layers` (#158). That last one is one-sided on purpose – a ray is
* not a collider, so the collider's own "Collides with" has no say over who may look at it.
*/
export interface RayFilter {
ignore: Entity | null;
triggers: boolean;
layers: number;
}
/** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */
interface SweepContact {
by: Entity;
/** The surface of the shape met when it is a tile's (#193). */
surface?: TileMaterial;
own: Entity;
normal: Vec2;
left: Vec2;
}
/** What the behaviors read of collisions, whichever system works them out. */
export interface CollisionSource {
readonly enters: CollisionPair[];
readonly stays: CollisionPair[];
readonly exits: CollisionPair[];
touching(world: World, a: Entity, b: Entity): boolean;
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult;
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null;
/** Every solid on the line, nearest first; `maxHits` above 0 keeps that many of them (#157). */
raycastAll(
world: World,
from: Vec2,
direction: Vec2,
length: number,
filter: RayFilter,
maxHits?: number,
): RayResult[];
/**
* How a pair that stays in contact touches right now (#149): the normal from `b` towards `a`, the
* impulse of the last physics step and the middle of the contact points; null where nothing
* simulates the contact (the editor run).
*/
contactOf(pair: CollisionPair): Contact | null;
}
/** A contact as the physics engine knows it (see `CollisionSource.contactOf`). */
export interface Contact {
normal: Vec2;
impulse: number;
point: Vec2;
}
/**
* The world as `Move` with "Collide with solids" sees it: the shapes of every collider and every
* placed tilemap, and the move that stops at them and slides along.
*/
export class Solids {
/**
* The blocking rectangles and outlined cells of each tilemap, in cells. Worked out once per tick –
* painting is picked up by the next one, a behavior's `Set tile` at once – and turned into world
* shapes only when asked, since the map may move in between.
*/
private blocks = new Map<Entity, { edits: TileEdits | undefined; blocks: TilemapBlocks }>();
/** Forgets the cells, so the next tick works them out afresh. */
reset(): void {
this.blocks = new Map();
}
private blocksOf(world: World, tilemap: Entity): TilemapBlocks {
let held = this.blocks.get(tilemap);
// A `Set tile` replaces the edits, so a move later in the same tick already meets the new cell.
if (!held || held.edits !== tilemap.tileEdits) {
const stored = findAssetOf(world.project, tilemap.assetId, 'tilemap');
const asset = stored && liveTilemap(stored, tilemap.tileEdits);
const { project } = world;
const { props } = tilemap;
// One group per side and surface, so cells only merge with cells that block alike (#159, #193).
const groups = (angle: number | null) =>
tileMaterials(project, asset!, angle ?? false).map((material) => ({
angle,
material,
rects: solidRects(project, props, asset!, angle ?? false, material),
polygons: solidPolygons(project, props, asset!, angle ?? false, material),
}));
const blocks = asset ? [...groups(null), ...oneWayAngles(project, asset).flatMap(groups)] : [];
held = { edits: tilemap.tileEdits, blocks };
this.blocks.set(tilemap, held);
}
return held.blocks;
}
/**
* Stands for the map's blocking cells: the same as long as they are, since the same cells hand out
* the very same rectangles and polygons (`tilemapCollision.ts`).
*/
blocksToken(world: World, tilemap: Entity): string {
return this.blocksOf(world, tilemap)
.map(({ angle, material, rects, polygons }) =>
[angle, material.friction, material.bounciness, identityToken(rects), identityToken(polygons)].join(
':',
),
)
.join(',');
}
/**
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
* rectangle and one polygon per outlined cell (#141), the one-way cells blocking from the map's
* top (#155) within their tile's angle (#159). A tilemap placed before the first tick is worked out
* on the spot.
*/
solidShapesOf(world: World, body: Entity, within?: ShapeBounds): SolidShape[] {
const m = worldMatrixOf(body);
if (body.type !== 'tilemap')
return [
{ shape: colliderShape(body.props, m, body.wornFrame?.()), facing: oneWayFacing(body.props, m) },
];
const up = oneWayFacing({ oneWay: true }, m);
// A map is many solids under one entity, so the cells out of reach are dropped before any of
// them is worked out in world units at all (#184).
const range = within ? cellsWithin(body.props, m, within) : null;
const near = (x: number, y: number, w?: number, h?: number) => !range || cellsMeet(range, x, y, w, h);
return this.blocksOf(world, body).flatMap(({ angle, material: surface, rects, polygons }) => {
const facing = angle === null ? null : up && { ...up, cone: oneWayCone(angle) };
return [
...rects
.filter((rect) => near(rect.x, rect.y, rect.w, rect.h))
.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing, surface })),
...polygons
.filter((polygon) => near(polygon.x, polygon.y))
.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing, surface })),
];
});
}
shapesOf(world: World, body: Entity, within?: ShapeBounds): ColliderShape[] {
return this.solidShapesOf(world, body, within).map(({ shape }) => shape);
}
/**
* The first solid the ray from `from` along `direction` meets within `length` (world units): any
* blocking collider or tilemap cell on one of the filter's layers (#158), triggers when asked, a
* one-way one only on the side it blocks from. What the ray starts inside is not met (see `rayCast`).
*/
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
const d = unitOf(direction);
if ((d.x === 0 && d.y === 0) || !(length > 0)) return null;
const area = rayBounds(from, d, length);
let best: RayResult | null = null;
for (const body of this.rayBodies(world, filter)) {
for (const { shape, facing } of this.solidShapesOf(world, body, area)) {
const hit = rayCast(shape, from, d, best?.distance ?? length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
if (best && hit.distance >= best.distance) continue;
best = this.rayResult(body, from, d, hit);
}
}
return best;
}
/**
* Everything the ray meets within `length`, nearest first (#157). One entry per collider – the
* first face it enters, however many convex pieces it is made of – and one per tilemap cell the
* ray goes through, since a map is many solids under one entity. `maxHits` above 0 keeps that many
* of the nearest and stops there; the filter is the one `raycast` uses.
*/
raycastAll(
world: World,
from: Vec2,
direction: Vec2,
length: number,
filter: RayFilter,
maxHits = 0,
): RayResult[] {
const d = unitOf(direction);
if ((d.x === 0 && d.y === 0) || !(length > 0)) return [];
const area = rayBounds(from, d, length);
const hits: RayResult[] = [];
for (const body of this.rayBodies(world, filter)) {
// One solid shape is one hit, except for a collider: its pieces are one shape to the world,
// so only the nearest of them counts (a polygon in three parts is not three enemies).
const perShape = body.type === 'tilemap';
let nearest: RayResult | null = null;
for (const { shape, facing } of this.solidShapesOf(world, body, area)) {
const hit = rayCast(shape, from, d, length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
const result = this.rayResult(body, from, d, hit);
if (perShape) hits.push(result);
else if (!nearest || result.distance < nearest.distance) nearest = result;
}
if (nearest) hits.push(nearest);
}
hits.sort((a, b) => a.distance - b.distance);
return maxHits > 0 ? hits.slice(0, maxHits) : hits;
}
/** The bodies a ray is allowed to look at, in world order (see `RayFilter`). */
private *rayBodies(world: World, filter: RayFilter): Generator<Entity> {
for (const body of world.entities) {
if (!isCollisionBody(body) || !isHandleEnabled(body)) continue;
if (body.type === 'collider' && isTrigger(body.props) && !filter.triggers) continue;
if (filter.ignore && travelsWith(body, filter.ignore)) continue;
if (!maskHas(filter.layers, collisionFilterOf(body.props).layer)) continue;
yield body;
}
}
private rayResult(body: Entity, from: Vec2, d: Vec2, hit: { normal: Vec2; distance: number }): RayResult {
return {
collider: body,
point: { x: from.x + d.x * hit.distance, y: from.y + d.y * hit.distance },
normal: hit.normal,
distance: hit.distance,
};
}
/**
* Moves the object towards (dx, dy) and, where a solid is in the way, stops flush at it and goes
* on along its surface: what is left of the move loses the part into the surface, and friction
* takes off the part along it in proportion (Coulomb) – so a circle rolls off an edge, and a
* pressed object slides the less the rougher the pair (#150). An existing overlap is never
* resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
* (dx, dy) are in the entity's own axes: under a turned parent, "x" runs wherever that parent
* points it. `onBlocked` hears each solid the move ran into, once.
*/
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
onBlocked?: (blocked: Blocked) => void,
): MoveResult {
const own: Mover[] = blockingCollidersOf(world, entity).map((collider) => ({
collider,
filter: collisionFilterOf(collider.props),
}));
const frame = parentFrame(entity);
const wanted = applyVector(frame, { x: dx, y: dy });
if (own.length === 0) {
entity.x += dx;
entity.y += dy;
return NOT_BLOCKED;
}
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole
// move. Only what the move can reach is kept: sliding never lengthens what is left, so the
// mover never gets farther from where it starts than the whole move is long (#184).
const reach = own.map((a) =>
grownBounds(shapeBounds(shapeOf(a.collider)), Math.hypot(wanted.x, wanted.y) + REACH_SLACK),
);
const area = reach.reduce(unionBounds);
const obstacles: Obstacle[] = world.entities
.filter((c) => blocksMover(c, entity))
.flatMap((body) => {
const filter = collisionFilterOf(body.props);
if (!own.some((a) => layersMeet(a.filter, filter))) return [];
return this.solidShapesOf(world, body, area)
.filter((solid) => reach.some((near) => boundsMeet(near, shapeBounds(solid.shape))))
.map((solid) => ({ ...solid, body, filter }));
});
// A one-way solid the mover is inside already, or told to fall through, lets it pass for the
// whole move: it is on its way through, from below or from the side.
const ignoring = world.ignoresOneWay(entity);
const passing = new Set(
obstacles.filter(
(o) =>
o.facing &&
(ignoring || own.some((a) => meets(a, o) && shapesOverlap(shapeOf(a.collider), o.shape))),
),
);
const contacts: SweepContact[] = [];
let rest = wanted;
for (let pass = 0; pass < MAX_PASSES && (rest.x !== 0 || rest.y !== 0); pass++) {
const contact = sweep(entity, frame, own, obstacles, passing, rest);
if (!contact) break;
contacts.push(contact);
rest = slideAlong(
contact.left,
contact.normal,
frictionBetween(contact.own, contact.by, contact.surface),
);
}
if (contacts.length === 0) return NOT_BLOCKED;
const pushed = new Set<Entity>();
for (const { by, normal } of contacts) {
if (pushed.has(by)) continue;
pushed.add(by);
const along = { x: -normal.x, y: -normal.y };
onBlocked?.({ by, along, distance: Math.max(0, dot(wanted, along)) });
}
const against = (axis: Vec2, delta: number) =>
delta !== 0 && contacts.some(({ normal }) => Math.sign(delta) * dot(normal, unitOf(axis)) < -SQUARE);
const first = contacts[0]!.normal;
return {
blockedX: against({ x: frame.a, y: frame.b }, dx),
blockedY: against({ x: frame.c, y: frame.d }, dy),
hit: true,
normal: { x: first.x || 0, y: first.y || 0 },
};
}
}
/**
* The linear map from the entity's own axes to the world: its parent's. A parent scaled to nothing
* leaves no direction to move in, so there the world's axes stand in.
*/
function parentFrame(entity: Entity): Mat {
const parent = entity.parent ? linearOf(worldMatrixOf(entity.parent)) : IDENTITY;
return Math.abs(determinant(parent)) < 1e-12 ? IDENTITY : parent;
}
/**
* Moves the entity along `move` (world) in steps no solid is thinner than, until a step runs into
* one: then it is set back flush against it and the contact is reported with what is left of the move.
* A one-way solid met anywhere but on its blocking side joins `passing` instead.
*/
function sweep(
entity: Entity,
frame: Mat,
own: Mover[],
obstacles: Obstacle[],
passing: Set<Obstacle>,
move: Vec2,
): SweepContact | null {
const distance = Math.hypot(move.x, move.y);
const forward = { x: move.x / distance, y: move.y / distance };
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, forward))));
const step = distance / steps;
const toLocal = invert(frame);
const shift = (length: number) => {
const local = applyVector(toLocal, { x: forward.x * length, y: forward.y * length });
entity.x += local.x;
entity.y += local.y;
};
for (let i = 0; i < steps; i++) {
// How deep each pair already overlaps. A step may never deepen that, but resolving it is not
// our job (no jolt, and leaving a wall is always possible).
const before = own.map((a) => {
const mover = shapeOf(a.collider);
return obstacles.map(({ shape }) => overlapDepth(contactBetween(mover, shape, forward).separation));
});
shift(step);
let correction = 0;
let found: { own: Entity; obstacle: Obstacle } | null = null;
for (const [k, a] of own.entries()) {
const mover = shapeOf(a.collider);
for (const [j, obstacle] of obstacles.entries()) {
if (passing.has(obstacle) || !meets(a, obstacle)) continue;
const was = before[k]![j]!;
if (overlapDepth(contactBetween(mover, obstacle.shape, forward).separation) <= was + DEEPER) continue;
// Coming from outside, the step goes back exactly to where the two met; from inside, all
// of it is taken back.
const back =
was === 0
? Math.min(step, exitDistance(mover, obstacle.shape, { x: -forward.x, y: -forward.y }))
: step;
if (obstacle.facing) {
const met = movedShape(mover, (p) => ({ x: p.x - forward.x * back, y: p.y - forward.y * back }), 0);
if (!landsOn(contactBetween(met, obstacle.shape, forward).normal, obstacle.facing)) {
passing.add(obstacle);
continue;
}
}
if (back > correction || !found) {
correction = back;
found = { own: a.collider, obstacle };
}
}
}
if (!found) continue;
const { own: collider, obstacle } = found;
shift(-correction);
const left = distance - i * step - (step - correction);
return {
by: obstacle.body,
surface: obstacle.surface,
own: collider,
normal: contactBetween(shapeOf(collider), obstacle.shape, forward).normal,
left: { x: forward.x * left, y: forward.y * left },
};
}
return null;
}
/**
* What of a move goes on after a contact: nothing into the surface, and along it what friction
* leaves – shortened by `friction` times the part that pressed into the surface, down to nothing.
*/
function slideAlong(move: Vec2, normal: Vec2, friction: number): Vec2 {
const into = dot(move, normal);
if (into >= 0) return move;
const along = { x: move.x - into * normal.x, y: move.y - into * normal.y };
const length = Math.hypot(along.x, along.y);
const kept = length - friction * -into;
if (kept <= 0 || length === 0) return { x: 0, y: 0 };
return { x: (along.x * kept) / length, y: (along.y * kept) / length };
}
/**
* The friction of a pair, as the physics engine combines it for its bodies (the average), so an
* object moved by the graph slides as far as one the engine moves. A tile brings its own (#193).
*/
function frictionBetween(own: Entity, obstacle: Entity, surface?: TileMaterial): number {
return (surfaceOf(own).friction + surfaceOf(obstacle, surface).friction) / 2;
}
/** What a shape of `body` offers to what meets it: a tile's own surface, else the collider's material. */
export function surfaceOf(body: Entity, surface?: TileMaterial): TileMaterial {
return surface ?? colliderMaterial(body.type === 'collider' ? body.props : {});
}
/** The rectangle around every shape, or null without one – nothing to narrow anything down with. */
export function boundsAround(shapes: readonly ColliderShape[]): ShapeBounds | undefined {
return shapes.length > 0 ? shapes.map(shapeBounds).reduce(unionBounds) : undefined;
}
/** The rectangle around both rectangles. */
function unionBounds(a: ShapeBounds, b: ShapeBounds): ShapeBounds {
return {
left: Math.min(a.left, b.left),
top: Math.min(a.top, b.top),
right: Math.max(a.right, b.right),
bottom: Math.max(a.bottom, b.bottom),
};
}
/** The rectangle around a ray, so a map only works out the cells it runs through (#184). */
function rayBounds(from: Vec2, d: Vec2, length: number): ShapeBounds {
const to = { x: from.x + d.x * length, y: from.y + d.y * length };
return grownBounds(
{
left: Math.min(from.x, to.x),
top: Math.min(from.y, to.y),
right: Math.max(from.x, to.x),
bottom: Math.max(from.y, to.y),
},
REACH_SLACK,
);
}
function unitOf(v: Vec2): Vec2 {
const length = Math.hypot(v.x, v.y);
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
}
/**
* A map's blocking cells, one group per surface (#193) and side: `angle` null for the cells that
* block from everywhere, else the angle one-way cells land things within (#159).
*/
type TilemapBlocks = {
angle: number | null;
material: TileMaterial;
rects: readonly CellRect[];
polygons: readonly CellPolygon[];
}[];
export function shapeOf(collider: Entity): ColliderShape {
return colliderShape(collider.props, worldMatrixOf(collider), collider.wornFrame?.());
}
/** What of an entity stops at walls when it moves: its solid colliders that are switched on. */
export function blockingCollidersOf(world: World, entity: Entity): Entity[] {
return collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
}
/** Whether `body` (a collider or a tilemap) stands in the way of `mover`. */
function blocksMover(body: Entity, mover: Entity): boolean {
return (
isCollisionBody(body) &&
!(body.type === 'collider' && isTrigger(body.props)) &&
isHandleEnabled(body) &&
!travelsWith(body, mover)
);
}
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
function travelsWith(collider: Entity, entity: Entity): boolean {
for (const e of parentChain(collider, (c) => c.parent)) if (e === entity) return true;
return false;
}
/**
* Longest safe step in world units: half of the thinnest collider involved, measured along the
* travel as the colliders sit in the world (never below 1).
*/
function stepLimit(own: Mover[], obstacles: Obstacle[], along: Vec2): number {
let min = Infinity;
for (const { collider } of own) min = Math.min(min, shapeExtent(shapeOf(collider), along));
for (const { shape } of obstacles) min = Math.min(min, shapeExtent(shape, along));
return Math.max(1, min / 2);
}
/** Whether the mover's collider and that obstacle are on layers that meet (#152). */
function meets(mover: Mover, obstacle: Obstacle): boolean {
return layersMeet(mover.filter, obstacle.filter);
}
/**
* Whether two colliders (or a collider and a tilemap) may meet at all, by their layers – the one
* rule the physics engine, `Solids` and the event pair search all answer by.
*/
export function collidersMeet(a: Entity, b: Entity): boolean {
return layersMeet(collisionFilterOf(a.props), collisionFilterOf(b.props));
}
/** What takes part in collisions: colliders, and placed tilemaps with their solid cells. */
export function isCollisionBody(entity: Entity): boolean {
return entity.type === 'collider' || entity.type === 'tilemap';
}
/**
* Whose layer an object is on (#211): a collider or tilemap its own, anything else that of its first
* collider child, switched on or not – the layer is set either way.
*/
export function layerHolderOf(world: World, entity: Entity): Entity | undefined {
return isCollisionBody(entity) ? entity : world.childrenOf(entity).find((e) => e.type === 'collider');
}
/**
* The object a collider belongs to: its parent, or the collider itself when top-level. A tilemap
* is its own body wherever it is placed – its cells are the thing, not a shape stuck on one.
*/
export function bodyOf(collider: Entity): Entity {
if (collider.type === 'tilemap') return collider;
return collider.parent ?? collider;
}
/** What a body touches with: a tilemap its cells, anything else its colliders (see `collidersOf`). */
export function partsOf(world: World, entity: Entity): Entity[] {
return entity.type === 'tilemap' ? [entity] : collidersOf(world, entity);
}
/**
* A collider stands for itself; any other entity stands for its direct collider children – a
* tilemap as well when it is the one being moved: its cells only ever take part as an obstacle.
*/
function collidersOf(world: World, entity: Entity): Entity[] {
if (entity.type === 'collider') return [entity];
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
}