import type { Collider, ColliderDesc, EventQueue, PhysicsHooks, RigidBody, World as RapierWorld, } from '@dimforge/rapier2d-compat'; import { isHandleEnabled, worldMatrixOf } from '../../core/graph'; import { apply, determinant, IDENTITY, invert } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; import { parentChain } from '../../core/model/walk'; import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers'; import type { TileMaterial } from '../../core/model/tilemapCollision'; import { type ColliderShape, contactBetween, isTrigger, type OneWaySide, movedShape, shapeArea, shapesOverlap, } from '../../core/properties/collider'; import { hasTransformProps } from '../../core/properties/PropertySchema'; import { type BodyType, colliderMaterial, type RigidbodySettings, rigidbodySettings, sceneGravity, } from '../../core/properties/rigidbody'; import type { Entity } from '../Entity'; import type { World } from '../World'; import { type Blocked, blockingCollidersOf, bodyOf, type CollisionPair, type CollisionSource, type Contact, type MoveResult, partsOf, type RayFilter, type RayResult, shapeOf, type SolidShape, Solids, surfaceOf, } from '../systems/solids'; import type { System } from '../systems/System'; import { angleBetween, blend, matrixPose, type Piece, pieceArea, pieceKey, piecesInBody, type Pose, poseOf, round, samePose, } from './bodyGeometry'; import { type Rapier, toMetres, toPixels } from './engine'; import { passesOneWay, reachOf } from './oneWay'; /** The engine always steps this long, whatever the frame took. */ export const PHYSICS_STEP = 1 / 60; /** After a hitch the world catches up this many steps at most, and forgets the rest. */ const MAX_STEPS_PER_TICK = 8; /** * One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below * it; `free` is a collider of an object without one, a body of its own that goes where the object * is put; `tilemap` is a placed map with a box per merged rectangle of its solid cells. */ interface Body { kind: 'rigid' | 'free' | 'tilemap'; body: RigidBody; /** The engine's colliders – several for a polygon, one per convex piece – whose each is, and its shape. */ colliders: Collider[]; sources: Entity[]; solids: SolidShape[]; /** What the colliders were built from; they are built again when it changes (a scaled object). */ shapeKey: string; settingsKey: string; /** Whether the engine may turn it – what `writeBack` hands on as the object's rotation. */ turns: boolean; /** The mass it carries of its own because no collider gives it one (kg, 0 = none). */ ownMass: number; /** Where the pose was last put, by us or by the object moving. */ placed: Pose; /** A dynamic body's pose before and after the last step, for drawing in between. */ previous: Pose; current: Pose; } /** * An engine collider: the entity it stands for, its world shape at the last sync and, for a one-way * one, the side it blocks from (#155). */ interface Part { entity: Entity; shape: ColliderShape; facing: OneWaySide | null; owner: Body; } /** * The played game's physics on Rapier (#146): rigidbodies fall, bounce and rest, and the collision * events come from the engine's contact pairs. Ticked first, like the collision system it stands in * for, so the behaviors see this frame's events. * * What has no rigidbody keeps behaving as it did before the engine: a collider is a body of its own * that goes wherever the object is put, and whether two such colliders touch is decided by the * editor's own overlap test – the engine only proposes the pairs. Touching without overlapping is * no collision, as it never was. Only where a dynamic body is involved does the engine's contact say it. */ export class PhysicsSystem implements System, CollisionSource { enters: CollisionPair[] = []; stays: CollisionPair[] = []; exits: CollisionPair[] = []; private engine: RapierWorld | null = null; private queue: EventQueue | null = null; private readonly bodies = new Map(); private readonly parts = new Map(); /** Collider handle pairs the engine reports in contact or intersecting, keyed `h1|h2`. */ private readonly candidates = new Map(); private current = new Map(); /** The engine colliders each current pair was found with, `a`'s first – where its contact is read. */ private pairColliders = new Map(); /** * Pairs of a dynamic body and a one-way collider near each other, keyed `h1|h2`: whether the body * passes through (#155). Decided before each step, since the engine's hook must not touch the engine. */ private passes = new Map(); private hooks: PhysicsHooks | null = null; private readonly solids = new Solids(); private backlog = 0; /** The length of the frame being played – the time a move of the graph's is spread over. */ private frameDt = PHYSICS_STEP; private readonly hinted = new Set(); private world: World | null = null; constructor(private readonly rapier: Rapier) {} start(world: World): void { const engine = new this.rapier.World(this.gravity(world)); engine.timestep = PHYSICS_STEP; this.engine = engine; this.queue = new this.rapier.EventQueue(true); this.world = world; const { SolverFlags } = this.rapier; this.hooks = { filterContactPair: (h1, h2) => this.passes.get(handlePairKey(h1, h2)) ? SolverFlags.EMPTY : SolverFlags.COMPUTE_IMPULSE, filterIntersectionPair: () => true, }; } stop(): void { this.engine?.free(); this.queue?.free(); this.engine = null; this.world = null; this.queue = null; this.bodies.clear(); this.parts.clear(); this.candidates.clear(); this.current = new Map(); this.pairColliders = new Map(); this.passes = new Map(); this.hooks = null; this.enters = []; this.stays = []; this.exits = []; this.backlog = 0; this.frameDt = PHYSICS_STEP; this.hinted.clear(); } update(world: World, dt: number): void { const engine = this.engine; if (!engine) return; this.solids.reset(); this.frameDt = dt > 0 ? dt : PHYSICS_STEP; this.enters = []; this.exits = []; this.sync(world); engine.gravity = this.gravity(world); this.backlog += dt; let steps = 0; const entered = new Set(); while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) { for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current; this.decideOneWay(world); engine.step(this.queue!, this.hooks ?? undefined); for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body); this.collect(world, entered); this.backlog -= PHYSICS_STEP; steps++; } this.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog); this.writeBack(this.backlog / PHYSICS_STEP); this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair); } /** Whether any collider of `a` is in contact with any collider of `b` as of the last step. */ touching(world: World, a: Entity, b: Entity): boolean { for (const ca of partsOf(world, a)) for (const cb of partsOf(world, b)) if (this.current.has(`${ca.id}|${cb.id}`) || this.current.has(`${cb.id}|${ca.id}`)) return true; return false; } /** * `Move` with "Collide with solids": the object stops at what is in the way and slides along it, * as in the editor run, and a dynamic body that stops it gets a push (#147). */ moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult { return this.solids.moveAndSlide(world, entity, dx, dy, (blocked) => this.push(world, entity, blocked)); } raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null { return this.solids.raycast(world, from, direction, length, filter); } raycastAll( world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter, maxHits?: number, ): RayResult[] { return this.solids.raycastAll(world, from, direction, length, filter, maxHits); } /** How a current pair touches as of the last step: read from the engine's contact. */ contactOf(pair: CollisionPair): Contact | null { const handles = this.pairColliders.get(`${pair.a.id}|${pair.b.id}`); if (!handles || !this.parts.has(handles[0]) || !this.parts.has(handles[1])) return null; return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1])); } /** A dynamic body's velocity in world units per second; null for an object the engine does not move. */ velocityOf(target: Entity): Vec2 | null { const body = this.dynamicBodyOf(target)?.body; if (!body) return null; const v = body.linvel(); return { x: toPixels(v.x), y: toPixels(v.y) }; } /** Sets a dynamic body's velocity (world units per second); false when the object has none. */ setVelocity(target: Entity, velocity: Vec2): boolean { const body = this.dynamicBodyOf(target)?.body; body?.setLinvel({ x: toMetres(velocity.x), y: toMetres(velocity.y) }, true); return Boolean(body); } /** Degrees per second, counted the way the object's rotation is; null as for `velocityOf`. */ angularVelocityOf(target: Entity): number | null { const body = this.dynamicBodyOf(target); if (!body) return null; return body.turns ? (this.turnSign(target) * body.body.angvel() * 180) / Math.PI : 0; } setAngularVelocity(target: Entity, degrees: number): boolean { const body = this.dynamicBodyOf(target); // With fixed rotation the engine would keep the speed and never turn: nothing to set. if (body?.turns) body.body.setAngvel((this.turnSign(target) * degrees * Math.PI) / 180, true); return Boolean(body); } /** A kick (world units × kg per second): the velocity changes by impulse ÷ mass, at once. */ applyImpulse(target: Entity, impulse: Vec2): boolean { const body = this.dynamicBodyOf(target)?.body; body?.applyImpulse({ x: toMetres(impulse.x), y: toMetres(impulse.y) }, true); return Boolean(body); } /** The scene's gravity for the rest of this run, in world units per second squared. */ setGravity(world: World, gravity: Vec2): void { world.setSceneProp('gravityX', gravity.x); world.setSceneProp('gravityY', gravity.y); if (this.engine) this.engine.gravity = this.gravity(world); } /** * Says once per run and object what the physics did not do, in the log and the console – the * one place a quiet no-op gets noticed. */ hint(world: World, target: Entity, text: string): void { const key = `${target.id} ${text}`; if (this.hinted.has(key)) return; this.hinted.add(key); const line = `Physics: "${target.name}" ${text}`; world.log.push(line); console.warn(`[physics] ${line}`); } /** The engine body of the object a graph names – the object with the rigidbody, or the rigidbody. */ private dynamicBodyOf(target: Entity): Body | null { const owner = target.type === 'rigidbody' ? target.parent : target; if (!owner) return null; // On start, and an object spawned this frame, come before the engine has its body: it gets it now. const world = this.world; if (world && !this.bodies.has(owner)) if (world.childrenOf(owner).some((e) => e.type === 'rigidbody' && isHandleEnabled(e))) this.sync(world); const body = this.bodies.get(owner); return body?.kind === 'rigid' && body.body.isDynamic() && body.body.isEnabled() ? body : null; } /** Under a mirrored parent the engine turns against the rotation property. */ private turnSign(target: Entity): number { const owner = target.type === 'rigidbody' ? target.parent : target; return owner?.parent && determinant(worldMatrixOf(owner.parent)) < 0 ? -1 : 1; } /** * A mover that runs into a dynamic body pushes it into the contact, as one body meeting another * without bounce: the wanted speed is the move spread over the frame, and a light mover barely * shifts a heavy crate. Both keep moving – the mover as the graph says, the crate as the engine does. */ private push(world: World, mover: Entity, { by, along, distance }: Blocked): void { const part = [...this.parts.values()].find((p) => p.entity === by); const body = part?.owner.body; if (!body || part.owner.kind !== 'rigid' || !body.isDynamic() || !body.isEnabled()) return; const v = body.linvel(); const closing = toMetres(distance / this.frameDt) - (v.x * along.x + v.y * along.y); const m = this.massOf(world, mover); const M = body.mass(); if (closing <= 0 || m <= 0 || M <= 0) return; const impulse = ((m * M) / (m + M)) * closing; body.applyImpulse({ x: impulse * along.x, y: impulse * along.y }, true); } /** * What a moved object weighs against what it pushes: a dynamic body what the engine says, else * the mass of its rigidbody, else what its colliders weigh by their density. */ private massOf(world: World, entity: Entity): number { const own = this.bodies.get(entity); if (own?.kind === 'rigid' && own.body.isDynamic()) return own.body.mass(); const rigidbody = world.childrenOf(entity).find((e) => e.type === 'rigidbody' && isHandleEnabled(e)); const mass = rigidbody ? rigidbodySettings(rigidbody.props).mass : 0; if (mass > 0) return mass; return blockingCollidersOf(world, entity).reduce( (sum, c) => sum + colliderMaterial(c.props).density * shapeArea(shapeOf(c)) * toMetres(1) ** 2, 0, ); } private gravity(world: World): Vec2 { const g = sceneGravity(world.sceneProps); return { x: toMetres(g.x), y: toMetres(g.y) }; } /** Makes the engine's bodies match the entities: new ones in, gone ones out, moved ones moved. */ private sync(world: World): void { const wanted = this.wantedBodies(world); for (const [key, body] of this.bodies) { const want = wanted.get(key); if (!want || want.kind !== body.kind) this.remove(key, body); } for (const [key, want] of wanted) { let body = this.bodies.get(key); if (!body) { body = this.create(want, key); this.bodies.set(key, body); } this.place(body, want, key, world); this.build(body, want, key, world); } } /** * Every body the entities ask for, keyed by the entity that stands for it. A collider belongs to * the nearest object above it with a rigidbody switched on; without one it is a body of its own. */ private wantedBodies(world: World): Map { const owners = new Map(); for (const e of world.entities) if ( e.type === 'rigidbody' && e.parent && e.parent.type !== 'tilemap' && !owners.has(e.parent) && isHandleEnabled(e) ) owners.set(e.parent, e); const wanted = new Map(); for (const [owner, rigidbody] of owners) wanted.set(owner, { kind: 'rigid', settings: rigidbodySettings(rigidbody.props), colliders: [] }); for (const e of world.entities) { if (e.type === 'tilemap') wanted.set(e, { kind: 'tilemap', settings: null, colliders: [] }); if (e.type !== 'collider') continue; const owner = [...parentChain(e, (x) => x.parent)].slice(1).find((x) => owners.has(x)); if (owner) wanted.get(owner)!.colliders.push(e); else wanted.set(e, { kind: 'free', settings: null, colliders: [e] }); } return wanted; } private create(want: Wanted, key: Entity): Body { const R = this.rapier; const pose = this.poseWanted(want, key); const desc = want.kind === 'tilemap' ? R.RigidBodyDesc.fixed() : want.kind === 'free' ? R.RigidBodyDesc.kinematicPositionBased() : want.settings!.type === 'dynamic' ? R.RigidBodyDesc.dynamic() : want.settings!.type === 'static' ? R.RigidBodyDesc.fixed() : R.RigidBodyDesc.kinematicPositionBased(); desc.setTranslation(toMetres(pose.x), toMetres(pose.y)).setRotation(pose.angle); return { kind: want.kind, body: this.engine!.createRigidBody(desc), colliders: [], sources: [], solids: [], shapeKey: '', settingsKey: '', turns: false, ownMass: 0, placed: pose, previous: pose, current: pose, }; } private remove(key: Entity, body: Body): void { for (const collider of body.colliders) this.parts.delete(collider.handle); this.engine!.removeRigidBody(body.body); this.bodies.delete(key); } /** Where the entities say the body is: the object's origin, a free collider's shape, a map at zero. */ private poseWanted(want: Wanted, key: Entity): Pose { if (want.kind === 'tilemap') return { x: 0, y: 0, angle: 0 }; if (want.kind === 'free') { const shape = shapeOf(key); return { x: shape.x, y: shape.y, angle: shape.kind === 'circle' ? 0 : shape.angle }; } return matrixPose(worldMatrixOf(key)); } /** * Puts the body where its object is. A dynamic body is only moved when something else moved the * object since we last wrote it – that is a teleport, and the body keeps its velocity (#146, 3). */ private place(body: Body, want: Wanted, key: Entity, world: World): void { const settings = want.settings; const settingsKey = settings ? JSON.stringify(settings) + hasTransformProps(key.type) : ''; if (settings && settingsKey !== body.settingsKey) { body.settingsKey = settingsKey; this.applySettings(body, settings, key); } const on = isHandleEnabled(key); if (body.body.isEnabled() !== on) body.body.setEnabled(on); const pose = this.poseWanted(want, key); if (samePose(pose, body.placed)) return; body.placed = pose; const t = { x: toMetres(pose.x), y: toMetres(pose.y) }; if (body.body.isKinematic() && want.kind === 'rigid') { body.body.setNextKinematicTranslation(t); body.body.setNextKinematicRotation(pose.angle); return; } if (body.body.isDynamic()) this.hint( world, key, 'has a dynamic rigidbody and was moved directly; it keeps its velocity. ' + 'Apply force, Apply impulse and Set velocity move it with the physics.', ); body.body.setTranslation(t, true); body.body.setRotation(pose.angle, true); body.previous = pose; body.current = pose; } private applySettings(target: Body, settings: RigidbodySettings, owner: Entity): void { const body = target.body; body.setBodyType(this.bodyType(settings.type), true); body.setGravityScale(settings.gravityScale, true); body.setLinearDamping(settings.linearDamping); body.setAngularDamping(settings.angularDamping); // An object without a rotation of its own could not show the body turning, so it does not turn. target.turns = !settings.fixedRotation && hasTransformProps(owner.type); body.lockRotations(!target.turns, true); body.enableCcd(settings.ccd); } private bodyType(type: BodyType) { const T = this.rapier.RigidBodyType; return type === 'dynamic' ? T.Dynamic : type === 'static' ? T.Fixed : T.KinematicPositionBased; } /** The body's colliders, built again whenever what they were built from changed. */ private build(body: Body, want: Wanted, key: Entity, world: World): void { const shapes = this.shapesWanted(want, key, world); const origin = body.placed; const local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) })); const shapeKey = JSON.stringify( local.map(({ entity, pieces, facing, surface }) => [ entity.id, pieces.map(pieceKey), facing && [round(facing.x), round(facing.y), facing.cone], surface ?? 0, entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0, // The layers sit on the engine collider, so changing one has to rebuild it (#152). interactionGroups(collisionFilterOf(entity.props)), ]), ) + JSON.stringify(want.settings?.mass ?? 0); if (shapeKey !== body.shapeKey) { body.shapeKey = shapeKey; for (const collider of body.colliders) { this.parts.delete(collider.handle); this.engine!.removeCollider(collider, true); } body.colliders = []; body.sources = []; body.solids = []; const mass = want.settings?.mass ?? 0; const area = local.reduce( (sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0), 0, ); for (const { entity, shape, facing, surface, pieces } of local) for (const piece of pieces) { const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0; const desc = this.colliderDesc(entity, piece, share, facing !== null, surface); if (!desc) continue; const collider = this.engine!.createCollider(desc, body.body); body.colliders.push(collider); body.sources.push(entity); body.solids.push({ shape, facing }); this.parts.set(collider.handle, { entity, shape, facing, owner: body }); } } // Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A // tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from. const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid])); body.colliders.forEach((collider, i) => { const entity = body.sources[i]!; const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!; this.parts.set(collider.handle, { entity, shape, facing, owner: body }); const on = isHandleEnabled(entity); if (collider.isEnabled() === on) return; collider.setEnabled(on); // A resting body sleeps, and losing what held it up does not wake it by itself. body.body.wakeUp(); }); if (body.kind !== 'rigid') return; // Without a collider the engine would weigh it nothing, and a thing of no weight never falls. const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity)); const own = weighed ? 0 : want.settings?.mass || 1; if (own !== body.ownMass) { body.ownMass = own; body.body.setAdditionalMass(own, true); } } private shapesWanted(want: Wanted, key: Entity, world: World): (SolidShape & { entity: Entity })[] { if (want.kind === 'tilemap') return this.solids.solidShapesOf(world, key).map((solid) => ({ ...solid, entity: key })); return want.colliders.flatMap((entity) => this.solids.solidShapesOf(world, entity).map((solid) => ({ ...solid, entity })), ); } /** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */ private colliderDesc( entity: Entity, piece: Piece, mass: number, oneWay: boolean, surface?: TileMaterial, ): ColliderDesc | null { const R = this.rapier; if (pieceArea(piece) <= 0) return null; let desc: ColliderDesc | null; switch (piece.kind) { case 'ball': desc = R.ColliderDesc.ball(toMetres(piece.r)); break; case 'cuboid': desc = R.ColliderDesc.cuboid(toMetres(piece.hw), toMetres(piece.hh)); break; case 'capsule': desc = R.ColliderDesc.capsule(toMetres(piece.half), toMetres(piece.r)); break; case 'convex': desc = R.ColliderDesc.convexHull( new Float32Array(piece.points.flatMap((p) => [toMetres(p.x), toMetres(p.y)])), ); break; } if (!desc) return null; if (piece.kind !== 'convex') desc.setTranslation(toMetres(piece.x), toMetres(piece.y)).setRotation(piece.angle); // A tile's cell brings its tile's surface (#193); a density it does not need, a map never moves. const material = colliderMaterial(entity.type === 'collider' ? entity.props : {}); const { friction, bounciness } = surfaceOf(entity, surface); desc.setFriction(friction).setRestitution(bounciness); // The livelier side wins, as in Box2D: a ball that should bounce does so on plain ground too. desc.setRestitutionCombineRule(R.CoefficientCombineRule.Max); if (!isSolid(entity)) desc.setSensor(true).setDensity(0); else if (mass > 0) desc.setMass(mass); else desc.setDensity(material.density); desc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS); desc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL); // The layers (#152). Rapier applies one group to detecting and to blocking alike, which is the // single setting the issue asked for: what does not collide reports nothing either. const groups = interactionGroups(collisionFilterOf(entity.props)); desc.setCollisionGroups(groups).setSolverGroups(groups); if (oneWay && isSolid(entity)) desc.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS); return desc; } /** * Whether each dynamic body near a one-way collider passes through it in the coming step (#155), * by `passesOneWay`. One that is to pass is woken: a resting body sleeps, and a new answer alone * would not move it. */ private decideOneWay(world: World): void { const platforms: [number, Part][] = []; const movers: [number, Part][] = []; for (const [handle, part] of this.parts) { if (!isHandleEnabled(part.entity) || !isSolid(part.entity)) continue; if (part.facing) platforms.push([handle, part]); else if (part.owner.kind === 'rigid' && part.owner.body.isDynamic()) movers.push([handle, part]); } const decided = new Map(); for (const [hp, platform] of platforms) { const surface = this.shapeNow(platform); const facing = this.facingNow(platform); const vp = platform.owner.body.linvel(); for (const [hm, mover] of movers) { if (mover.owner === platform.owner) continue; const shape = this.shapeNow(mover); const vm = mover.owner.body.linvel(); const relative = { x: toPixels(vm.x - vp.x), y: toPixels(vm.y - vp.y) }; const margin = Math.hypot(relative.x, relative.y) * PHYSICS_STEP * 2 + 16; if (Math.hypot(shape.x - surface.x, shape.y - surface.y) > reachOf(shape) + reachOf(surface) + margin) continue; const key = handlePairKey(hp, hm); const contact = contactBetween(shape, surface, relative); const landed = this.passes.get(key) === false; const passes = passesOneWay(contact, facing, relative, landed, world.ignoresOneWay(mover.entity)); if (passes && mover.owner.body.isSleeping()) mover.owner.body.wakeUp(); decided.set(key, passes); } } this.passes = decided; } /** A part's shape where the engine has its body now: a dynamic body has moved on since the sync. */ private shapeNow(part: Part): ColliderShape { const body = part.owner; if (!body.body.isDynamic()) return part.shape; const from = body.placed; const to = poseOf(body.body); const turn = to.angle - from.angle; const cos = Math.cos(turn); const sin = Math.sin(turn); const move = (p: Vec2): Vec2 => { const dx = p.x - from.x; const dy = p.y - from.y; return { x: to.x + dx * cos - dy * sin, y: to.y + dx * sin + dy * cos }; }; return movedShape(part.shape, move, turn); } private facingNow(part: Part): OneWaySide { const facing = part.facing!; if (!part.owner.body.isDynamic()) return facing; const turn = part.owner.body.rotation() - part.owner.placed.angle; const cos = Math.cos(turn); const sin = Math.sin(turn); return { x: facing.x * cos - facing.y * sin, y: facing.x * sin + facing.y * cos, cone: facing.cone }; } /** * The pairs in contact after a step, compared with those before it. The engine proposes the * pairs; where no dynamic body takes part, the overlap test decides as it always did. */ private collect(world: World, entered: Set): void { this.queue!.drainCollisionEvents((h1, h2, started) => { const key = h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`; if (started) this.candidates.set(key, [h1, h2]); else this.candidates.delete(key); }); const order = new Map(world.entities.map((e, i) => [e, i])); const now = new Map(); const first = new Map(); for (const [key, [h1, h2]] of this.candidates) { let p = this.parts.get(h1); let q = this.parts.get(h2); if (!p || !q) { this.candidates.delete(key); continue; } const a0 = p.entity; const b0 = q.entity; if (!order.has(a0) || !order.has(b0) || !isHandleEnabled(a0) || !isHandleEnabled(b0)) continue; if (bodyOf(a0) === bodyOf(b0) || (a0.type === 'tilemap' && b0.type === 'tilemap')) continue; const engineSays = p.owner.body.isDynamic() || q.owner.body.isDynamic(); if (!engineSays && !shapesOverlap(p.shape, q.shape)) continue; let c1 = h1; let c2 = h2; if (order.get(a0)! > order.get(b0)!) { [p, q] = [q, p]; [c1, c2] = [c2, c1]; } const pairKey = `${p.entity.id}|${q.entity.id}`; if (now.has(pairKey)) continue; now.set(pairKey, { a: p.entity, b: q.entity }); first.set(pairKey, [this.engine!.getCollider(c1), this.engine!.getCollider(c2)]); } this.pairColliders = new Map(); for (const [key, pair] of now) { const [c1, c2] = first.get(key)!; this.pairColliders.set(key, [c1.handle, c2.handle]); if (this.current.has(key)) { now.set(key, this.current.get(key)!); continue; } this.enters.push(Object.assign(pair, this.measure(c1, c2))); entered.add(key); } for (const [key, pair] of this.current) if (!now.has(key)) this.exits.push(pair); this.current = now; } /** * How the pair touches: the normal from the second collider towards the first, the impulse of the * last step and the middle of the contact points (world units; zero without a contact). */ private measure(c1: Collider, c2: Collider): Contact { let normal = { x: 0, y: 0 }; let impulse = 0; let sum = { x: 0, y: 0 }; let points = 0; this.engine!.contactPair(c1, c2, (manifold, flipped) => { if (manifold.numContacts() === 0) return; const n = manifold.normal(); // The manifold's normal runs from its first collider to its second; ours runs back to `a`. const sign = flipped ? 1 : -1; if (normal.x === 0 && normal.y === 0) normal = { x: sign * n.x, y: sign * n.y }; // Each of its points has a side on either collider, in that collider's space: the contact // point is the middle of the two, which is also where two bodies that sank in meet. const [first, second] = flipped ? [c2, c1] : [c1, c2]; const toWorld = (collider: Collider, p: { x: number; y: number }) => { const at = collider.translation(); const cos = Math.cos(collider.rotation()); const sin = Math.sin(collider.rotation()); return { x: at.x + p.x * cos - p.y * sin, y: at.y + p.x * sin + p.y * cos }; }; for (let i = 0; i < manifold.numContacts(); i++) { impulse += manifold.contactImpulse(i); const p1 = manifold.localContactPoint1(i); const p2 = manifold.localContactPoint2(i); if (!p1 || !p2) continue; const a = toWorld(first, p1); const b = toWorld(second, p2); sum = { x: sum.x + (a.x + b.x) / 2, y: sum.y + (a.y + b.y) / 2 }; points++; } }); const point = points > 0 ? { x: toPixels(sum.x / points), y: toPixels(sum.y / points) } : { x: 0, y: 0 }; return { normal, impulse: toPixels(impulse), point }; } /** * Hands the dynamic bodies' poses to their objects, a parent before its children, drawn `alpha` * of the way from the step before to the last one so the picture moves evenly between steps. */ private writeBack(alpha: number): void { const dynamic = [...this.bodies].filter(([, body]) => body.kind === 'rigid' && body.body.isDynamic()); dynamic.sort(([a], [b]) => depthOf(a) - depthOf(b)); for (const [owner, body] of dynamic) { const pose = blend(body.previous, body.current, alpha); const parent = owner.parent ? worldMatrixOf(owner.parent) : IDENTITY; const local = apply(invert(parent), pose); owner.x = local.x; owner.y = local.y; if (body.turns) { const turned = angleBetween(matrixPose(worldMatrixOf(owner)).angle, pose.angle); const sign = determinant(parent) < 0 ? -1 : 1; owner.props.rotation = Number(owner.props.rotation ?? 0) + (sign * turned * 180) / Math.PI; } body.placed = matrixPose(worldMatrixOf(owner)); } } } interface Wanted { kind: Body['kind']; settings: RigidbodySettings | null; colliders: Entity[]; } function handlePairKey(h1: number, h2: number): string { return h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`; } /** A tilemap's cells always block; a collider unless it is a trigger. */ function isSolid(entity: Entity): boolean { return entity.type !== 'collider' || !isTrigger(entity.props); } function depthOf(entity: Entity): number { return [...parentChain(entity, (e) => e.parent)].length; }