diff --git a/README.md b/README.md index e43a9b9c..de88f6d3 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1055 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1057 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 6ae8b506..3201b3cb 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -97,6 +97,17 @@ export interface PhysicsControl { setGravity(x: number, y: number): void; } +/** + * What a move with collision met: which of the object's own axes something stood against, whether + * it hit anything, and the world normal of the first contact (pointing away from what was hit). + */ +export interface MoveOutcome { + blockedX: boolean; + blockedY: boolean; + hit: boolean; + normal: { x: number; y: number }; +} + /** * Everything the graph does to the world goes through here, so the core stays free of runtime * imports. Implemented by the runtime (BehaviorSystem) and by test doubles. @@ -132,10 +143,10 @@ export interface NodeContext { /** A collider entity stands for itself; any other entity for its direct collider children. */ isTouching(a: EntityHandle, b: EntityHandle): boolean; /** - * Moves `target` by (dx, dy) in local units, stopping solid colliders at other solid ones axis by - * axis (x first, then y) so it slides along walls. + * Moves `target` by (dx, dy) in local units; its solid colliders stop flush at other solid ones + * and the rest of the move goes on along the surface, less what friction takes. */ - moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean }; + moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome; /** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */ physics?: PhysicsControl; log(message: string): void; diff --git a/src/core/graph/nodes/action/Move.ts b/src/core/graph/nodes/action/Move.ts index c2c96fbc..fd9277a7 100644 --- a/src/core/graph/nodes/action/Move.ts +++ b/src/core/graph/nodes/action/Move.ts @@ -1,15 +1,30 @@ +import type { MoveOutcome } from '../../NodeContext'; import type { NodeDef } from '../../NodeRegistry'; import { asBoolean, asEntity, asNumber } from '../../values'; import { bool, exec, num, obj, withDefault } from '../shared'; +const FREE = { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 }; + +function outputsOf(outcome: MoveOutcome) { + return { + blockedX: outcome.blockedX, + blockedY: outcome.blockedY, + hit: outcome.hit, + normalX: outcome.normal.x, + normalY: outcome.normal.y, + }; +} + export const Move: NodeDef = { type: 'Move', label: 'Move', category: 'action', description: "Moves an object by (dx, dy) × scale × dt. Connect dt to an event's dt for per-second movement; " + - 'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop at ' + - 'other solid colliders and slide along them; blocked x/y tell which axes hit.', + 'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop ' + + 'flush at other solid colliders and the rest of the move goes on along the surface, less what the ' + + 'friction of both colliders takes; blocked x/y tell which axes something stood against, hit whether ' + + 'anything was met, and normal x/y which way the first surface faces.', inputs: [ exec(), obj('object'), @@ -18,17 +33,25 @@ export const Move: NodeDef = { withDefault(num('scale'), 1), withDefault(num('dt'), 1), ], - outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')], + outputs: [ + exec(), + bool('blockedX', 'blocked x'), + bool('blockedY', 'blocked y'), + bool('hit'), + num('normalX', 'normal x'), + num('normalY', 'normal y'), + ], params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }], run: (ctx, inputs, params) => { const target = asEntity(inputs.object); - if (!target) return { next: 'exec', outputs: { blockedX: false, blockedY: false } }; + if (!target) return { next: 'exec', outputs: FREE }; const factor = asNumber(inputs.scale) * asNumber(inputs.dt); const dx = asNumber(inputs.dx) * factor; const dy = asNumber(inputs.dy) * factor; - if (asBoolean(params.collide)) return { next: 'exec', outputs: ctx.moveAndSlide(target, dx, dy) }; + if (asBoolean(params.collide)) + return { next: 'exec', outputs: outputsOf(ctx.moveAndSlide(target, dx, dy)) }; target.x += dx; target.y += dy; - return { next: 'exec', outputs: { blockedX: false, blockedY: false } }; + return { next: 'exec', outputs: FREE }; }, }; diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index d01a1083..b2bc008b 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -18,9 +18,6 @@ export type ColliderShape = type Box = Extract; type Circle = Extract; -/** A movement axis in the plane (`z` is draw order, not geometry). */ -export type PlaneAxis = 'x' | 'y'; - /** The shape as it sits in the world: sized by the transform's scale, a box turned by its angle. */ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape { const scale = scaleOf(m); @@ -120,6 +117,86 @@ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean { return distanceToBox(toBoxFrame(box, circle), box.w / 2, box.h / 2) < circle.r - EPSILON; } +/** + * How two shapes meet: `separation` is the gap between them (negative when they overlap, then its + * size is the shortest way out) and `normal` the unit direction from `obstacle` towards `mover` that + * gap is measured along – the face that was hit, or the direction from a hit corner. Where the + * shapes give no direction (centres on top of each other) it runs against `travel`. + */ +export function contactBetween( + mover: ColliderShape, + obstacle: ColliderShape, + travel: Vec2, +): { separation: number; normal: Vec2 } { + const against = (n: Vec2, offset: number): Vec2 => { + const sign = offset !== 0 ? Math.sign(offset) : dot(n, travel) > 0 ? -1 : 1; + return { x: sign * n.x + 0, y: sign * n.y + 0 }; + }; + if (mover.kind === 'box' && obstacle.kind === 'box') { + const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y }; + let best = { separation: -Infinity, normal: { x: 0, y: 0 } }; + for (const n of [...axesOf(mover), ...axesOf(obstacle)]) { + const offset = dot(centres, n); + const separation = Math.abs(offset) - reach(mover, n) - reach(obstacle, n); + const normal = against(n, offset); + // On a tie (a corner on a corner) the face the travel runs into more squarely wins. + if ( + separation > best.separation + EPSILON || + (separation > best.separation - EPSILON && dot(normal, travel) < dot(best.normal, travel)) + ) + best = { separation: Math.max(separation, best.separation), normal }; + } + return best; + } + if (mover.kind === 'circle' && obstacle.kind === 'circle') { + const d = { x: mover.x - obstacle.x, y: mover.y - obstacle.y }; + const length = Math.hypot(d.x, d.y); + const normal = length > 0 ? { x: d.x / length, y: d.y / length } : against(unit(travel), 0); + return { separation: length - mover.r - obstacle.r, normal }; + } + const { box, circle } = boxAndCircle(mover, obstacle); + const [ux, uy] = axesOf(box); + const p = toBoxFrame(box, circle); + const hw = box.w / 2; + const hh = box.h / 2; + // Local direction from the box towards the circle, and the gap along it. + let dir: Vec2; + let separation: number; + if (Math.abs(p.x) > hw || Math.abs(p.y) > hh) { + const d = { x: p.x - clamp(p.x, hw), y: p.y - clamp(p.y, hh) }; + const length = Math.hypot(d.x, d.y); + dir = { x: d.x / length, y: d.y / length }; + separation = length - circle.r; + } else { + // The centre is inside: out through the nearer face. + const sign = mover.kind === 'circle' ? 1 : -1; + const local = { x: sign * dot(travel, ux), y: sign * dot(travel, uy) }; + const byX = hw - Math.abs(p.x) <= hh - Math.abs(p.y); + dir = byX + ? { x: p.x !== 0 ? Math.sign(p.x) : -Math.sign(local.x) || 1, y: 0 } + : { x: 0, y: p.y !== 0 ? Math.sign(p.y) : -Math.sign(local.y) || 1 }; + separation = -(byX ? hw - Math.abs(p.x) : hh - Math.abs(p.y)) - circle.r; + } + const world = { x: dir.x * ux.x + dir.y * uy.x, y: dir.x * ux.y + dir.y * uy.y }; + // `world` runs from the box to the circle; the normal runs from the obstacle to the mover. + const sign = mover.kind === 'circle' ? 1 : -1; + return { separation, normal: { x: sign * world.x + 0, y: sign * world.y + 0 } }; +} + +/** How deep two shapes overlap by the measure `shapesOverlap` uses: 0 when they only touch. */ +export function overlapDepth(separation: number): number { + return separation < -EPSILON ? -separation : 0; +} + +function clamp(v: number, half: number): number { + return Math.min(half, Math.max(-half, v)); +} + +function unit(v: Vec2): Vec2 { + const length = Math.hypot(v.x, v.y); + return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 1, y: 0 }; +} + /** * How far `mover` has to travel along `direction` until it just clears `obstacle`; 0 when they are * apart already. This is what stops a moving object at a wall: it is pushed back against its diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index d65b2a20..0c44f6ba 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -26,6 +26,7 @@ import { bodyOf, type CollisionPair, type CollisionSource, + type MoveResult, partsOf, shapeOf, Solids, @@ -169,12 +170,7 @@ export class PhysicsSystem implements System, CollisionSource { * `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, - ): { blockedX: boolean; blockedY: boolean } { + moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult { return this.solids.moveAndSlide(world, entity, dx, dy, (blocked) => this.push(world, entity, blocked)); } @@ -253,7 +249,7 @@ export class PhysicsSystem implements System, CollisionSource { } /** - * A mover that runs into a dynamic body pushes it along its travel, as one body meeting another + * 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. */ diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index a38bedce..13dc325f 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -30,7 +30,7 @@ import type { World } from '../World'; import type { Rapier } from '../physics/engine'; import { PhysicsSystem } from '../physics/PhysicsSystem'; import { CollisionSystem } from './CollisionSystem'; -import { bodyOf, type CollisionPair, type CollisionSource } from './solids'; +import { bodyOf, type CollisionPair, type CollisionSource, NOT_BLOCKED } from './solids'; import { AnimationSystem } from './AnimationSystem'; import type { Input } from './Input'; import type { System } from './System'; @@ -563,11 +563,11 @@ export class BehaviorSystem implements System { }, moveAndSlide: (target, dx, dy) => { const found = resolve(target); - if (!found) return { blockedX: false, blockedY: false }; + if (!found) return NOT_BLOCKED; if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy); found.x += dx; found.y += dy; - return { blockedX: false, blockedY: false }; + return NOT_BLOCKED; }, physics: this.collisions instanceof PhysicsSystem diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 840f748e..d6510d11 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -2,7 +2,15 @@ import { isHandleEnabled } from '../../core/graph'; import { shapesOverlap } from '../../core/properties/collider'; import type { Entity } from '../Entity'; import type { World } from '../World'; -import { bodyOf, type CollisionPair, type CollisionSource, isCollisionBody, partsOf, Solids } from './solids'; +import { + bodyOf, + type CollisionPair, + type CollisionSource, + isCollisionBody, + type MoveResult, + partsOf, + Solids, +} from './solids'; import type { System } from './System'; /** @@ -67,12 +75,7 @@ export class CollisionSystem implements System, CollisionSource { return false; } - moveAndSlide( - world: World, - entity: Entity, - dx: number, - dy: number, - ): { blockedX: boolean; blockedY: boolean } { + moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult { return this.solids.moveAndSlide(world, entity, dx, dy); } } diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index dd0045b9..39ae78ff 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -1,5 +1,5 @@ import { isHandleEnabled, worldMatrixOf } from '../../core/graph'; -import { applyVector, IDENTITY } from '../../core/model/matrix'; +import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; import { type CellRect, cellRectShape, solidRects } from '../../core/model/tilemapCollision'; @@ -7,18 +7,24 @@ import { parentChain } from '../../core/model/walk'; import { type ColliderShape, colliderShape, + contactBetween, exitDistance, isTrigger, - type PlaneAxis, + overlapDepth, shapeExtent, } from '../../core/properties/collider'; +import { colliderMaterial } from '../../core/properties/rigidbody'; import type { Entity } from '../Entity'; import type { World } from '../World'; -/** Overlaps up to this depth (world units) count as contact, not as "already inside". */ -const SKIN = 0.05; -/** Upper bound for the sub-steps of a single move, so a huge delta cannot stall a frame. */ +/** 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; /** * Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a @@ -34,8 +40,8 @@ export interface CollisionPair { } /** - * What stopped a move along one axis: the collider or tilemap in the way, the world direction the - * move was going, and how far it wanted to go there (world units). + * 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; @@ -43,24 +49,46 @@ export interface Blocked { 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 in the mover's way and the collider or tilemap it belongs to. */ interface Obstacle { shape: ColliderShape; body: Entity; } +/** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */ +interface Contact { + by: Entity; + 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, - ): { blockedX: boolean; blockedY: boolean }; + moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult; } /** @@ -101,10 +129,13 @@ export class Solids { } /** - * Moves one axis at a time, stopping each at contact so the object slides along walls. An existing - * overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in. - * The axes are the entity's own: under a turned parent, "x" runs wherever that parent points it. - * `onBlocked` hears what stopped each axis. + * 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, @@ -112,117 +143,149 @@ export class Solids { dx: number, dy: number, onBlocked?: (blocked: Blocked) => void, - ): { blockedX: boolean; blockedY: boolean } { + ): MoveResult { const own = blockingCollidersOf(world, entity); + 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. - const obstacles = - own.length === 0 - ? [] - : world.entities - .filter((c) => blocksMover(c, entity)) - .flatMap((body) => this.shapesOf(world, body).map((shape) => ({ shape, body }))); - const slide = (axis: PlaneAxis, delta: number) => { - const blocked = slideAxis(entity, own, obstacles, axis, delta); - if (blocked) onBlocked?.(blocked); - return blocked !== null; + const obstacles = world.entities + .filter((c) => blocksMover(c, entity)) + .flatMap((body) => this.shapesOf(world, body).map((shape) => ({ shape, body }))); + const contacts: Contact[] = []; + let rest = wanted; + for (let pass = 0; pass < MAX_PASSES && (rest.x !== 0 || rest.y !== 0); pass++) { + const contact = sweep(entity, frame, own, obstacles, rest); + if (!contact) break; + contacts.push(contact); + rest = slideAlong(contact.left, contact.normal, frictionBetween(contact.own, contact.by)); + } + 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 }, }; - return { blockedX: slide('x', dx), blockedY: slide('y', dy) }; } } -function slideAxis( - entity: Entity, - own: Entity[], - obstacles: Obstacle[], - axis: PlaneAxis, - delta: number, -): Blocked | null { - if (delta === 0 || own.length === 0) return null; - const travel = axisTravel(entity, axis); - const distance = Math.abs(delta) * travel.length; - const steps = Math.min( - MAX_STEPS, - Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, travel.unit))), - ); +/** + * 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. + */ +function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[], move: Vec2): Contact | 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++) { - const by = stepAxis(entity, own, obstacles, axis, delta / steps, travel); - if (by) { - const dir = Math.sign(delta); - return { by, along: { x: dir * travel.unit.x, y: dir * travel.unit.y }, distance }; + // 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); + 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); + for (const [j, obstacle] of obstacles.entries()) { + 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 (back > correction || !found) { + correction = back; + found = { own: a, obstacle }; + } + } } + if (!found) continue; + const { own: collider, obstacle } = found; + shift(-correction); + const left = distance - i * step - (step - correction); + return { + by: obstacle.body, + own: collider, + normal: contactBetween(shapeOf(collider), obstacle.shape, forward).normal, + left: { x: forward.x * left, y: forward.y * left }, + }; } return null; } -/** One indivisible move along `axis`; returns the obstacle that stopped it, if one did. */ -function stepAxis( - entity: Entity, - own: Entity[], - obstacles: Obstacle[], - axis: PlaneAxis, - delta: number, - travel: AxisTravel, -): Entity | null { - const dir: 1 | -1 = delta > 0 ? 1 : -1; - const forward = { x: dir * travel.unit.x, y: dir * travel.unit.y }; - // How deep each pair already sits in the other before the step. A step may never deepen - // that, but resolving it is not our job (no jolt, and leaving a wall is always possible). - // Overlaps below the skin count as plain contact – that is where rounding leftovers of a - // previous correction land, and taking those for "already inside" would open the wall. - const before = own.map((a) => { - const mover = shapeOf(a); - return obstacles.map(({ shape }) => { - const p = penetration(mover, shape, forward); - return p > SKIN ? p : 0; - }); - }); - entity[axis] += delta; - let correction = 0; - let by: Entity | null = null; - own.forEach((a, i) => { - const mover = shapeOf(a); - obstacles.forEach(({ shape, body }, j) => { - const t = penetration(mover, shape, forward) - before[i]![j]!; - if (t > correction) { - correction = t; - by = body; - } - }); - }); - if (correction <= 0) return null; - entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta)); - return by; +/** + * 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 }; } -/** Where one unit along an entity's local axis goes in the world, and how far. */ -interface AxisTravel { - unit: Vec2; - length: number; +/** + * 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 has no material of its + * own and has what a new collider starts with. + */ +function frictionBetween(own: Entity, obstacle: Entity): number { + const of = (e: Entity) => colliderMaterial(e.type === 'collider' ? e.props : {}).friction; + return (of(own) + of(obstacle)) / 2; } -function axisTravel(entity: Entity, axis: PlaneAxis): AxisTravel { - const parent = entity.parent ? worldMatrixOf(entity.parent) : IDENTITY; - const v = applyVector(parent, axis === 'x' ? { x: 1, y: 0 } : { x: 0, y: 1 }); +function dot(a: Vec2, b: Vec2): number { + return a.x * b.x + a.y * b.y; +} + +function unitOf(v: Vec2): Vec2 { const length = Math.hypot(v.x, v.y); - return length > 0 - ? { unit: { x: v.x / length, y: v.y / length }, length } - : { unit: { x: 1, y: 0 }, length: 1 }; + return length > 0 ? { x: v.x / length, y: v.y / length } : v; } export function shapeOf(collider: Entity): ColliderShape { return colliderShape(collider.props, worldMatrixOf(collider)); } -/** - * How far `a` would have to be pushed back against its travel to just clear `b` (0 when they are - * apart). Once a's centre has passed b's centre along the travel the shorter way out is forwards, - * so nothing blocks – that keeps an object that ended up inside something able to leave. - */ -function penetration(mover: ColliderShape, obstacle: ColliderShape, forward: Vec2): number { - if ((mover.x - obstacle.x) * forward.x + (mover.y - obstacle.y) * forward.y >= 0) return 0; - return exitDistance(mover, obstacle, { x: -forward.x, y: -forward.y }); -} - /** 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)); diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts index c873c1f4..4e11bb17 100644 --- a/tests/core/collider.test.ts +++ b/tests/core/collider.test.ts @@ -4,12 +4,16 @@ import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { findAsset } from '../../src/core/model/Project'; import { transformMatrix, translation } from '../../src/core/model/matrix'; +import type { Vec2 } from '../../src/core/model/Properties'; import { colliderShape, + contactBetween, exitDistance, + overlapDepth, shapeContains, shapesOverlap, } from '../../src/core/properties/collider'; +import { shapeOf } from '../../src/runtime/systems/solids'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { COLLISION_SYSTEMS, @@ -107,6 +111,48 @@ describe('collider geometry', () => { (50 + 10 * Math.SQRT2 - 90 * Math.SQRT1_2) * Math.SQRT2, ); }); + + it('contact: the gap between two shapes and the normal from the obstacle towards the mover (#150)', () => { + const RIGHT = { x: 1, y: 0 }; + // Box against a box face: exactly the face's direction, the gap along it. + expect(contactBetween(box(-100, 10, 100, 60), box(0, 0, 100, 100), RIGHT)).toEqual({ + separation: 0, + normal: { x: -1, y: 0 }, + }); + expect(contactBetween(box(0, -90, 100, 60), box(0, 0, 100, 100), RIGHT)).toEqual({ + separation: 10, + normal: { x: 0, y: -1 }, + }); + // A turned wall: its own face, not an upright one. + const slope = contactBetween(box(-200, -200, 20, 20), box(0, 0, 100, 100, 45), RIGHT).normal; + expect(slope.x).toBeCloseTo(-Math.SQRT1_2, 12); + expect(slope.y).toBeCloseTo(-Math.SQRT1_2, 12); + // Corner on corner is a tie: the face the travel runs into more squarely wins. + expect(contactBetween(box(-100, -100, 100, 100), box(0, 0, 100, 100), RIGHT).normal).toEqual(LEFT); + expect(contactBetween(box(-100, -100, 100, 100), box(0, 0, 100, 100), { x: 0, y: 1 }).normal).toEqual(UP); + // A circle at a corner: from the corner towards its centre, whichever of the two moves. + const corner = contactBetween(circle(-60, -58, 10), box(0, 0, 100, 100), RIGHT); + expect(corner.separation).toBeCloseTo(Math.hypot(10, 8) - 10, 12); + expect(corner.normal.x).toBeCloseTo(-10 / Math.hypot(10, 8), 12); + expect(corner.normal.y).toBeCloseTo(-8 / Math.hypot(10, 8), 12); + const reverse = contactBetween(box(0, 0, 100, 100), circle(-60, -58, 10), RIGHT).normal; + expect(reverse.x).toBeCloseTo(-corner.normal.x, 12); + expect(reverse.y).toBeCloseTo(-corner.normal.y, 12); + // Overlapping: the shortest way out, as a negative gap. + expect(contactBetween(circle(0, 45, 10), box(0, 0, 100, 100), RIGHT)).toEqual({ + separation: -15, + normal: { x: 0, y: 1 }, + }); + expect(contactBetween(circle(0, 0, 10), circle(0, 12, 10), RIGHT)).toEqual({ + separation: -8, + normal: { x: 0, y: -1 }, + }); + // Nothing to tell a direction by: against the travel. + expect(contactBetween(circle(5, 5, 10), circle(5, 5, 10), RIGHT).normal).toEqual(LEFT); + expect(overlapDepth(-15)).toBe(15); + expect(overlapDepth(-1e-12)).toBe(0); + expect(overlapDepth(3)).toBe(0); + }); }); /** Two crates (100×60) 300 apart, each with a collider child; a behavior "Hit" placed below crate a. */ @@ -215,24 +261,53 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { }); describe('move and slide', () => { - const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact + const SKIN = 0.05; // how far off flush still counts as pressed against the wall it('stops flush at a solid collider and reports the blocked axis', () => { const { world, collisions, crateA } = runtime(physics); // Crates are 100 wide, 300 apart: 200 of travel before they touch (centres 100 apart). - expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, 250, 0)).toMatchObject({ + blockedX: true, + blockedY: false, + }); expect(crateA.x).toBe(200); - expect(collisions.moveAndSlide(world, crateA, 50, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, 50, 0)).toMatchObject({ + blockedX: true, + blockedY: false, + }); expect(crateA.x).toBe(200); // touching edges do not overlap, so the wall keeps holding - expect(collisions.moveAndSlide(world, crateA, -30, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, -30, 0)).toMatchObject({ + blockedX: false, + blockedY: false, + }); expect(crateA.x).toBe(170); }); - it('slides along the wall: a diagonal move keeps its y component', () => { - const { world, collisions, crateA } = runtime(physics); - expect(collisions.moveAndSlide(world, crateA, 250, 40)).toEqual({ blockedX: true, blockedY: false }); - expect(crateA.x).toBe(200); - expect(crateA.y).toBe(40); + it('slides along the wall as far as friction lets it (#150)', () => { + // 250 × 40 hits the wall after 200 × 32; of the 50 × 8 left, 50 press into the wall and + // friction 0.5 × 50 = 25 is more than the 8 along it: the crate stops there. + const pressed = runtime(physics); + expect(pressed.collisions.moveAndSlide(pressed.world, pressed.crateA, 250, 40)).toEqual({ + blockedX: true, + blockedY: false, + hit: true, + normal: { x: -1, y: 0 }, + }); + expect(pressed.crateA.x).toBe(200); + expect(pressed.crateA.y).toBeCloseTo(32, 9); + // Without friction on either side the whole 8 go on along the wall. + const slick = runtime(physics); + for (const e of slick.world.entities) if (e.type === 'collider') e.props.friction = 0; + slick.collisions.moveAndSlide(slick.world, slick.crateA, 250, 40); + expect(slick.crateA.x).toBe(200); + expect(slick.crateA.y).toBeCloseTo(40, 9); + // In between, the pair's average friction takes its share: (0 + 0.2) / 2 × 50 = 5 of the 8. + const rough = runtime(physics); + const colliders = rough.world.entities.filter((e) => e.type === 'collider'); + colliders[0]!.props.friction = 0; + colliders[1]!.props.friction = 0.2; + rough.collisions.moveAndSlide(rough.world, rough.crateA, 250, 40); + expect(rough.crateA.y).toBeCloseTo(32 + 3, 9); }); it('triggers report but never block; own and descendant colliders are ignored', () => { @@ -242,7 +317,10 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'isTrigger', true)); const { world, collisions } = startWorld(s, { physics, collisions: true }); const crateA = world.findByObjectId(a)!; - expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, 250, 0)).toMatchObject({ + blockedX: false, + blockedY: false, + }); expect(crateA.x).toBe(250); world.tick(0.1); expect(collisions.enters).toHaveLength(1); @@ -253,18 +331,27 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { rider.props.isTrigger = false; rider.parent = crateA; rider.x = 150; - expect(collisions.moveAndSlide(world, crateA, 10, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, 10, 0)).toMatchObject({ + blockedX: false, + blockedY: false, + }); expect(crateA.x).toBe(260); }); it('an existing overlap is neither resolved (no jolt) nor deepened; leaving is always possible', () => { const { world, collisions, crateA, crateB } = runtime(physics); crateB.x = 60; // start overlapped by 40 - expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, 5, 0)).toMatchObject({ blockedX: true, blockedY: false }); expect(crateA.x).toBe(0); // no jolt out, but no burrowing deeper either - expect(collisions.moveAndSlide(world, crateA, -20, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, -20, 0)).toMatchObject({ + blockedX: false, + blockedY: false, + }); expect(crateA.x).toBe(-20); // moving away is always free - expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(collisions.moveAndSlide(world, crateA, -100, 0)).toMatchObject({ + blockedX: false, + blockedY: false, + }); expect(crateA.x).toBe(-120); }); @@ -281,7 +368,10 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { expect(crateA.y).toBeGreaterThan(240 - SKIN); // A step larger than the obstacle must not jump over it: past the wall nothing overlaps to push back. crateA.y = 0; - expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true }); + expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toMatchObject({ + blockedX: false, + blockedY: true, + }); expect(crateA.y).toBeCloseTo(240, 6); }); @@ -311,6 +401,129 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { }); }); + describe('along the contact normal (#150)', () => { + /** 32×32 blocks with a collider each at `centres`, and a mover of the same kind at `start`. */ + function field(centres: Vec2[], start: Vec2, moverCollider = true) { + const f = makeSpriteScene('Tile'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 32)); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 32)); + const ids = [...centres, start].map((c, i) => { + const id = place(f, f.spriteId, c); + if (i < centres.length || moverCollider) f.stack.execute(new AddColliderCommand(f.sceneId, id)); + return id; + }); + const { world, collisions } = startWorld(f, { physics, collisions: true }); + const all = ids.map((id) => world.findByObjectId(id)!); + const colliderOf = (e: (typeof all)[number]) => world.childrenOf(e).find((c) => c.type === 'collider')!; + const mover = all.at(-1)!; + const move = (dx: number, dy: number) => collisions.moveAndSlide(world, mover, dx, dy); + return { world, blocks: all.slice(0, -1), mover, move, colliderOf }; + } + + it('a circle running into an edge rolls up over it instead of hanging on the corner', () => { + const { blocks, mover, move, colliderOf } = field([{ x: 300, y: 50 }], { x: 0, y: 0 }); + Object.assign(colliderOf(blocks[0]!).props, { width: 100, height: 60 }); // corner at (250, 20) + Object.assign(colliderOf(mover).props, { shape: 'circle', radius: 30 }); + const first = []; + for (let i = 0; i < 80; i++) { + const result = move(5, 0); + if (result.hit && first.length === 0) first.push(result); + } + // The first touch is the corner 20 below the centre, √(30² − 20²) ahead: the normal runs from it. + const reach = Math.sqrt(30 * 30 - 20 * 20); + expect(first[0]!.normal.x).toBeCloseTo(-reach / 30, 9); + expect(first[0]!.normal.y).toBeCloseTo(-20 / 30, 9); + expect(first[0]).toMatchObject({ blockedX: true, blockedY: false }); + // Over the corner and on along the top face, never inside the block. + expect(mover.x).toBeGreaterThan(300); + expect(mover.y).toBeLessThanOrEqual(20 - 30 + 1e-9); + expect(mover.y).toBeGreaterThan(20 - 30 - 5); + expect(shapesOverlap(shapeOf(colliderOf(mover)), shapeOf(colliderOf(blocks[0]!)))).toBe(false); + }); + + it('on a slope, pressed down by the graph, it slides as far as friction lets it: g·sin − μ·g·cos', () => { + const slide = (friction: number) => { + // A ramp turned by 30°, falling right; a circle dropped onto it 5 a frame. + const { blocks, mover, move, colliderOf } = field([{ x: 0, y: 200 }], { x: 0, y: 100 }); + blocks[0]!.props.rotation = 30; + Object.assign(colliderOf(blocks[0]!).props, { width: 1000, height: 60, friction }); + Object.assign(colliderOf(mover).props, { shape: 'circle', radius: 10, friction }); + for (let i = 0; i < 20; i++) move(0, 5); + const from = { x: mover.x, y: mover.y }; + const results = Array.from({ length: 10 }, () => move(0, 5)); + const ramp = shapeOf(colliderOf(blocks[0]!)); + const gap = contactBetween(shapeOf(colliderOf(mover)), ramp, { x: 0, y: 1 }).separation; + const moved = { dx: mover.x - from.x, dy: mover.y - from.y }; + // Walking down the ramp nothing stands against x; walking up it, the ramp does. + const downhill = move(3, 5); + const uphill = move(-3, 5); + return { ...moved, gap, results, downhill, uphill }; + }; + const sin = Math.sin(Math.PI / 6); + const cos = Math.cos(Math.PI / 6); + for (const friction of [0, 0.5, 0.6]) { + const { dx, dy, gap, results, downhill, uphill } = slide(friction); + expect(downhill).toMatchObject({ hit: true, blockedX: false, blockedY: true }); + expect(uphill).toMatchObject({ hit: true, blockedX: true, blockedY: true }); + const along = 10 * Math.max(0, 5 * sin - friction * 5 * cos); + expect(dx, `friction ${friction}`).toBeCloseTo(along * cos, 6); + expect(dy, `friction ${friction}`).toBeCloseTo(along * sin, 6); + expect(Math.abs(gap)).toBeLessThan(1e-6); // on the surface, not above nor in it + expect(results.every((r) => r.hit && r.blockedY && !r.blockedX)).toBe(true); + expect(results[0]!.normal.x).toBeCloseTo(sin, 9); + expect(results[0]!.normal.y).toBeCloseTo(-cos, 9); + } + }); + + for (const shape of ['box', 'circle'] as const) + it(`a ${shape} pressed onto a floor of twelve blocks slides over every seam without catching`, () => { + const floor = Array.from({ length: 12 }, (_, i) => ({ x: i * 32, y: 100 })); + const { mover, move, colliderOf } = field(floor, { x: 0, y: 60 }); // floor top at 84 + Object.assign( + colliderOf(mover).props, + shape === 'box' ? { width: 30, height: 30 } : { shape: 'circle', radius: 15 }, + ); + const steps: { dx: number; y: number; blockedX: boolean; blockedY: boolean }[] = []; + for (let i = 0; i < 100; i++) { + const x = mover.x; + const { blockedX, blockedY } = move(3, 1.5); + steps.push({ dx: mover.x - x, y: mover.y, blockedX, blockedY }); + } + // Landed within the first frames; from then on every frame goes 3 − 0.5 × 1.5 on, flush. + for (const step of steps.slice(10)) { + expect(step.dx).toBeCloseTo(3 - 0.5 * 1.5, 9); + expect(step.y).toBeCloseTo(84 - 15, 9); + expect(step).toMatchObject({ blockedX: false, blockedY: true }); + } + expect(mover.x).toBeGreaterThan(5 * 32); // across several seams + }); + + it('pressed against a wall of three stacked blocks, it slides down past both seams', () => { + const wall = [0, 32, 64].map((y) => ({ x: 100, y })); + const { mover, move, colliderOf } = field(wall, { x: 66, y: -10 }); // wall face at 84 + Object.assign(colliderOf(mover).props, { width: 30, height: 30 }); + const steps: { dy: number; x: number; blockedX: boolean; blockedY: boolean }[] = []; + for (let i = 0; i < 30; i++) { + const y = mover.y; + const { blockedX, blockedY } = move(1.5, 3); + steps.push({ dy: mover.y - y, x: mover.x, blockedX, blockedY }); + } + for (const step of steps.slice(3)) { + expect(step.dy).toBeCloseTo(3 - 0.5 * 1.5, 9); + expect(step.x).toBeCloseTo(84 - 15, 9); + expect(step).toMatchObject({ blockedX: true, blockedY: false }); + } + expect(mover.y + 15).toBeGreaterThan(48); // the lower edge went past both seams (16 and 48) + }); + + it('an object without a collider of its own moves freely', () => { + const { mover, move } = field([{ x: 40, y: 0 }], { x: 0, y: 0 }, false); + expect(move(100, 7)).toEqual({ blockedX: false, blockedY: false, hit: false, normal: { x: 0, y: 0 } }); + expect(mover.x).toBe(100); + expect(mover.y).toBe(7); + }); + }); + describe('collision events in behaviors', () => { it("fires On collision enter/exit on the body's behavior with the other body; Is touching agrees", () => { const s = setup(); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 62323a00..69c522a1 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -836,13 +836,28 @@ const CASES: Record = { name: 'moves by (dx, dy) × scale × dt', inputs: { object: at('m', 0, 0), dx: 1, dy: 0, scale: 100, dt: 0.5 }, next: 'exec', - outputs: { blockedX: false, blockedY: false }, + outputs: { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 }, + }, + { + name: 'with collision it hands on what the move met', + inputs: { object: at('m', 0, 0), dx: 4, dy: 2, scale: 1, dt: 1 }, + params: { collide: true }, + context: { + moveAndSlide: (_target: unknown, dx: number, dy: number) => ({ + blockedX: dx === 4, + blockedY: dy === 3, + hit: true, + normal: { x: -1, y: 0 }, + }), + }, + next: 'exec', + outputs: { blockedX: true, blockedY: false, hit: true, normalX: -1, normalY: 0 }, }, { name: 'no object is a no-op that still continues the flow', inputs: { object: null, dx: 1, dy: 1 }, next: 'exec', - outputs: { blockedX: false, blockedY: false }, + outputs: { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 }, }, ], SetPosition: [ diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index e663d578..eddee394 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -668,6 +668,26 @@ describe('Move with "Collide with solids" pushes dynamic bodies (#147)', () => { expect(still.walked).toBeCloseTo(60 - 32, 6); }); + it('the push goes along the contact normal, not the travel (#150)', () => { + // No gravity. A circle runs straight right into the crate's upper-left corner, 8 above its top + // face: the normal there is (−√192, −8) / 16, so the crate is pushed right and down. + const f = physicsScene(); + f.gravity(0, 0); + const crate = f.add(0, 0, { w: 32, body: { fixedRotation: true } }); + const ball = f.add(-60, -24, { w: 32, circle: true }); + const { entity, physics, world, frame } = run(f); + let normal = { x: 0, y: 0 }; + for (let i = 0; i < 20 && normal.x === 0; i++) { + normal = physics.moveAndSlide(world, entity(ball), 3, 0).normal; + frame(1 / 60); + } + expect(normal.x).toBeCloseTo(-Math.sqrt(192) / 16, 6); + expect(normal.y).toBeCloseTo(-8 / 16, 6); + const v = physics.velocityOf(entity(crate))!; + expect(v.x).toBeGreaterThan(0); + expect(v.y / v.x).toBeCloseTo(8 / Math.sqrt(192), 3); + }); + it('the mass of a kinematic mover counts: it shoves the heavy crate', () => { const { moved, gap } = shove( { w: 32, body: { mass: 4 } }, diff --git a/tests/core/tilemap-collision.test.ts b/tests/core/tilemap-collision.test.ts index bf23174a..04d4b2ec 100644 --- a/tests/core/tilemap-collision.test.ts +++ b/tests/core/tilemap-collision.test.ts @@ -341,11 +341,17 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { const { world, collisions } = startWorld(s, { physics, collisions: true }); const hero = world.findByObjectId(s.hero)!; // Right edge at 20, the wall's face at 48: 28 of travel. - expect(collisions.moveAndSlide(world, hero, 97.3, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(collisions.moveAndSlide(world, hero, 97.3, 0)).toMatchObject({ + blockedX: true, + blockedY: false, + }); expect(hero.x).toBeCloseTo(28, 9); - expect(collisions.moveAndSlide(world, hero, 5, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(collisions.moveAndSlide(world, hero, 5, 0)).toMatchObject({ blockedX: true, blockedY: false }); expect(hero.x).toBeCloseTo(28, 9); - expect(collisions.moveAndSlide(world, hero, -30, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(collisions.moveAndSlide(world, hero, -30, 0)).toMatchObject({ + blockedX: false, + blockedY: false, + }); expect(hero.x).toBeCloseTo(-2, 9); }); @@ -354,7 +360,10 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { s.paint(COLUMN_SIX); const { world, collisions } = startWorld(s, { physics, collisions: true }); const hero = world.findByObjectId(s.hero)!; - expect(collisions.moveAndSlide(world, hero, 5000, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(collisions.moveAndSlide(world, hero, 5000, 0)).toMatchObject({ + blockedX: true, + blockedY: false, + }); expect(hero.x).toBeCloseTo(28, 6); }); @@ -374,7 +383,8 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { expect(blockedY).toBe(false); } expect(hero.x).toBeCloseTo(28, 6); - expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6); + // Pressing 3.1 into the face costs 0.5 × 3.1 of each step down (friction 0.5 on both sides). + expect(hero.y).toBeCloseTo(-200 + 60 * (6.7 - 0.5 * 3.1), 6); }); it('a drawn-in wall lets the hero closer and stays one rectangle; slides past a bump on it', () => { @@ -400,7 +410,7 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { expect(blockedY).toBe(false); } expect(hero.x).toBeCloseTo(40, 6); - expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6); + expect(hero.y).toBeCloseTo(-200 + 60 * (6.7 - 0.5 * 3.1), 6); }); it('a grown wall keeps the hero further off', () => { diff --git a/tests/core/walk.test.ts b/tests/core/walk.test.ts index 11ff98ef..a0f92874 100644 --- a/tests/core/walk.test.ts +++ b/tests/core/walk.test.ts @@ -90,7 +90,10 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { // Close the loop: the wall's collider is its child and the wall is parented to that collider. wall.parent = wallColliderEntity; - expect(collisions.moveAndSlide(world, mover, 10, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(collisions.moveAndSlide(world, mover, 10, 0)).toMatchObject({ + blockedX: false, + blockedY: false, + }); expect(mover.x).toBe(10); // And a whole tick, which walks every collider up to the body it belongs to. world.tick(1 / 60); diff --git a/tests/helpers.ts b/tests/helpers.ts index 1b29e132..c2732d30 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -227,7 +227,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { moveAndSlide: (target, dx, dy) => { target.x += dx; target.y += dy; - return { blockedX: false, blockedY: false }; + return { blockedX: false, blockedY: false, hit: false, normal: { x: 0, y: 0 } }; }, log: (message) => logs.push(message), played: [], diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index 6330342f..dc1259ed 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -33,6 +33,34 @@ const walkGraph = { ], }; +/** + * Rolls its owner right at 120 per second with "Collide with solids" on, and logs Move's normal + * (x, then y) on every frame that hits something. + */ +const BALL_R = 20; +const CORNER_DROP = 12; +const rollGraph = { + nodes: [ + node('pr-update', 'OnUpdate', 0, 0), + node('pr-owner', 'Owner', 0, 160), + node('pr-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }), + node('pr-hit', 'Branch', 600, 0), + node('pr-log-x', 'Log', 850, 0), + node('pr-log-y', 'Log', 1100, 0), + ], + edges: [ + edge('pr-e1', 'pr-update', 'exec', 'pr-move', 'exec'), + edge('pr-e2', 'pr-update', 'dt', 'pr-move', 'dt'), + edge('pr-e3', 'pr-owner', 'object', 'pr-move', 'object'), + edge('pr-e4', 'pr-move', 'exec', 'pr-hit', 'exec'), + edge('pr-e5', 'pr-move', 'hit', 'pr-hit', 'condition'), + edge('pr-e6', 'pr-hit', 'then', 'pr-log-x', 'exec'), + edge('pr-e7', 'pr-move', 'normalX', 'pr-log-x', 'message'), + edge('pr-e8', 'pr-log-x', 'exec', 'pr-log-y', 'exec'), + edge('pr-e9', 'pr-move', 'normalY', 'pr-log-y', 'message'), + ], +}; + /** * A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a * collider's material rows sit below the fold of the inspector, and after the earlier steps the @@ -305,10 +333,67 @@ export async function physics(t) { const hints = consoleLog.filter((line) => line.includes('[physics]')).slice(hintsBefore); check('nothing in the step made the physics hint at a no-op', hints.length === 0, hints.join(' | ')); - await mutate(` + const clearPhysics = () => + mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-')); p.assets = p.assets.filter((a) => !a.id.startsWith('phys-')); __gm.state.select(null)`); + await clearPhysics(); + + // 41d. Move along the contact normal (#150): a ball walked straight right into the upper-left + // corner of a block, 12 below its centre, rolls up over it instead of hanging on the corner, + // and its graph logs the normal of the first contact from Move's hit / normal outputs. + const edgeLog = consoleLog.length; + await mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + ${MAKE_OBJECT} + const asset = structuredClone(assets.find((a) => a.id === 'mover')); + Object.assign(asset, { id: 'phys-roll', name: 'Physics roll', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(rollGraph)} }); + assets.push(asset); + assets.push({ id: 'phys-col-ball', type: 'collider', name: 'phys-col-ball', props: { shape: 'circle', width: 40, height: 40, radius: ${BALL_R}, isTrigger: false } }); + assets.push({ id: 'phys-col-block', type: 'collider', name: 'phys-col-block', props: { shape: 'box', width: 100, height: 60, radius: 20, isTrigger: false } }); + const put = (id, assetId, parentId, x, y, name, overrides = {}) => { + const o = make(id, assetId, parentId, y, name); + o.position.x = x; + o.overrides = overrides; + scene.objects.push(o); + }; + put('phys-ball', 'crate', '', 0, ${Y}, 'Rolling ball', { width: 40, height: 40 }); + put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0); + put('phys-ball-roll', 'phys-roll', 'phys-ball', 0, 0, 'Roll'); + put('phys-block', 'crate', '', 150, ${Y + CORNER_DROP + 30}, 'Edge block', { width: 100, height: 60 }); + put('phys-block-col', 'phys-col-block', 'phys-block', 0, 0); + __gm.state.select(null)`); + await play(); + await sleep(2000); + const ball = await json( + `(() => { const e = __gm.world()?.entities.find((x) => x.objectId === 'phys-ball'); return e && { x: e.worldX, y: e.worldY }; })()`, + ); + await stop(); + const top = Y + CORNER_DROP - BALL_R; // the ball's centre when it rolls along the block's top face + check( + 'Move rolls a ball up over the edge it runs into, onto the top face', + ball && ball.x > 150 && ball.y <= top + 0.01 && ball.y > top - 3, + JSON.stringify({ ball, top }), + ); + const lines = consoleLog.slice(edgeLog).filter((line) => line.startsWith('[behavior ')); + const logged = lines + .filter((line) => line.startsWith('[behavior Roll]')) + .map((line) => Number(line.split(']').pop())); + const reach = Math.sqrt(BALL_R ** 2 - CORNER_DROP ** 2); + check( + "Move's hit and normal outputs: the first contact faces from the corner to the ball's centre", + logged.length >= 2 && + Math.abs(logged[0] + reach / BALL_R) < 1e-6 && + Math.abs(logged[1] + CORNER_DROP / BALL_R) < 1e-6, + JSON.stringify({ + logged: logged.slice(0, 4), + expected: [-reach / BALL_R, -CORNER_DROP / BALL_R], + lines: lines.slice(0, 4), + }), + ); + await clearPhysics(); }