diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 1096b002..d8726a48 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -7,9 +7,8 @@ import type { World as RapierWorld, } from '@dimforge/rapier2d-compat'; import { isHandleEnabled, worldMatrixOf } from '../../core/graph'; -import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model/matrix'; +import { apply, determinant, IDENTITY, invert } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; -import { signedArea } from '../../core/model/polygon'; import { parentChain } from '../../core/model/walk'; import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers'; import type { TileMaterial } from '../../core/model/tilemapCollision'; @@ -17,10 +16,8 @@ import { type ColliderShape, contactBetween, isTrigger, - landsOn, type OneWaySide, movedShape, - piecesOf, shapeArea, shapesOverlap, } from '../../core/properties/collider'; @@ -51,25 +48,26 @@ import { 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; -/** A pose that moved less than this (world units, radians) was not moved by anyone. */ -const POSE_EPSILON = 1e-6; -/** - * How deep (world units) a body may sit in a one-way collider and still count as on top of it: the - * engine lets a landing sink in a little before it pushes it out (1.9 px measured at 60 Hz, #155). - */ -const ONE_WAY_SLOP = 2; - -interface Pose { - x: number; - y: number; - angle: number; -} /** * One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below @@ -626,12 +624,8 @@ export class PhysicsSystem implements System, CollisionSource { } /** - * Whether each dynamic body near a one-way collider passes through it in the coming step (#155). - * It lands when it is on the blocking side (or sunk in no deeper than a landing sinks), the contact - * faces that side within the collider's angle (#159) and it is not moving away from it; a body - * that landed is held as long as the two overlap, however deep a fast fall took it in one step. - * Otherwise it passes – from below, from the side, from inside – and so does a body told to ignore - * one-way colliders. One that is to pass is woken: a resting body sleeps, and a new answer alone + * 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 { @@ -657,12 +651,8 @@ export class PhysicsSystem implements System, CollisionSource { continue; const key = handlePairKey(hp, hm); const contact = contactBetween(shape, surface, relative); - const lands = - contact.separation >= -ONE_WAY_SLOP && - landsOn(contact.normal, facing) && - relative.x * facing.x + relative.y * facing.y <= 1e-3; - const held = this.passes.get(key) === false && contact.separation < 0; - const passes = world.ignoresOneWay(mover.entity) || (!lands && !held); + 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); } @@ -820,112 +810,6 @@ function handlePairKey(h1: number, h2: number): string { return h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`; } -/** How far the shape reaches from its centre. */ -function reachOf(shape: ColliderShape): number { - let reach = 0; - for (const { points, r } of piecesOf(shape)) - for (const p of points) reach = Math.max(reach, Math.hypot(p.x - shape.x, p.y - shape.y) + r); - return reach; -} - -function poseOf(body: RigidBody): Pose { - const t = body.translation(); - return { x: toPixels(t.x), y: toPixels(t.y), angle: body.rotation() }; -} - -function matrixPose(m: Mat): Pose { - return { x: m.tx, y: m.ty, angle: Math.atan2(m.b, m.a) }; -} - -function samePose(a: Pose, b: Pose): boolean { - return ( - Math.abs(a.x - b.x) < POSE_EPSILON && - Math.abs(a.y - b.y) < POSE_EPSILON && - Math.abs(angleBetween(a.angle, b.angle)) < POSE_EPSILON - ); -} - -/** The turn from `from` to `to`, the short way round. */ -function angleBetween(from: number, to: number): number { - const d = (to - from) % (2 * Math.PI); - return d > Math.PI ? d - 2 * Math.PI : d < -Math.PI ? d + 2 * Math.PI : d; -} - -function blend(a: Pose, b: Pose, t: number): Pose { - return { - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - angle: a.angle + angleBetween(a.angle, b.angle) * t, - }; -} - -/** - * One engine collider in the body's own frame (world units): a ball, a box or a capsule placed and - * turned there, or a convex piece given by its corners. - */ -type Piece = - | { kind: 'ball'; x: number; y: number; angle: number; r: number } - | { kind: 'cuboid'; x: number; y: number; angle: number; hw: number; hh: number } - | { kind: 'capsule'; x: number; y: number; angle: number; half: number; r: number } - | { kind: 'convex'; points: Vec2[] }; - -/** A world shape as seen from a body at `origin`, as the engine colliders that make it up. */ -function piecesInBody(origin: Pose, shape: ColliderShape): Piece[] { - const cos = Math.cos(origin.angle); - const sin = Math.sin(origin.angle); - const toBody = (p: Vec2): Vec2 => { - const dx = p.x - origin.x; - const dy = p.y - origin.y; - return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos }; - }; - switch (shape.kind) { - case 'circle': - return [{ kind: 'ball', ...toBody(shape), angle: 0, r: shape.r }]; - case 'box': - return [ - { - kind: 'cuboid', - ...toBody(shape), - angle: shape.angle - origin.angle, - hw: shape.w / 2, - hh: shape.h / 2, - }, - ]; - case 'capsule': { - const a = toBody(shape.a); - const b = toBody(shape.b); - const half = Math.hypot(b.x - a.x, b.y - a.y) / 2; - const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; - if (half === 0) return [{ kind: 'ball', ...mid, angle: 0, r: shape.r }]; - // The engine's capsule lies along its own y axis. - const angle = Math.atan2(b.y - a.y, b.x - a.x) - Math.PI / 2; - return [{ kind: 'capsule', ...mid, angle, half, r: shape.r }]; - } - case 'polygon': - return shape.pieces.map((piece) => ({ kind: 'convex', points: piece.map(toBody) })); - } -} - -function pieceArea(piece: Piece): number { - switch (piece.kind) { - case 'ball': - return Math.PI * piece.r * piece.r; - case 'cuboid': - return 4 * piece.hw * piece.hh; - case 'capsule': - return Math.PI * piece.r * piece.r + 4 * piece.half * piece.r; - case 'convex': - return Math.abs(signedArea(piece.points)); - } -} - -/** What a piece was built from, rounded, so a rebuild happens when – and only when – it changed. */ -function pieceKey(piece: Piece): unknown { - if (piece.kind === 'convex') return piece.points.map((p) => [round(p.x), round(p.y)]); - const { kind, ...values } = piece; - return [kind, ...Object.values(values).map(round)]; -} - /** 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); @@ -934,7 +818,3 @@ function isSolid(entity: Entity): boolean { function depthOf(entity: Entity): number { return [...parentChain(entity, (e) => e.parent)].length; } - -function round(n: number): number { - return Math.round(n * 1e4) / 1e4; -} diff --git a/src/runtime/physics/bodyGeometry.ts b/src/runtime/physics/bodyGeometry.ts new file mode 100644 index 00000000..de9c12be --- /dev/null +++ b/src/runtime/physics/bodyGeometry.ts @@ -0,0 +1,117 @@ +import type { RigidBody } from '@dimforge/rapier2d-compat'; +import type { Mat } from '../../core/model/matrix'; +import type { Vec2 } from '../../core/model/Properties'; +import { signedArea } from '../../core/model/polygon'; +import type { ColliderShape } from '../../core/properties/collider'; +import { toPixels } from './engine'; + +/** A pose that moved less than this (world units, radians) was not moved by anyone. */ +export const POSE_EPSILON = 1e-6; + +export interface Pose { + x: number; + y: number; + angle: number; +} + +export function poseOf(body: RigidBody): Pose { + const t = body.translation(); + return { x: toPixels(t.x), y: toPixels(t.y), angle: body.rotation() }; +} + +export function matrixPose(m: Mat): Pose { + return { x: m.tx, y: m.ty, angle: Math.atan2(m.b, m.a) }; +} + +export function samePose(a: Pose, b: Pose): boolean { + return ( + Math.abs(a.x - b.x) < POSE_EPSILON && + Math.abs(a.y - b.y) < POSE_EPSILON && + Math.abs(angleBetween(a.angle, b.angle)) < POSE_EPSILON + ); +} + +/** The turn from `from` to `to`, the short way round. */ +export function angleBetween(from: number, to: number): number { + const d = (to - from) % (2 * Math.PI); + return d > Math.PI ? d - 2 * Math.PI : d < -Math.PI ? d + 2 * Math.PI : d; +} + +export function blend(a: Pose, b: Pose, t: number): Pose { + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + angle: a.angle + angleBetween(a.angle, b.angle) * t, + }; +} + +/** + * One engine collider in the body's own frame (world units): a ball, a box or a capsule placed and + * turned there, or a convex piece given by its corners. + */ +export type Piece = + | { kind: 'ball'; x: number; y: number; angle: number; r: number } + | { kind: 'cuboid'; x: number; y: number; angle: number; hw: number; hh: number } + | { kind: 'capsule'; x: number; y: number; angle: number; half: number; r: number } + | { kind: 'convex'; points: Vec2[] }; + +/** A world shape as seen from a body at `origin`, as the engine colliders that make it up. */ +export function piecesInBody(origin: Pose, shape: ColliderShape): Piece[] { + const cos = Math.cos(origin.angle); + const sin = Math.sin(origin.angle); + const toBody = (p: Vec2): Vec2 => { + const dx = p.x - origin.x; + const dy = p.y - origin.y; + return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos }; + }; + switch (shape.kind) { + case 'circle': + return [{ kind: 'ball', ...toBody(shape), angle: 0, r: shape.r }]; + case 'box': + return [ + { + kind: 'cuboid', + ...toBody(shape), + angle: shape.angle - origin.angle, + hw: shape.w / 2, + hh: shape.h / 2, + }, + ]; + case 'capsule': { + const a = toBody(shape.a); + const b = toBody(shape.b); + const half = Math.hypot(b.x - a.x, b.y - a.y) / 2; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + if (half === 0) return [{ kind: 'ball', ...mid, angle: 0, r: shape.r }]; + // The engine's capsule lies along its own y axis. + const angle = Math.atan2(b.y - a.y, b.x - a.x) - Math.PI / 2; + return [{ kind: 'capsule', ...mid, angle, half, r: shape.r }]; + } + case 'polygon': + return shape.pieces.map((piece) => ({ kind: 'convex', points: piece.map(toBody) })); + } +} + +export function pieceArea(piece: Piece): number { + switch (piece.kind) { + case 'ball': + return Math.PI * piece.r * piece.r; + case 'cuboid': + return 4 * piece.hw * piece.hh; + case 'capsule': + return Math.PI * piece.r * piece.r + 4 * piece.half * piece.r; + case 'convex': + return Math.abs(signedArea(piece.points)); + } +} + +/** What a piece was built from, rounded, so a rebuild happens when – and only when – it changed. */ +export function pieceKey(piece: Piece): unknown { + if (piece.kind === 'convex') return piece.points.map((p) => [round(p.x), round(p.y)]); + const { kind, ...values } = piece; + return [kind, ...Object.values(values).map(round)]; +} + +export function round(n: number): number { + return Math.round(n * 1e4) / 1e4; +} diff --git a/src/runtime/physics/oneWay.ts b/src/runtime/physics/oneWay.ts new file mode 100644 index 00000000..f368ddd7 --- /dev/null +++ b/src/runtime/physics/oneWay.ts @@ -0,0 +1,39 @@ +import type { Vec2 } from '../../core/model/Properties'; +import { type ColliderShape, landsOn, type OneWaySide, piecesOf } from '../../core/properties/collider'; + +/** + * How deep (world units) a body may sit in a one-way collider and still count as on top of it: the + * engine lets a landing sink in a little before it pushes it out (1.9 px measured at 60 Hz, #155). + */ +export const ONE_WAY_SLOP = 2; + +/** How far the shape reaches from its centre. */ +export function reachOf(shape: ColliderShape): number { + let reach = 0; + for (const { points, r } of piecesOf(shape)) + for (const p of points) reach = Math.max(reach, Math.hypot(p.x - shape.x, p.y - shape.y) + r); + return reach; +} + +/** + * Whether a body passes through a one-way collider in the coming step (#155). It lands when it is + * on the blocking side (or sunk in no deeper than a landing sinks), the contact faces that side + * within the collider's angle (#159) and it is not moving away from it; a body that `landed` in + * the step before is held as long as the two overlap, however deep a fast fall took it in one step. + * Otherwise it passes – from below, from the side, from inside – and so does a body told to ignore + * one-way colliders. + */ +export function passesOneWay( + contact: { separation: number; normal: Vec2 }, + facing: OneWaySide, + relative: Vec2, + landed: boolean, + ignores: boolean, +): boolean { + const lands = + contact.separation >= -ONE_WAY_SLOP && + landsOn(contact.normal, facing) && + relative.x * facing.x + relative.y * facing.y <= 1e-3; + const held = landed && contact.separation < 0; + return ignores || (!lands && !held); +} diff --git a/tests/core/body-geometry.test.ts b/tests/core/body-geometry.test.ts new file mode 100644 index 00000000..4f1ee6b3 --- /dev/null +++ b/tests/core/body-geometry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { piecesInBody } from '../../src/runtime/physics/bodyGeometry'; + +describe('a collider as the engine builds it', () => { + const origin = { x: 100, y: 50, angle: 0 }; + const capsule = (a: { x: number; y: number }, b: { x: number; y: number }) => + ({ kind: 'capsule', x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, angle: 0, a, b, r: 5 }) as const; + + it('a standing capsule is the engine capsule unturned, since that one lies along its y axis', () => { + const [piece] = piecesInBody(origin, capsule({ x: 100, y: 30 }, { x: 100, y: 70 })); + expect(piece).toMatchObject({ kind: 'capsule', x: 0, y: 0, half: 20, r: 5 }); + expect(piece?.kind === 'capsule' && Math.abs(Math.sin(piece.angle))).toBeCloseTo(0); + }); + + it('a lying capsule is turned a quarter, in the frame of the body it hangs on', () => { + const [piece] = piecesInBody(origin, capsule({ x: 90, y: 60 }, { x: 130, y: 60 })); + expect(piece).toMatchObject({ kind: 'capsule', x: 10, y: 10, half: 20 }); + expect(piece?.kind === 'capsule' && Math.abs(Math.cos(piece.angle))).toBeCloseTo(0); + }); +}); diff --git a/tests/core/one-way.test.ts b/tests/core/one-way.test.ts index cd8f4075..81add556 100644 --- a/tests/core/one-way.test.ts +++ b/tests/core/one-way.test.ts @@ -8,6 +8,7 @@ import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { TILE_ONE_WAY_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; +import { ONE_WAY_SLOP, passesOneWay } from '../../src/runtime/physics/oneWay'; import { solidRects } from '../../src/core/model/tilemapCollision'; import { loadPhysics } from '../../src/runtime/physics/engine'; import type { PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem'; @@ -252,3 +253,36 @@ describe('the physics engine and one-way colliders (#155)', () => { expect(r.entity(rises).y).toBeCloseTo(-10, 1); }); }); + +describe('the one-way rule, without the engine (#155)', () => { + const up = { x: 0, y: -1, cone: Math.cos(Math.PI / 4) }; + const onTop = (separation: number) => ({ separation, normal: { x: 0, y: -1 } }); + const falling = { x: 0, y: 100 }; + + it('a body falling onto the top lands, one coming from below passes', () => { + expect(passesOneWay(onTop(0), up, falling, false, false)).toBe(false); + expect(passesOneWay({ separation: 0, normal: { x: 0, y: 1 } }, up, { x: 0, y: -100 }, false, false)).toBe( + true, + ); + }); + + it('a contact outside the angle passes: from the side', () => { + expect(passesOneWay({ separation: 0, normal: { x: 1, y: 0 } }, up, { x: -100, y: 0 }, false, false)).toBe( + true, + ); + }); + + it('sunk in as deep as a landing sinks still lands, deeper only when it landed before', () => { + expect(passesOneWay(onTop(-ONE_WAY_SLOP), up, falling, false, false)).toBe(false); + expect(passesOneWay(onTop(-5), up, falling, false, false)).toBe(true); + expect(passesOneWay(onTop(-5), up, falling, true, false)).toBe(false); + }); + + it('a body moving away from the top is not held once it no longer overlaps', () => { + expect(passesOneWay(onTop(0), up, { x: 0, y: -100 }, true, false)).toBe(true); + }); + + it('a body told to ignore one-way colliders passes even where it would land', () => { + expect(passesOneWay(onTop(0), up, falling, true, true)).toBe(true); + }); +});