import { beforeAll, describe, expect, it } from 'vitest'; import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { GraphNode } from '../../src/core/graph'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { COLLISION_SYSTEMS, graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld, } from '../helpers'; beforeAll(async () => { await loadPhysics(); }); /** Wires `event → Log(port) → Log(port) …`, one entry in `world.log` per port, or `message` when none. */ function logOn( f: SceneFixture, behavior: BehaviorAsset, event: string, params: Record, ports: string[], message = event, ): GraphNode { const edit = graphEditor(f, behavior); const node = edit.addNode(event, params); let from = node; for (const port of ports.length > 0 ? ports : ['']) { const log = edit.addNode('Log', port ? {} : { message }); edit.connect(from, 'exec', log, 'exec'); if (port) edit.connect(node, port, log, 'message'); from = log; } return node; } /** Two 100 × 60 crates with a collider each, the second 300 to the right; a behavior sits on the first. */ function crates() { const f = makeSpriteScene('Crate'); f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60)); const behavior = makeBehavior(f, 'Hit'); const a = place(f, f.spriteId, { x: 0, y: 0 }); const b = place(f, f.spriteId, { x: 300, y: 0 }); f.stack.execute(new AddColliderCommand(f.sceneId, a)); f.stack.execute(new AddColliderCommand(f.sceneId, b)); place(f, behavior.id, { x: 0, y: 0 }, a); return { ...f, behavior, b }; } describe.each(COLLISION_SYSTEMS)('$system: On collision stay with an interval (#149)', ({ physics }) => { it('fires each time the interval has passed since the pair met, and starts over when they meet again', () => { const f = crates(); logOn(f, f.behavior, 'OnCollisionStay', { interval: 0.5 }, [], 'slow'); logOn(f, f.behavior, 'OnCollisionStay', {}, [], 'each'); const { world, frame } = startWorld(f, { physics, collisions: true, behaviors: true }); const other = world.findByObjectId(f.b)!; const slow = () => world.log.filter((line) => line === 'slow').length; const each = () => world.log.filter((line) => line === 'each').length; other.x = 50; frame(0.1); // they meet for (let i = 0; i < 4; i++) frame(0.1); expect([slow(), each()]).toEqual([0, 4]); frame(0.1); // half a second since they met expect([slow(), each()]).toEqual([1, 5]); for (let i = 0; i < 5; i++) frame(0.1); expect([slow(), each()]).toEqual([2, 10]); frame(0.35); // a long frame counts as long as it is frame(0.35); expect(slow()).toBe(3); other.x = 500; frame(0.1); other.x = 50; frame(0.1); // they meet again: the clock starts over for (let i = 0; i < 4; i++) frame(0.1); expect(slow()).toBe(3); frame(0.1); expect(slow()).toBe(4); }); }); describe('what On collision stay knows of the contact (#149)', () => { /** A 32 px crate with a rigidbody falling onto a 400 × 40 ground whose top is at y = 180. */ function landing(density = 1) { const f = makeSpriteScene('Box'); const behavior = makeBehavior(f, 'Feel'); const size = (id: string, w: number, h: number) => { f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'width', w)); f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'height', h)); }; const ground = place(f, f.spriteId, { x: 0, y: 200 }); size(ground, 400, 40); const groundCollider = new AddColliderCommand(f.sceneId, ground); f.stack.execute(groundCollider); size(groundCollider.objectId, 400, 40); const box = place(f, f.spriteId, { x: 10, y: 100 }); size(box, 32, 32); const boxCollider = new AddColliderCommand(f.sceneId, box); f.stack.execute(boxCollider); size(boxCollider.objectId, 32, 32); f.stack.execute(new SetOverrideCommand(f.sceneId, boxCollider.objectId, 'density', density)); const rigidbody = new AddRigidbodyCommand(f.sceneId, box); f.stack.execute(rigidbody); f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, 'fixedRotation', true)); place(f, behavior.id, { x: 0, y: 0 }, box); return { ...f, behavior }; } it('while playing: the normal from the ground towards the box, the impulse that holds it up and the contact point', () => { const rest = (density: number) => { const f = landing(density); logOn(f, f.behavior, 'OnCollisionStay', { interval: 1 }, [ 'normalX', 'normalY', 'impulse', 'pointX', 'pointY', ]); const { world, frame } = startWorld(f, { physics: true, collisions: true, behaviors: true }); for (let i = 0; i < 150; i++) frame(1 / 60); return world.log.slice(-5).map(Number); }; const [nx, ny, light, px, py] = rest(1); expect([nx, ny]).toEqual([0, -1]); expect(px).toBeCloseTo(10, 3); expect(py).toBeCloseTo(180, 1); // The engine's own number: it is not exactly m·g·dt (1.25 times that, measured), but it grows with the weight. const [, , heavy] = rest(3); expect(light).toBeGreaterThan(0); expect(heavy! / light!).toBeCloseTo(3, 3); }); it('On collision enter says where the pair met as well', () => { const f = landing(); logOn(f, f.behavior, 'OnCollisionEnter', {}, ['pointX', 'pointY']); const { world, frame } = startWorld(f, { physics: true, collisions: true, behaviors: true }); for (let i = 0; i < 60; i++) frame(1 / 60); const [px, py] = world.log.slice(0, 2).map(Number); expect(px).toBeCloseTo(10, 3); expect(Math.abs(py! - 180)).toBeLessThan(2); }); it('in the editor run nothing is simulated: the contact outputs stay 0', () => { const f = landing(); logOn(f, f.behavior, 'OnCollisionStay', {}, ['normalY', 'impulse', 'pointY']); const { world, frame } = startWorld(f, { collisions: true, behaviors: true }); const box = world.entities.find((e) => e.type === 'sprite' && e.y === 100)!; box.y = 170; // overlapping the ground frame(1 / 60); frame(1 / 60); expect(world.log).toEqual(['0', '0', '0']); }); });