Move the one-way rule and collider geometry out of physics (#215)
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:
1 parent
ed29b2f3dc
commit
f9d5e8bca5
5 files changed
+229
-139
No files matched your search
@@ -7,9 +7,8 @@ import type {
|
|||||||
World as RapierWorld,
|
World as RapierWorld,
|
||||||
} from '@dimforge/rapier2d-compat';
|
} from '@dimforge/rapier2d-compat';
|
||||||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
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 type { Vec2 } from '../../core/model/Properties';
|
||||||
import { signedArea } from '../../core/model/polygon';
|
|
||||||
import { parentChain } from '../../core/model/walk';
|
import { parentChain } from '../../core/model/walk';
|
||||||
import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers';
|
import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers';
|
||||||
import type { TileMaterial } from '../../core/model/tilemapCollision';
|
import type { TileMaterial } from '../../core/model/tilemapCollision';
|
||||||
@@ -17,10 +16,8 @@ import {
|
|||||||
type ColliderShape,
|
type ColliderShape,
|
||||||
contactBetween,
|
contactBetween,
|
||||||
isTrigger,
|
isTrigger,
|
||||||
landsOn,
|
|
||||||
type OneWaySide,
|
type OneWaySide,
|
||||||
movedShape,
|
movedShape,
|
||||||
piecesOf,
|
|
||||||
shapeArea,
|
shapeArea,
|
||||||
shapesOverlap,
|
shapesOverlap,
|
||||||
} from '../../core/properties/collider';
|
} from '../../core/properties/collider';
|
||||||
@@ -51,25 +48,26 @@ import {
|
|||||||
surfaceOf,
|
surfaceOf,
|
||||||
} from '../systems/solids';
|
} from '../systems/solids';
|
||||||
import type { System } from '../systems/System';
|
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 { type Rapier, toMetres, toPixels } from './engine';
|
||||||
|
import { passesOneWay, reachOf } from './oneWay';
|
||||||
|
|
||||||
/** The engine always steps this long, whatever the frame took. */
|
/** The engine always steps this long, whatever the frame took. */
|
||||||
export const PHYSICS_STEP = 1 / 60;
|
export const PHYSICS_STEP = 1 / 60;
|
||||||
/** After a hitch the world catches up this many steps at most, and forgets the rest. */
|
/** After a hitch the world catches up this many steps at most, and forgets the rest. */
|
||||||
const MAX_STEPS_PER_TICK = 8;
|
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
|
* 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).
|
* 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
|
* by `passesOneWay`. One that is to pass is woken: a resting body sleeps, and a new answer alone
|
||||||
* 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
|
|
||||||
* would not move it.
|
* would not move it.
|
||||||
*/
|
*/
|
||||||
private decideOneWay(world: World): void {
|
private decideOneWay(world: World): void {
|
||||||
@@ -657,12 +651,8 @@ export class PhysicsSystem implements System, CollisionSource {
|
|||||||
continue;
|
continue;
|
||||||
const key = handlePairKey(hp, hm);
|
const key = handlePairKey(hp, hm);
|
||||||
const contact = contactBetween(shape, surface, relative);
|
const contact = contactBetween(shape, surface, relative);
|
||||||
const lands =
|
const landed = this.passes.get(key) === false;
|
||||||
contact.separation >= -ONE_WAY_SLOP &&
|
const passes = passesOneWay(contact, facing, relative, landed, world.ignoresOneWay(mover.entity));
|
||||||
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);
|
|
||||||
if (passes && mover.owner.body.isSleeping()) mover.owner.body.wakeUp();
|
if (passes && mover.owner.body.isSleeping()) mover.owner.body.wakeUp();
|
||||||
decided.set(key, passes);
|
decided.set(key, passes);
|
||||||
}
|
}
|
||||||
@@ -820,112 +810,6 @@ function handlePairKey(h1: number, h2: number): string {
|
|||||||
return h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`;
|
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. */
|
/** A tilemap's cells always block; a collider unless it is a trigger. */
|
||||||
function isSolid(entity: Entity): boolean {
|
function isSolid(entity: Entity): boolean {
|
||||||
return entity.type !== 'collider' || !isTrigger(entity.props);
|
return entity.type !== 'collider' || !isTrigger(entity.props);
|
||||||
@@ -934,7 +818,3 @@ function isSolid(entity: Entity): boolean {
|
|||||||
function depthOf(entity: Entity): number {
|
function depthOf(entity: Entity): number {
|
||||||
return [...parentChain(entity, (e) => e.parent)].length;
|
return [...parentChain(entity, (e) => e.parent)].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function round(n: number): number {
|
|
||||||
return Math.round(n * 1e4) / 1e4;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
|||||||
import { findAsset } from '../../src/core/model/Project';
|
import { findAsset } from '../../src/core/model/Project';
|
||||||
import { TILE_ONE_WAY_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
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 { 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 { solidRects } from '../../src/core/model/tilemapCollision';
|
||||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||||
import type { PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem';
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user