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>
699 lines
27 KiB
TypeScript
699 lines
27 KiB
TypeScript
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));
|
||
}
|