Move the one-way rule and collider geometry out of physics (#215)
game-maker / gate (push) Successful in 2m48s
game-maker / suite (push) Successful in 8m46s

PhysicsSystem had 940 lines. The step logic shares its state with
the engine handles of parts and bodies, so a separate class would
need almost the whole system as its interface. That part stays. Two
pieces without own state move out instead. Behaviour is unchanged.

- passesOneWay in oneWay.ts is the one-way rule as a pure function.
  It can now be tested without Rapier.
- bodyGeometry.ts holds the collider shapes and the pose maths.
- PhysicsSystem is down to 820 lines.

Tests:
- ./check.sh: 146 files, 2199 tests, CHECK EXIT 0
- 5 new unit tests for the one-way rule without an engine.
- New tests/core/body-geometry.test.ts: a capsule turned by 90
  degrees went unnoticed before. Both tests fail against that bend.
- Counter-checks on landing hold and teleport detection fail in
  3 to 6 tests each.
- e2e 1-28,31,36,39-43 on the final state: ALL PASSED, 1060 checks
- Each of the four #215 commits was checked green on its own in a
  scratch worktree: 2190, 2192, 2192, 2199 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 10:20:07 +02:00
1 parent ed29b2f3dc
commit f9d5e8bca5
5 files changed
+229 -139

No files matched your search

+19 -139
View File
@@ -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;
}
+117
View File
@@ -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;
}
+39
View File
@@ -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);
}
+20
View File
@@ -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);
});
});
+34
View File
@@ -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);
});
});