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; /** With a rigidbody, and these overrides on it (bodyType, fixedRotation, …). */ body?: Record; /** Overrides on the object itself (rotation, scale). */ props?: Record; 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(); 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): 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, owner: string, event: 'OnStart' | 'OnUpdate', type: string, params: Record = {}, 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); }); });