Move with collision reacts along the contact normal: it travels to the surface, stays flush and slides the rest of the move along it with Coulomb friction (the mean of both colliders, a tile counts as 0.5), a circle now rolls over an edge instead of catching on it, pushed dynamic bodies move off along the normal, Move gains the outputs hit, normal x and normal y, an object without its own collider moves freely, and editor run and play share the same path through Solids; contact point and normal come from our own f64 geometry (contactBetween), because Rapier's castShape lands 3e-6 px inside the wall in f32 (#150)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d931e66f92
commit
1b5e386b60
15 files changed
+680
-161
No files matched your search
+228
-15
@@ -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();
|
||||
|
||||
@@ -836,13 +836,28 @@ const CASES: Record<string, Case[]> = {
|
||||
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: [
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -227,7 +227,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): 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: [],
|
||||
|
||||
Reference in new issue
Block a user