Files
feuerwerk/tests/core/physics-system.test.ts
T

487 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import { worldPositionOf } from '../../src/core/graph';
import type { TilemapAsset } from '../../src/core/model/Asset';
import type { PropertyValue } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
import type { Entity } from '../../src/runtime/Entity';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { PHYSICS_STEP, type PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem';
import type { CollisionPair } from '../../src/runtime/systems/solids';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
interface Thing {
/** The object's size; its collider is made to match. */
w: number;
h?: number;
circle?: boolean;
/** Overrides on the collider (friction, bounciness, density, isTrigger). */
collider?: Record<string, PropertyValue>;
/** With a rigidbody, and these overrides on it (bodyType, fixedRotation, …). */
body?: Record<string, PropertyValue>;
/** Overrides on the object itself (rotation, scale). */
props?: Record<string, PropertyValue>;
parent?: string;
}
/** A scene of sprites with colliders, each maybe with a rigidbody; `add` returns the object's id. */
function physicsScene() {
const f = makeSpriteScene('Box');
const bodies = new Map<string, string>();
const add = (x: number, y: number, thing: Thing): string => {
const id = place(f, f.spriteId, { x, y }, thing.parent);
const set = (objectId: string, key: string, value: PropertyValue) =>
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, key, value));
set(id, 'width', thing.w);
set(id, 'height', thing.h ?? thing.w);
for (const [key, value] of Object.entries(thing.props ?? {})) set(id, key, value);
const collider = new AddColliderCommand(f.sceneId, id);
f.stack.execute(collider);
if (thing.circle) {
set(collider.objectId, 'shape', 'circle');
set(collider.objectId, 'radius', thing.w / 2);
}
for (const [key, value] of Object.entries(thing.collider ?? {})) set(collider.objectId, key, value);
if (thing.body) {
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
f.stack.execute(rigidbody);
for (const [key, value] of Object.entries(thing.body)) set(rigidbody.objectId, key, value);
bodies.set(id, rigidbody.objectId);
}
return id;
};
const gravity = (x: number, y: number) => {
f.stack.execute(new SetAssetPropertyCommand(f.sceneId, 'gravityX', x));
f.stack.execute(new SetAssetPropertyCommand(f.sceneId, 'gravityY', y));
};
return { ...f, add, gravity, rigidbodyOf: (id: string) => bodies.get(id)! };
}
/** The ground used throughout: 400 × 40 at y = 200, its top face at y = 180. */
const GROUND: Thing = { w: 400, h: 40 };
function run(f: SceneFixture) {
const started = startWorld(f, { physics: true, collisions: true, behaviors: true });
const physics = started.collisions as PhysicsSystem;
const seconds = (s: number, each?: () => void) => {
for (let i = 0; i < Math.round(s * 60); i++) {
started.frame(1 / 60);
each?.();
}
};
const entity = (id: string) => started.world.findByObjectId(id)!;
return { ...started, physics, seconds, entity };
}
describe('AddRigidbodyCommand', () => {
it('puts a rigidbody below the object, one at most, and undo takes it away', () => {
const f = physicsScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });
const command = new AddRigidbodyCommand(f.sceneId, box);
f.stack.execute(command);
const asset = findAsset(f.project, command.assetId)!;
expect(asset).toMatchObject({ type: 'rigidbody', name: 'Box rigidbody' });
expect(asset.props).toMatchObject({
bodyType: 'dynamic',
mass: 0,
gravityScale: 1,
fixedRotation: false,
});
expect(f.object(command.objectId).parentId).toBe(box);
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, box))).toThrow(/has a rigidbody already/);
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, command.objectId))).toThrow(
/rigidbody cannot have a rigidbody/,
);
const collider = new AddColliderCommand(f.sceneId, box);
f.stack.execute(collider);
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, collider.objectId))).toThrow(
/collider cannot have a rigidbody/,
);
f.stack.undo();
f.stack.undo();
expect(f.project.assets.some((a) => a.id === command.assetId)).toBe(false);
expect(f.sceneOf().objects.some((o) => o.id === command.objectId)).toBe(false);
});
it('keeps the material and the body settings through a round trip', () => {
const f = physicsScene();
const box = f.add(0, 0, {
w: 32,
collider: { friction: 0.2, bounciness: 0.7 },
body: { bodyType: 'kinematic' },
});
const loaded = roundTrip(f.project);
const scene = loaded.assets.find((a) => a.id === f.sceneId)!;
expect(scene.props).toMatchObject({ gravityX: 0, gravityY: 980 });
const objects = scene.type === 'scene' ? scene.objects : [];
const child = (type: string) =>
objects.find((o) => o.parentId === box && findAsset(loaded, o.assetId)?.type === type)!;
expect(child('collider').overrides).toMatchObject({ friction: 0.2, bounciness: 0.7 });
expect(child('rigidbody').overrides).toMatchObject({ bodyType: 'kinematic' });
});
});
describe('falling and resting', () => {
it('a dynamic box falls at 980 per second squared and comes to rest on a collider without a rigidbody', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, physics } = run(f);
seconds(0.5);
// ½·g·t² = 122.5 after 30 steps; the picture is drawn between the last two steps, and with
// frames exactly one step long that is the one before: ½·g·(29/60)² ≈ 114.5.
expect(entity(box).y).toBeGreaterThan(114.5 - 2);
expect(entity(box).y).toBeLessThan(122.5 + 2);
seconds(2.5);
expect(entity(box).y).toBeCloseTo(180 - 16, 0);
expect(Math.abs(physics.velocityOf(entity(box))!.y)).toBeLessThan(1);
seconds(2);
expect(entity(box).y).toBeCloseTo(180 - 16, 0);
expect(entity(box).x).toBeCloseTo(0, 3);
});
it('comes to rest on a painted tilemap', () => {
const f = physicsScene();
const solid = new CreateAssetCommand('tile', 'Wall');
const map = new CreateAssetCommand('tilemap', 'Ground');
f.stack.execute(solid);
f.stack.execute(map);
f.stack.execute(new SetAssetPropertyCommand(solid.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
place(f, map.asset.id, { x: 0, y: 0 });
const layer = (findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.id;
// The bottom row: cells of 48, the grid centred on the origin, so its top face is at 4·48 = 192.
const row = Array.from({ length: 10 }, (_, x) => ({ x, y: 9, tileId: solid.asset.id }));
f.stack.execute(new PaintTilesCommand(map.asset.id, layer, row));
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(3);
expect(entity(box).y).toBeCloseTo(192 - 16, 0);
});
it('a static, a switched-off or a weightless rigidbody stays where it is, and so does all in a scene without gravity', () => {
const f = physicsScene();
const still = f.add(-150, 0, { w: 32, body: { bodyType: 'static' } });
const weightless = f.add(-50, 0, { w: 32, body: { gravityScale: 0 } });
const off = f.add(50, 0, { w: 32, body: {} });
f.stack.execute(new SetOverrideCommand(f.sceneId, f.rigidbodyOf(off), 'enabled', false));
const falls = f.add(150, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(1);
expect(entity(still).y).toBe(0);
expect(entity(weightless).y).toBe(0);
expect(entity(off).y).toBe(0);
expect(entity(falls).y).toBeGreaterThan(400);
const g = physicsScene();
g.gravity(0, 0);
const floating = g.add(0, 0, { w: 32, body: {} });
const second = run(g);
second.seconds(1);
expect(second.entity(floating).y).toBe(0);
});
it('gravity is the scene’s own and can pull sideways', () => {
const f = physicsScene();
f.gravity(490, 0);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(1);
expect(Math.abs(entity(box).x - 245)).toBeLessThan(10);
expect(entity(box).y).toBeCloseTo(0, 6);
});
});
describe('the material', () => {
/** How high a ball dropped from 184 above the ground climbs again after its first bounce. */
function rebound(bounciness: number): number {
const f = physicsScene();
f.add(0, 200, GROUND);
const ball = f.add(0, 0, { w: 32, circle: true, body: {}, collider: { bounciness } });
const { seconds, entity } = run(f);
let landed = false;
let highest = Infinity;
seconds(4, () => {
const y = entity(ball).y;
if (y > 180 - 16 - 1) landed = true;
else if (landed) highest = Math.min(highest, y);
});
return Number.isFinite(highest) ? 180 - 16 - highest : 0;
}
it('bounciness 0 stays down, 0.5 climbs back a quarter of the drop, 1 nearly all of it', () => {
const drop = 180 - 16;
expect(rebound(0)).toBeLessThan(1);
const half = rebound(0.5) / drop;
expect(half).toBeGreaterThan(0.2);
expect(half).toBeLessThan(0.3);
expect(rebound(1) / drop).toBeGreaterThan(0.9);
});
/** A box set flat on a slope turned 30°, left to itself for half a second: how far it went. */
function slide(friction: number): number {
const f = physicsScene();
f.add(0, 200, { ...GROUND, props: { rotation: 30 }, collider: { friction } });
// The slope's top face, lifted by half the box: along the face normal (sin 30°, −cos 30°).
const lift = 20 + 16 + 0.5;
const x = lift * Math.sin(Math.PI / 6);
const y = 200 - lift * Math.cos(Math.PI / 6);
const box = f.add(x, y, { w: 32, props: { rotation: 30 }, body: {}, collider: { friction } });
const { seconds, entity } = run(f);
seconds(0.5);
return Math.hypot(entity(box).x - x, entity(box).y - y);
}
it('slides down a slope without friction and holds on it with enough', () => {
// Frictionless the slope pulls with g·sin 30° = 490: about 61 along it in half a second.
expect(slide(0)).toBeGreaterThan(40);
// tan 30° ≈ 0.58, so a friction of 1 holds it.
expect(slide(1)).toBeLessThan(2);
});
it('a push between two masses keeps the momentum', () => {
const f = physicsScene();
f.gravity(0, 0);
const light = f.add(0, 0, { w: 32, body: {}, collider: { bounciness: 1 } });
const heavy = f.add(100, 0, { w: 32, body: {}, collider: { bounciness: 1, density: 3 } });
const { seconds, entity, physics } = run(f);
seconds(1 / 60);
physics.setVelocity(entity(light), { x: 300, y: 0 });
seconds(1);
const after = physics.velocityOf(entity(light))!.x + 3 * physics.velocityOf(entity(heavy))!.x;
expect(physics.velocityOf(entity(heavy))!.x).toBeGreaterThan(100);
expect(Math.abs(after - 300)).toBeLessThan(3);
});
it('a fixed mass counts instead of the density', () => {
const f = physicsScene();
f.gravity(0, 0);
const light = f.add(0, 0, { w: 32, body: {}, collider: { bounciness: 1 } });
// Density 50 would make it fifty times the light one; the mass says three times.
const heavy = f.add(100, 0, {
w: 32,
body: { mass: 3 * 0.32 * 0.32 },
collider: { bounciness: 1, density: 50 },
});
const { seconds, entity, physics } = run(f);
seconds(1 / 60);
physics.setVelocity(entity(light), { x: 300, y: 0 });
seconds(1);
const after = physics.velocityOf(entity(light))!.x + 3 * physics.velocityOf(entity(heavy))!.x;
expect(Math.abs(after - 300)).toBeLessThan(3);
});
});
describe('turning', () => {
/** A box dropped with its centre just past the edge of a narrow ledge. */
function tip(body: Record<string, PropertyValue>): number {
const f = physicsScene();
f.add(0, 200, { w: 100, h: 40 });
const box = f.add(60, 150, { w: 32, body });
const { seconds, entity } = run(f);
seconds(1);
return Number(entity(box).props.rotation ?? 0);
}
it('a box falling past an edge tips over; with fixed rotation it does not', () => {
expect(Math.abs(tip({}))).toBeGreaterThan(5);
expect(tip({ fixedRotation: true })).toBe(0);
});
});
describe('objects and bodies', () => {
it('simulates below a turned parent in world units and hands back a local position', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const parent = place(f, f.spriteId, { x: 100, y: -100 });
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
const box = f.add(20, 0, { w: 32, body: {}, parent });
const { seconds, entity } = run(f);
seconds(3);
const world = worldPositionOf(entity(box));
expect(world.x).toBeCloseTo(20, 1);
expect(world.y).toBeCloseTo(164, 0);
// Turned a quarter clockwise, the parent's own x axis points down the world's y.
expect(entity(box).x).toBeCloseTo(264, 0);
expect(Math.abs(Number(entity(box).props.rotation ?? 0))).toBeLessThan(0.5);
});
it('putting a falling object somewhere else keeps its speed', () => {
const f = physicsScene();
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, physics } = run(f);
seconds(0.5);
const speed = physics.velocityOf(entity(box))!.y;
expect(speed).toBeGreaterThan(400);
entity(box).y = -500;
seconds(1 / 60);
expect(physics.velocityOf(entity(box))!.y).toBeGreaterThan(speed);
expect(entity(box).y).toBeLessThan(-490);
});
it('a scaled object rests on its grown shape', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(2);
expect(entity(box).y).toBeCloseTo(164, 0);
entity(box).props.scale = 2;
seconds(2);
expect(entity(box).y).toBeCloseTo(180 - 32, 0);
});
it('a kinematic body driven sideways pushes a dynamic box along the ground', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 164, { w: 32, body: {} });
const paddle = f.add(-40, 160, { w: 16, h: 30, body: { bodyType: 'kinematic' } });
const { seconds, entity } = run(f);
seconds(0.5);
const start = entity(box).x;
seconds(1, () => {
entity(paddle).x += 2;
});
// The paddle travels 120; the box, 24 ahead of it, is shoved along for the rest of the way.
expect(entity(box).x - start).toBeGreaterThan(80);
// Flush would be 24. While shoving the two sink into each other by about a step's travel, and
// the box is drawn a step behind – measured 20 at 2 units a frame – but it stays in front.
expect(entity(box).x - entity(paddle).x).toBeGreaterThan(16);
});
it('a kinematic body goes where it is put and carries nothing of gravity', () => {
const f = physicsScene();
const lift = f.add(0, 0, { w: 64, h: 16, body: { bodyType: 'kinematic' } });
const { seconds, entity } = run(f);
seconds(0.5);
expect(entity(lift).y).toBe(0);
entity(lift).x = 40;
seconds(0.5);
expect(entity(lift).x).toBe(40);
});
});
describe('the engine’s clock', () => {
function fallAfter(frames: readonly number[]): number {
const f = physicsScene();
const box = f.add(0, 0, { w: 32, body: {} });
const { world, entity } = run(f);
for (const dt of frames) world.tick(dt);
return entity(box).y;
}
it('steps the same whatever the frames were, and never catches up more than a few steps', () => {
const even = fallAfter(Array.from({ length: 60 }, () => 1 / 60));
const uneven: number[] = [];
for (let left = 1; left > 1e-9;) {
const dt = Math.min(left, [0.004, 0.031, 0.012, 0.02, 0.009][uneven.length % 5]!);
uneven.push(dt);
left -= dt;
}
expect(Math.abs(fallAfter(uneven) - even)).toBeLessThan(0.5);
// A frame of a whole second is a hitch: the world moves a few steps on, not sixty.
expect(fallAfter([1])).toBeLessThan(fallAfter(Array.from({ length: 9 }, () => PHYSICS_STEP)));
});
});
describe('collision events from the engine', () => {
it('a body falling through a trigger enters and leaves it and is not stopped', () => {
const f = physicsScene();
f.add(0, 100, { w: 200, h: 20, collider: { isTrigger: true } });
const ball = f.add(0, 0, { w: 16, circle: true, body: {} });
const { seconds, entity, physics } = run(f);
const enters: CollisionPair[] = [];
const exits: CollisionPair[] = [];
seconds(1, () => {
enters.push(...physics.enters);
exits.push(...physics.exits);
});
expect(enters).toHaveLength(1);
expect(exits).toHaveLength(1);
expect(entity(ball).y).toBeGreaterThan(300);
});
it('On collision enter tells which way the other side pushes and how hard they met', () => {
const f = physicsScene();
const ground = f.add(0, 200, GROUND);
const ball = f.add(0, 0, { w: 32, circle: true, body: {} });
for (const [owner, name] of [
[ball, 'Ball'],
[ground, 'Ground'],
] as const) {
const behavior = makeBehavior(f, name);
place(f, behavior.id, { x: 0, y: 0 }, owner);
const edit = graphEditor(f, behavior);
const enter = edit.addNode('OnCollisionEnter');
const normal = edit.addNode('Log');
const impulse = edit.addNode('Log');
edit.wire([
[enter, 'exec', normal, 'exec'],
[enter, 'normalY', normal, 'message'],
[normal, 'exec', impulse, 'exec'],
[enter, 'impulse', impulse, 'message'],
]);
}
const { seconds, world } = run(f);
seconds(1);
// Each side once: its normal, then the impulse.
expect(world.log).toHaveLength(4);
const [ballNormal, i1, groundNormal, i2] = world.log.map(Number) as [number, number, number, number];
// The ball's behavior runs first. The ground pushes the ball up; the ball pushes the ground down.
expect(ballNormal).toBe(-1);
expect(groundNormal).toBe(1);
// The ball of 32 hits at about √(2·980·164) ≈ 567 per second, and its mass is π·0.16² ≈ 0.08.
expect(i1).toBeGreaterThan(20);
expect(i2).toBe(i1);
});
it('two objects without a rigidbody only collide while they overlap, not while they merely touch', () => {
const f = physicsScene();
const a = f.add(0, 0, { w: 100 });
const b = f.add(100, 0, { w: 100 });
const { seconds, entity, physics, world } = run(f);
seconds(1 / 60);
expect(physics.touching(world, entity(a), entity(b))).toBe(false);
entity(b).x = 99.5;
seconds(1 / 60);
expect(physics.enters).toHaveLength(1);
entity(b).x = 100;
seconds(1 / 60);
expect(physics.exits).toHaveLength(1);
});
it('a switched-off collider leaves the pair it was in', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, physics, world } = run(f);
seconds(2);
expect(physics.stays).toHaveLength(1);
const collider = world.entities.find((e: Entity) => e.type === 'collider' && e.parent === entity(box))!;
collider.props.enabled = false;
seconds(1 / 60);
expect(physics.exits).toHaveLength(1);
// And without a collider nothing holds the box up.
seconds(1);
expect(entity(box).y).toBeGreaterThan(300);
});
});