Files
feuerwerk/tests/core/physics-system.test.ts
T
adminandClaude Opus 5.5 5fbc6eb088
game-maker / gate (push) Successful in 3m42s
game-maker / suite (push) Failing after 11m7s
Set the order in which event nodes run across behaviors (#264)
Which of two events ran first depended on hidden lists: the order of
the behavior instances and the order in which nodes were created. A
graph had no way to say that one On update must run before another,
and an On update of one behavior could run before a key event of
another.

- A frame now runs event kind by event kind over all behaviors: On
  disable and On enable, key down, key up, key held, timer, collision
  enter, stay and exit, animation finished, click, mouse wheel and at
  last On update.
- All 17 event nodes have a field Order, a whole number, default 0,
  negative allowed. Within one kind a lower Order runs earlier, no
  matter which behavior holds the node.
- On a tie the current hierarchy decides from top to bottom, then the
  order in which the nodes were created. The old rule that a behavior
  enabled later goes to the end is gone.
- The node shows Order: 5 only when the value is not 0, so existing
  graphs look the same.
- The rule lives as pure functions in core/graph/eventOrder.ts. The
  interpreter can now fire a single event node.

Changes in existing projects:

- An object destroyed in a frame gets no further event in that frame.
  On disable and On destroy still come at the end of the frame.
- New instances get all their On start first, then all On enable.
  Teardown runs all On disable first, then all On destroy.
- State events follow right after the event kind that caused them.
- Four unit tests had fixed the old order and are adjusted:
  two in behavior-events, one in physics-system, one in key-repeat.

Tests:

- ./check.sh: 191 files, 2784 tests, CHECK EXIT 0.
- GM_STEPS=1-6: ALL PASSED, 280 checks.
- Full e2e run (local): ALL PASSED, 1551 checks, measured.
- tests/core/event-order.test.ts: 11 tests for the rule, the field on
  all 17 nodes, order across behaviors, key before update, two nodes
  in one graph, On start, hierarchy after late enable and reorder at
  runtime, state events, save and load.
- e2e 6z-viii, 2 checks with real mouse and keyboard: Order -1 set in
  the inspector shows on the node, and a run logs B, A although B is
  lower in the hierarchy.
- Counter-checks: 5 in unit tests and 1 in the browser, all fail as
  expected. The originals were restored and checked with cmp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:21:31 +02:00

804 lines
34 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('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);
});
});