Files
feuerwerk/tests/core/physics-system.test.ts
T
adminandClaude Opus 5.5 cfd288c4b7 Halve the frame time of a playing scene with a tilemap (#284)
The performance panel showed Physics and Editor as the large parts of
a frame in a scene of 222 entities. Nearly all of it came from a
tilemap that only stands there.

- Whoever writes cells in place now says so with cellsChanged. Only
  then does cellsVersion compare the content. The comparison ran about
  60 times per frame and was the largest single item at 14 percent.
- The four writers call it: the painter during and at the end of a
  stroke, PaintTilesCommand on execute and on undo. A new writer that
  forgets it stays invisible.
- The game view is drawn once per frame in play. With an animated tile
  in view it was drawn twice, once by the run and once by animate().
  animate() now gets the redraw count from the start of the tick.
- TilemapField builds the geometry of a mesh only when its source
  changes: the cells, the tile size, the origin or the image. For that
  groupByTile and layerRows return the same objects for the same list.
- PhysicsSystem skips a tilemap whose source and switch are unchanged.
  The switch is kept because a map that starts switched off would
  otherwise have dead colliders once it is switched on.
- World.syncToModel looks entities and assets up in a map per pass
  instead of a linear search per object.

Measured on stein2 with a copy of the reference project, ms per frame:
Physics 2.64 to 0.97, Drawing 6.89 to 2.56, Editor 1.95 to 1.31.

Left open: syncToModel still mirrors every entity every frame. That
is its own issue.

Tests:
- Gate: 198 files, 2887 tests (2883 + 4), exit 0
- e2e 1-17,28,33,39-43,59: 810 checks passed, 40 has +3
- Smokes: 24 / 19 / 21, exit 0
- Three unit tests wrote cells directly and now call cellsChanged
- Eleven counter-probes fail. Two did not at first: the stroke needed
  a check of its own, the physics test has to start with the map
  switched off. That last one was repeated on stein2 only.
- e2e total 1613 is computed, not measured in a full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:52:57 +02:00

834 lines
35 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 map switched off by its parent holds nothing, holds once switched on, and lets go again', () => {
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));
const holder = place(f, f.spriteId, { x: 0, y: 0 });
place(f, map.asset.id, { x: 0, y: 0 }, holder);
const layer = (findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.id;
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, physics } = run(f);
// The map's own values stay as they are throughout: only what is above it changes.
entity(holder).props.enabled = false;
seconds(1);
expect(entity(box).y).toBeGreaterThan(300);
entity(holder).props.enabled = true;
entity(box).y = 0;
physics.setVelocity(entity(box), { x: 0, y: 0 });
seconds(3);
expect(entity(box).y).toBeCloseTo(192 - 16, 0);
entity(holder).props.enabled = false;
seconds(1);
expect(entity(box).y).toBeGreaterThan(300);
});
it('lands on a floor set during the run and falls on once it is taken away (#135)', () => {
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));
const ground = place(f, map.asset.id, { x: 0, y: 0 });
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, world } = run(f);
for (let x = 0; x < 10; x++) world.setTile(entity(ground), '', { x, y: 9 }, solid.asset.id);
seconds(3);
expect(entity(box).y).toBeCloseTo(192 - 16, 0);
for (let x = 0; x < 10; x++) world.setTile(entity(ground), '', { x, y: 9 }, '');
seconds(1);
expect(entity(box).y).toBeGreaterThan(300);
expect((findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.cells).toEqual({});
});
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 [groundNormal, i1, ballNormal, i2] = world.log.map(Number) as [number, number, number, number];
// The ground's behavior runs first, it stands higher in the hierarchy (#264). 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);
});
});
/** A behavior on `owner` that runs one node when `event` fires, on the owner unless `untargeted`. */
function onOwner(
f: ReturnType<typeof physicsScene>,
owner: string,
event: 'OnStart' | 'OnUpdate',
type: string,
params: Record<string, PropertyValue> = {},
untargeted = false,
) {
const behavior = makeBehavior(f, `${type} on ${owner}`);
place(f, behavior.id, { x: 0, y: 0 }, owner);
const edit = graphEditor(f, behavior);
const on = edit.addNode(event);
const node = edit.addNode(type, params);
const self = edit.addNode('Owner');
edit.connect(on, 'exec', node, 'exec');
if (!untargeted) edit.connect(self, 'object', node, 'object');
return { edit, node, self };
}
/** Drawn a step behind (see "falling and resting"): after one second, 59 of the 60 steps show. */
const SHOWN = 59 / 60;
describe('physics from the graph (#147)', () => {
it('Apply impulse in On start kicks by impulse ÷ mass before the first step; Get velocity reads it', () => {
const f = physicsScene();
f.gravity(0, 0);
const box = f.add(0, 0, { w: 32, body: { mass: 2 } });
const { edit, node, self } = onOwner(f, box, 'OnStart', 'ApplyImpulse', { x: 100, y: -40 });
const read = edit.addNode('GetVelocity');
const log = edit.addNode('Log');
edit.wire([
[node, 'exec', log, 'exec'],
[self, 'object', read, 'object'],
[read, 'x', log, 'message'],
]);
const { seconds, entity, physics, world } = run(f);
expect(world.log).toHaveLength(1);
expect(Number(world.log[0])).toBeCloseTo(50, 4);
expect(physics.velocityOf(entity(box))!.y).toBeCloseTo(-20, 4);
seconds(1);
expect(entity(box).x).toBeCloseTo(50 * SHOWN, 2);
expect(entity(box).y).toBeCloseTo(-20 * SHOWN, 2);
});
it('Apply force from On update pushes as hard at any frame rate: velocity = force × time ÷ mass', () => {
const speedAfterOneSecond = (dt: number) => {
const f = physicsScene();
f.gravity(0, 0);
const box = f.add(0, 0, { w: 32, body: { mass: 2 } });
onOwner(f, box, 'OnUpdate', 'ApplyForce', { x: 100 });
const { frame, collisions, world } = startWorld(f, {
physics: true,
collisions: true,
behaviors: true,
});
for (let i = 0; i < Math.round(1 / dt); i++) frame(dt);
return (collisions as PhysicsSystem).velocityOf(world.findByObjectId(box)!)!.x;
};
for (const dt of [1 / 30, 1 / 60, 1 / 144]) expect(speedAfterOneSecond(dt)).toBeCloseTo(50, 3);
});
it('Set velocity moves light and heavy alike; the rigidbody itself answers for its object', () => {
const f = physicsScene();
f.gravity(0, 0);
const light = f.add(0, 0, { w: 32, body: { mass: 1 } });
const heavy = f.add(0, 100, { w: 32, body: { mass: 50 } });
for (const id of [light, heavy]) onOwner(f, id, 'OnStart', 'SetVelocity', { x: 120, y: 0 });
const { seconds, entity, physics, world } = run(f);
seconds(1);
expect(entity(light).x).toBeCloseTo(120 * SHOWN, 2);
expect(entity(heavy).x).toBeCloseTo(120 * SHOWN, 2);
const rigidbody = world.findByObjectId(f.rigidbodyOf(heavy))!;
expect(physics.velocityOf(rigidbody)!.x).toBeCloseTo(120, 3);
});
it('Set angular velocity turns it the way rotation counts, under a mirrored parent too; not with fixed rotation', () => {
const f = physicsScene();
f.gravity(0, 0);
const spinner = f.add(0, 0, { w: 32, body: {} });
const locked = f.add(100, 0, { w: 32, body: { fixedRotation: true } });
const mirror = f.add(0, 200, { w: 8, props: { mirrorX: true } });
const mirrored = f.add(50, 0, { w: 32, body: {}, parent: mirror });
for (const id of [spinner, locked, mirrored])
onOwner(f, id, 'OnStart', 'SetAngularVelocity', { speed: 90 });
const { seconds, entity, physics, world } = run(f);
seconds(1);
expect(Number(entity(spinner).props.rotation)).toBeCloseTo(90 * SHOWN, 1);
expect(physics.angularVelocityOf(entity(spinner))).toBeCloseTo(90, 3);
expect(Number(entity(mirrored).props.rotation)).toBeCloseTo(90 * SHOWN, 1);
expect(physics.angularVelocityOf(entity(mirrored))).toBeCloseTo(90, 3);
expect(Number(entity(locked).props.rotation ?? 0)).toBe(0);
expect(physics.angularVelocityOf(entity(locked))).toBe(0);
// Nothing was kept back for later: let go of the rotation and it still does not turn.
world.findByObjectId(f.rigidbodyOf(locked))!.props.fixedRotation = false;
seconds(0.5);
expect(Number(entity(locked).props.rotation ?? 0)).toBe(0);
});
it('Set gravity turns the fall around for the rest of the run; the scene in the project keeps its own', () => {
const f = physicsScene();
f.gravity(0, 980);
const box = f.add(0, 0, { w: 32, body: {} });
onOwner(f, box, 'OnStart', 'SetGravity', { x: 0, y: -980 }, true);
const { seconds, entity, world } = run(f);
seconds(0.25);
// A model event while playing re-reads the scene, after the tick wrote back (as in PlaySession);
// the run's own gravity outlives it.
world.syncToModel();
world.syncFromModel();
seconds(0.25);
expect(entity(box).y).toBeLessThan(-100);
expect(world.sceneProps.gravityY).toBe(-980);
expect(findAsset(f.project, f.sceneId)!.props.gravityY).toBe(980);
});
it('moving a dynamic body directly keeps its speed and says so once; a physics node on an object without one says so once', () => {
const f = physicsScene();
f.gravity(0, 0);
const box = f.add(0, 0, { w: 32, body: {} });
const plain = f.add(200, 0, { w: 32 });
onOwner(f, box, 'OnStart', 'SetVelocity', { x: 60, y: 0 });
onOwner(f, plain, 'OnUpdate', 'ApplyImpulse', { x: 1 });
const { seconds, entity, physics, world } = run(f);
seconds(0.5, () => {
entity(box).y += 1;
});
expect(physics.velocityOf(entity(box))!.x).toBeCloseTo(60, 3);
expect(entity(plain).x).toBe(200);
expect([...world.log].sort()).toEqual([
'Physics: "Box" has a dynamic rigidbody and was moved directly; it keeps its velocity. Apply force, Apply impulse and Set velocity move it with the physics.',
'Physics: "Box" has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.',
]);
});
it('a dragged dynamic body goes where the drag puts it, and is let go at rest', () => {
const f = physicsScene();
f.gravity(0, 980);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, frame, entity, physics, world } = run(f);
seconds(0.5);
expect(physics.velocityOf(entity(box))!.y).toBeGreaterThan(400);
world.held = new Set([box]);
seconds(0.5, () => {
entity(box).x += 4;
entity(box).y = -50;
});
seconds(0.25);
expect(entity(box)).toMatchObject({ x: 120, y: -50 });
world.held = new Set();
seconds(1 / 60);
expect(Math.abs(entity(box).y + 50)).toBeLessThan(1);
const velocity = physics.velocityOf(entity(box))!;
expect(velocity.x).toBe(0);
expect(velocity.y).toBeCloseTo(980 / 60, 3);
// Dragging the rigidbody holds the body it gives the object just as well.
world.held = new Set([f.rigidbodyOf(box)]);
const y = entity(box).y;
seconds(0.25);
expect(entity(box).y).toBe(y);
// A grab too short for a physics step lets go at rest as well.
world.held = new Set();
seconds(0.5);
world.held = new Set([box]);
frame(0.001);
world.held = new Set();
frame(0.001);
expect(physics.velocityOf(entity(box))).toEqual({ x: 0, y: 0 });
expect(world.log).toEqual([]);
});
it('a tick does not write the old place back over a drag before the drag is read', () => {
const f = physicsScene();
f.gravity(0, 980);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, world } = run(f);
seconds(0.25);
// As the scene view does it: the drag writes the model, the game ticks and mirrors itself into
// the model, and only then does the change event make the world re-read it.
world.held = new Set([box]);
f.object(box).position = { x: 80, y: -40, z: 0 };
seconds(1 / 60);
world.syncToModel();
world.syncFromModel();
seconds(1 / 60);
expect(entity(box)).toMatchObject({ x: 80, y: -40 });
expect(f.object(box).position).toMatchObject({ x: 80, y: -40 });
});
it('in the editor run the physics nodes do nothing', () => {
const f = physicsScene();
f.gravity(0, 0);
const box = f.add(0, 0, { w: 32, body: {} });
onOwner(f, box, 'OnStart', 'ApplyImpulse', { x: 100 });
onOwner(f, box, 'OnStart', 'SetGravity', { x: 0, y: -980 }, true);
const { world } = startWorld(f, { collisions: true, behaviors: true });
for (let i = 0; i < 30; i++) world.tick(1 / 60);
expect(world.findByObjectId(box)!.x).toBe(0);
expect(world.sceneProps.gravityY).toBe(0);
expect(world.log).toEqual([]);
});
});
describe('Move with "Collide with solids" pushes dynamic bodies (#147)', () => {
/** A mover walking 3 a frame (180 per second) for a second into a crate 60 ahead, both on the ground. */
function shove(crate: Thing, mover: Thing = { w: 32 }) {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 164, { ...crate, w: 32 });
const walker = f.add(-60, 164, mover);
const { seconds, entity, physics, world } = run(f);
seconds(0.5);
const start = entity(box).x;
let gap = Infinity;
const blocked: boolean[] = [];
seconds(1, () => {
blocked.push(physics.moveAndSlide(world, entity(walker), 3, 0).blockedX);
gap = Math.min(gap, entity(box).x - 16 - (entity(walker).x + mover.w / 2));
});
return { moved: entity(box).x - start, walked: entity(walker).x + 60, gap, blocked, log: world.log };
}
it('a crate as heavy as the mover goes along; the mover never gets into it', () => {
const { moved, walked, gap, blocked } = shove({ w: 32, body: {} });
expect(moved).toBeGreaterThan(100);
expect(walked).toBeGreaterThan(moved);
expect(gap).toBeGreaterThanOrEqual(0);
expect(blocked).toContain(true);
});
it('a crate forty times heavier stays put, and a static one does not move at all', () => {
expect(shove({ w: 32, body: { mass: 4 } }).moved).toBeLessThan(1);
const still = shove({ w: 32, body: { bodyType: 'static' } });
expect(still.moved).toBe(0);
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 } },
{ w: 32, body: { bodyType: 'kinematic', mass: 40 } },
);
expect(moved).toBeGreaterThan(100);
expect(gap).toBeGreaterThanOrEqual(0);
});
});
describe('a tilemap in the engine keeps every cell its own shape', () => {
it('a collider without a rigidbody overlapping the first of two solid cells collides with the map', () => {
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));
const mapObject = place(f, map.asset.id, { x: 0, y: 0 });
const layer = (findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.id;
// Cells of 48, centred: cell (0, 0) spans −240 … −192, cell (9, 9) spans 192 … 240.
const cells = [
{ x: 0, y: 0, tileId: solid.asset.id },
{ x: 9, y: 9, tileId: solid.asset.id },
];
f.stack.execute(new PaintTilesCommand(map.asset.id, layer, cells));
const first = f.add(-216, -216, { w: 20 });
const last = f.add(216, 216, { w: 20 });
const { physics, world, entity, frame } = run(f);
frame(1 / 60);
frame(1 / 60);
expect(physics.touching(world, entity(first), entity(mapObject))).toBe(true);
expect(physics.touching(world, entity(last), entity(mapObject))).toBe(true);
});
});