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(); /** 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 { 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(); 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, 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)); }