728 lines
31 KiB
TypeScript
728 lines
31 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
|
||
/** 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('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);
|
||
});
|
||
});
|