566 lines
28 KiB
TypeScript
566 lines
28 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
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,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
const box = (x: number, y: number, w: number, h: number, degrees = 0) => ({
|
||
kind: 'box' as const,
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
angle: (degrees * Math.PI) / 180,
|
||
});
|
||
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
|
||
const LEFT = { x: -1, y: 0 };
|
||
const UP = { x: 0, y: -1 };
|
||
|
||
describe('collider geometry', () => {
|
||
it('box vs box: overlapping yes, touching edges no', () => {
|
||
expect(shapesOverlap(box(0, 0, 100, 100), box(90, 0, 100, 100))).toBe(true);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), box(100, 0, 100, 100))).toBe(false);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
|
||
});
|
||
|
||
it('a turned box reaches where its corners are, not where its upright frame would be', () => {
|
||
// A 100×20 bar turned by 45° reaches ≈ 42.4 along x; upright it would reach 50.
|
||
expect(shapesOverlap(box(0, 0, 100, 20, 45), box(95, 0, 100, 100))).toBe(false);
|
||
expect(shapesOverlap(box(0, 0, 100, 20), box(95, 0, 100, 100))).toBe(true);
|
||
// A diamond (square turned by 45°, corners at ±70.7) and a square at (110, 110): their upright
|
||
// frames overlap (120.7 > 110 on both axes) but the square's corner (60, 60) lies outside the
|
||
// diamond's edge x + y = 70.7.
|
||
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(110, 110, 100, 100))).toBe(false);
|
||
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(95, 0, 100, 100))).toBe(true);
|
||
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 0, y: 45 })).toBe(true);
|
||
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 45, y: 0 })).toBe(false);
|
||
});
|
||
|
||
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
|
||
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
|
||
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), circle(60, 0, 20))).toBe(true);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), circle(80, 0, 20))).toBe(false);
|
||
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
|
||
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
|
||
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
|
||
// The same corner turned to (0, 70.7): the circle at (0, 80) with r=10 just misses it.
|
||
expect(shapesOverlap(circle(0, 80, 10), box(0, 0, 100, 100, 45))).toBe(true);
|
||
expect(shapesOverlap(circle(0, 81, 10), box(0, 0, 100, 100, 45))).toBe(false);
|
||
});
|
||
|
||
it('builds shapes from properties with defaults, sized and turned by the transform', () => {
|
||
expect(colliderShape({}, translation(1, 2))).toEqual(box(1, 2, 100, 100));
|
||
expect(colliderShape({ shape: 'circle', radius: 7 }, translation(0, 0))).toEqual(circle(0, 0, 7));
|
||
const turned = colliderShape({ width: 40, height: 20 }, transformMatrix(5, 6, 30, 2, false, false));
|
||
expect(turned).toMatchObject({ kind: 'box', x: 5, y: 6, w: 80, h: 40 });
|
||
expect(turned.kind === 'box' && (turned.angle * 180) / Math.PI).toBeCloseTo(30);
|
||
// A mirror keeps a box a box: the same shape, whichever way its x axis points.
|
||
const mirrored = colliderShape({ width: 40, height: 20 }, transformMatrix(0, 0, 0, 1, true, false));
|
||
expect(shapeContains(mirrored, { x: 19, y: 9 })).toBe(true);
|
||
expect(shapeContains(mirrored, { x: 21, y: 9 })).toBe(false);
|
||
expect(colliderShape({ shape: 'circle', radius: 7 }, transformMatrix(0, 0, 0, 3, false, false))).toEqual(
|
||
circle(0, 0, 21),
|
||
);
|
||
});
|
||
|
||
it('exit distance: how far a mover must travel to just clear an obstacle', () => {
|
||
// Mover moved right into a box: centres 80 apart, need 100 → 20 back to the left.
|
||
expect(exitDistance(box(80, 0, 100, 100), box(0, 0, 100, 100), { x: 1, y: 0 })).toBe(20);
|
||
expect(exitDistance(box(0, 0, 100, 100), box(80, 0, 100, 100), LEFT)).toBe(20);
|
||
expect(exitDistance(box(0, 0, 100, 100), box(120, 0, 100, 100), LEFT)).toBe(0);
|
||
expect(exitDistance(box(0, 0, 100, 100), box(0, 90, 100, 100), UP)).toBe(10);
|
||
expect(exitDistance(box(0, 10, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(70);
|
||
expect(exitDistance(box(0, 100, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(0); // apart on y
|
||
expect(exitDistance(circle(0, 12, 10), circle(0, 0, 10), LEFT)).toBeCloseTo(16);
|
||
expect(exitDistance(circle(0, 20, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(60); // flat side
|
||
expect(exitDistance(circle(0, 56, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
|
||
expect(exitDistance(circle(0, 70, 10), box(0, 0, 100, 100), LEFT)).toBe(0);
|
||
// Moving away diagonally clears a corner sooner than along one axis would.
|
||
expect(exitDistance(box(80, 80, 100, 100), box(0, 0, 100, 100), { x: 1, y: 1 })).toBeCloseTo(
|
||
20 * Math.SQRT2,
|
||
);
|
||
// A wall turned by 45°, a box moving left into its slanted face: level with the corner it must
|
||
// back off to the corner (70.7 + 10 − 60), higher up the face is only half a unit deep along
|
||
// its normal – an upright frame would still demand the 20.7.
|
||
expect(exitDistance(box(-60, 0, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
|
||
50 * Math.SQRT2 + 10 - 60,
|
||
);
|
||
expect(exitDistance(box(-60, 30, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
|
||
(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. */
|
||
function setup() {
|
||
const f = makeSpriteScene('Crate');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||
const behavior = makeBehavior(f, 'Hit');
|
||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||
const colA = new AddColliderCommand(f.sceneId, a);
|
||
const colB = new AddColliderCommand(f.sceneId, b);
|
||
f.stack.execute(colA);
|
||
f.stack.execute(colB);
|
||
place(f, behavior.id, { x: 0, y: 0 }, a);
|
||
return { ...f, behavior, a, b, colA, colB };
|
||
}
|
||
|
||
/** The crates in a running world with collision detection. */
|
||
function runtime(physics: boolean) {
|
||
const s = setup();
|
||
const { world, collisions } = startWorld(s, { physics, collisions: true });
|
||
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
|
||
}
|
||
|
||
describe('AddColliderCommand', () => {
|
||
it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => {
|
||
const { project, stack, sceneOf, object, a, colA } = setup();
|
||
const asset = findAsset(project, colA.assetId)!;
|
||
expect(asset.type).toBe('collider');
|
||
expect(asset.name).toBe('Crate collider');
|
||
expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 });
|
||
expect(object(colA.objectId).parentId).toBe(a);
|
||
expect(object(colA.objectId).position).toEqual({ x: 0, y: 0, z: 0 });
|
||
// Undo of the last three commands: behavior placement, collider B, collider A.
|
||
stack.undo();
|
||
stack.undo();
|
||
stack.undo();
|
||
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(false);
|
||
expect(sceneOf().objects.some((o) => o.id === colA.objectId)).toBe(false);
|
||
stack.redo();
|
||
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(true);
|
||
});
|
||
|
||
it('refuses a collider on a collider and survives a round trip', () => {
|
||
const { project, stack, sceneId, colA } = setup();
|
||
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(
|
||
/cannot have a collider/,
|
||
);
|
||
expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({
|
||
shape: 'box',
|
||
width: 100,
|
||
height: 60,
|
||
radius: 50,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
|
||
beforeAll(async () => {
|
||
await loadPhysics();
|
||
});
|
||
|
||
describe('enter, stay and exit', () => {
|
||
it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => {
|
||
const { world, collisions, crateA, crateB } = runtime(physics);
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(0);
|
||
crateB.x = 90; // boxes of width 100 overlap once the centres are closer than 100
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(1);
|
||
expect(collisions.stays).toHaveLength(0);
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(0);
|
||
expect(collisions.stays).toHaveLength(1);
|
||
expect(collisions.touching(world, crateA, crateB)).toBe(true);
|
||
crateB.x = 300;
|
||
world.tick(0.1);
|
||
expect(collisions.exits).toHaveLength(1);
|
||
expect(collisions.stays).toHaveLength(0);
|
||
expect(collisions.touching(world, crateA, crateB)).toBe(false);
|
||
});
|
||
|
||
it('respects overrides and circles; two colliders of the same body never collide', () => {
|
||
const s = setup();
|
||
const { stack, sceneId, a, b, colA, colB } = s;
|
||
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'shape', 'circle'));
|
||
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'radius', 30));
|
||
const second = new AddColliderCommand(sceneId, a);
|
||
stack.execute(second); // a second collider on crate A, overlapping colA completely
|
||
const { world, collisions } = startWorld(s, { physics, collisions: true });
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(0); // colA and the second collider share a body
|
||
world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(2); // circle B against both colliders of A
|
||
expect(
|
||
collisions.enters.every(
|
||
(p) =>
|
||
p.a.objectId === colA.objectId ||
|
||
p.b.objectId === colA.objectId ||
|
||
p.a.objectId === second.objectId ||
|
||
p.b.objectId === second.objectId,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('move and slide', () => {
|
||
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)).toMatchObject({
|
||
blockedX: true,
|
||
blockedY: false,
|
||
});
|
||
expect(crateA.x).toBe(200);
|
||
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)).toMatchObject({
|
||
blockedX: false,
|
||
blockedY: false,
|
||
});
|
||
expect(crateA.x).toBe(170);
|
||
});
|
||
|
||
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', () => {
|
||
const s = setup();
|
||
const { stack, sceneId, a, b, colB } = s;
|
||
// Make B's collider a trigger → A passes through, but the CollisionSystem still sees the overlap.
|
||
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)).toMatchObject({
|
||
blockedX: false,
|
||
blockedY: false,
|
||
});
|
||
expect(crateA.x).toBe(250);
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(1);
|
||
// A collider parented under the mover travels with it and cannot block it.
|
||
const rider = world.entities.find(
|
||
(e) => e.type === 'collider' && e.parent === world.findByObjectId(b),
|
||
)!;
|
||
rider.props.isTrigger = false;
|
||
rider.parent = crateA;
|
||
rider.x = 150;
|
||
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)).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)).toMatchObject({
|
||
blockedX: false,
|
||
blockedY: false,
|
||
});
|
||
expect(crateA.x).toBe(-20); // moving away is always free
|
||
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toMatchObject({
|
||
blockedX: false,
|
||
blockedY: false,
|
||
});
|
||
expect(crateA.x).toBe(-120);
|
||
});
|
||
|
||
it('keeps blocking while pressed against a wall, however long and at any speed', () => {
|
||
const { world, collisions, crateA, crateB } = runtime(physics);
|
||
crateB.x = 0;
|
||
crateB.y = 300; // wall straight above, colliders are 100×60 → contact at y = 240
|
||
for (let i = 0; i < 500; i++) {
|
||
// Uneven steps, so contact is never hit at a round number.
|
||
const { blockedY } = collisions.moveAndSlide(world, crateA, 0, 3.7 + (i % 7) * 0.13);
|
||
expect(crateA.y).toBeLessThanOrEqual(240 + SKIN);
|
||
if (i > 60) expect(blockedY).toBe(true);
|
||
}
|
||
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)).toMatchObject({
|
||
blockedX: false,
|
||
blockedY: true,
|
||
});
|
||
expect(crateA.y).toBeCloseTo(240, 6);
|
||
});
|
||
|
||
it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => {
|
||
const s = setup();
|
||
const { behavior, a } = s;
|
||
// On update → Move(Owner, dx = 1000, dt, collide) → Log(blocked x)
|
||
const edit = graphEditor(s, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const move = edit.addNode('Move', { dx: 1000, collide: true });
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[update, 'exec', move, 'exec'],
|
||
[update, 'dt', move, 'dt'],
|
||
[owner, 'object', move, 'object'],
|
||
[move, 'exec', log, 'exec'],
|
||
[move, 'blockedX', log, 'message'],
|
||
]);
|
||
const { world } = startWorld(s, { physics, collisions: true, behaviors: true });
|
||
const crateA = world.findByObjectId(a)!;
|
||
world.tick(0.1); // 100 units: free
|
||
expect(crateA.x).toBe(100);
|
||
world.tick(0.2); // 200 more would reach 300; the wall stops it at 200
|
||
expect(crateA.x).toBe(200);
|
||
expect(world.log).toEqual(['false', 'true']);
|
||
});
|
||
});
|
||
|
||
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();
|
||
const { behavior, b, object } = s;
|
||
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
|
||
const edit = graphEditor(s, behavior);
|
||
const enter = edit.addNode('OnCollisionEnter');
|
||
const logEnter = edit.addNode('Log');
|
||
const exit = edit.addNode('OnCollisionExit');
|
||
const logExit = edit.addNode('Log', { message: 'bye' });
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const byName = edit.addNode('ObjectByName', { name: 'B' });
|
||
const touching = edit.addNode('IsTouching');
|
||
const logTouch = edit.addNode('Log');
|
||
edit.wire([
|
||
[enter, 'exec', logEnter, 'exec'],
|
||
[enter, 'other', logEnter, 'message'],
|
||
[exit, 'exec', logExit, 'exec'],
|
||
[update, 'exec', logTouch, 'exec'],
|
||
[owner, 'object', touching, 'a'],
|
||
[byName, 'object', touching, 'b'],
|
||
[touching, 'result', logTouch, 'message'],
|
||
]);
|
||
object(b).name = 'B';
|
||
|
||
const { world } = startWorld(s, { physics, collisions: true, behaviors: true });
|
||
const crateB = world.findByObjectId(b)!;
|
||
world.tick(0.1);
|
||
expect(world.log).toEqual(['false']);
|
||
crateB.x = 50;
|
||
world.tick(0.1);
|
||
expect(world.log).toEqual(['false', 'B', 'true']);
|
||
crateB.x = 500;
|
||
world.tick(0.1);
|
||
expect(world.log).toEqual(['false', 'B', 'true', 'bye', 'false']);
|
||
});
|
||
});
|
||
});
|