/** * The event nodes have no `run` – the BehaviorSystem decides when they fire. These tests drive a * real world and check that timing: key edges vs. held keys, the timer's own clock, and the * difference between a collision that starts and one that continues. */ import { beforeAll, describe, expect, it } from 'vitest'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { COLLISION_SYSTEMS, graphEditor, makeBehavior, makeSpriteScene, place, startWorld, type SceneFixture, } from '../helpers'; /** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */ function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } { const f = makeSpriteScene('Box'); f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60)); const behavior = makeBehavior(f, 'Logic'); const box = place(f, f.spriteId, { x: 0, y: 0 }); place(f, behavior.id, { x: 0, y: 0 }, box); return { ...f, behavior, box }; } /** Wires ` → Log(message)` so a fired event shows up as one entry in `world.log`. */ function logOn( f: SceneFixture & { behavior: BehaviorAsset }, event: string, params: Record = {}, message = event, ): void { const edit = graphEditor(f, f.behavior); const node = edit.addNode(event, params); const log = edit.addNode('Log', { message }); edit.connect(node, 'exec', log, 'exec'); } describe('key events', () => { it('On key down fires once per press, not while the key stays held', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a' }, 'down'); const { world, input, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual([]); input.press('a'); frame(); expect(world.log).toEqual(['down']); input.press('a'); // auto-repeat while held: no new edge frame(); frame(); expect(world.log).toEqual(['down']); input.release('a'); input.press('a'); frame(); expect(world.log).toEqual(['down', 'down']); }); it('On key up fires once when the key is released', () => { const f = scene(); logOn(f, 'OnKeyUp', { key: 'a' }, 'up'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(); frame(); expect(world.log).toEqual([]); input.release('a'); frame(); expect(world.log).toEqual(['up']); frame(); // the edge is consumed, not sticky expect(world.log).toEqual(['up']); }); it('On key held fires every frame the key is down', () => { const f = scene(); logOn(f, 'OnKeyHeld', { key: 'a' }, 'held'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(); frame(); frame(); expect(world.log).toEqual(['held', 'held', 'held']); input.release('a'); frame(); expect(world.log).toHaveLength(3); }); it('only the node whose key matches fires', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a' }, 'a'); logOn(f, 'OnKeyDown', { key: 'b' }, 'b'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('b'); frame(); expect(world.log).toEqual(['b']); }); it('losing the window releases every held key, so nothing stays down behind the user’s back', () => { const f = scene(); logOn(f, 'OnKeyUp', { key: 'a' }, 'up'); logOn(f, 'OnKeyHeld', { key: 'a' }, 'held'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(); expect(world.log).toEqual(['held']); input.releaseAll(); frame(); expect(world.log).toEqual(['held', 'up']); frame(); expect(world.log).toHaveLength(2); }); it('a key pressed as "a" and let go as "A" under Shift is released (#227)', () => { const f = scene(); logOn(f, 'OnKeyUp', { key: 'a' }, 'up'); logOn(f, 'OnKeyHeld', { key: 'a' }, 'held'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a', 'KeyA'); frame(); input.press('Shift', 'ShiftLeft'); input.press('A', 'KeyA'); // auto-repeat, now with Shift: still the same key frame(); input.release('A', 'KeyA'); frame(); input.release('Shift', 'ShiftLeft'); frame(); expect(world.log).toEqual(['held', 'held', 'up']); expect(input.isDown('a')).toBe(false); }); it('a key keeps the name it went down as, even when Shift gives its release another one', () => { const f = scene(); logOn(f, 'OnKeyUp', { key: '1' }, 'up'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('1', 'Digit1'); frame(); input.release('!', 'Digit1'); frame(); expect(world.log).toEqual(['up']); expect(input.heldKeys.size).toBe(0); }); it('a key stored upper-case in the graph still hears the key', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'A' }, 'down'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a', 'KeyA'); frame(); expect(world.log).toEqual(['down']); expect(input.isDown('A')).toBe(true); }); }); // The node's settings are milliseconds, the frame lengths seconds. Those lengths are exact in // binary (0.125, 0.25, 0.5), so a repeat due at a round hold time is never lost to a rounding error. describe('On key down repeat', () => { it('fires again after the delay, then on its own interval', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 250 }, 'down'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(0.125); expect(world.log).toEqual(['down']); for (let n = 0; n < 3; n++) frame(0.125); // held 0.375 s: still short of the delay expect(world.log).toEqual(['down']); frame(0.125); // held 0.5 s expect(world.log).toEqual(['down', 'down']); frame(0.125); expect(world.log).toHaveLength(2); // 0.625 s: half an interval on frame(0.125); expect(world.log).toHaveLength(3); // 0.75 s frame(0.25); expect(world.log).toHaveLength(4); }); it('leaves a key alone while the delay is zero', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 0, repeatInterval: 50 }, 'down'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); for (let n = 0; n < 30; n++) frame(0.125); expect(world.log).toEqual(['down']); }); it('fires every frame when the interval is zero', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 0 }, 'down'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(0.125); frame(0.125); // held 0.125 s expect(world.log).toEqual(['down']); frame(0.125); // held 0.25 s: the delay is up expect(world.log).toHaveLength(2); frame(0.0625); frame(0.0625); expect(world.log).toHaveLength(4); // a short frame counts as much as a long one }); it('starts the delay over on the next press', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'down'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(0.125); input.release('a'); frame(0.5); // well past the delay, but the key is gone: no repeat expect(world.log).toEqual(['down']); input.press('a'); frame(0.125); expect(world.log).toEqual(['down', 'down']); // the press itself, not a repeat frame(0.125); // held 0.125 s: the clock restarted, so nothing is due yet expect(world.log).toHaveLength(2); frame(0.125); // held 0.25 s expect(world.log).toHaveLength(3); }); it('catches up when one frame is longer than the interval', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 500 }, 'down'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(0.125); frame(2); // repeats due at 0.5, 1.0, 1.5 and 2.0 expect(world.log).toHaveLength(5); }); it('gives two nodes on the same key their own clocks', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'fast'); logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 750, repeatInterval: 750 }, 'slow'); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(0.125); expect(world.log).toEqual(['fast', 'slow']); // the press reaches both frame(0.125); frame(0.125); // held 0.25 s expect(world.log).toEqual(['fast', 'slow', 'fast']); frame(0.125); frame(0.125); // held 0.5 s expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast']); frame(0.25); // held 0.75 s: the fast one a third time, the slow one its first expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast', 'fast', 'slow']); }); }); describe('On timer', () => { it('fires on its own clock, independent of the frame rate', () => { const f = scene(); logOn(f, 'OnTimer', { interval: 1 }, 'tick'); const { world, frame } = startWorld(f, { behaviors: true }); for (let i = 0; i < 4; i++) frame(0.2); // 0.8 s expect(world.log).toEqual([]); frame(0.2); // 1.0 s expect(world.log).toEqual(['tick']); for (let i = 0; i < 5; i++) frame(0.2); // 2.0 s expect(world.log).toEqual(['tick', 'tick']); }); it('catches up when one frame is longer than the interval', () => { const f = scene(); logOn(f, 'OnTimer', { interval: 0.5 }, 'tick'); const { world, frame } = startWorld(f, { behaviors: true }); frame(2); // a hitch worth four intervals expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']); }); it('late is how far the firing frame is past the due time, so time minus late is exact', () => { const f = scene(); const edit = graphEditor(f, f.behavior); const timer = edit.addNode('OnTimer', { interval: 1 }); const log = edit.addNode('Log', {}); edit.connect(timer, 'exec', log, 'exec'); edit.connect(timer, 'late', log, 'message'); const { world, frame } = startWorld(f, { behaviors: true }); frame(0.75); frame(0.375); // 1.125 s: due at 1.0 expect(world.log).toEqual(['0.125']); frame(0.875); // 2.0 s: due exactly now expect(world.log).toEqual(['0.125', '0']); frame(2.5); // 4.5 s: catches up 3.0 and 4.0, the first one longer ago expect(world.log).toEqual(['0.125', '0', '1.5', '0.5']); }); it('two timers keep separate clocks', () => { const f = scene(); logOn(f, 'OnTimer', { interval: 1 }, 'slow'); logOn(f, 'OnTimer', { interval: 0.5 }, 'fast'); const { world, frame } = startWorld(f, { behaviors: true }); frame(0.5); expect(world.log).toEqual(['fast']); frame(0.5); expect(world.log).toEqual(['fast', 'slow', 'fast']); }); }); describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { beforeAll(async () => { await loadPhysics(); }); describe('On collision stay', () => { it('fires every frame the overlap lasts, while enter fires only on the first one', () => { 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); const fixture = { ...f, behavior }; logOn(fixture, 'OnCollisionEnter', {}, 'enter'); logOn(fixture, 'OnCollisionStay', {}, 'stay'); logOn(fixture, 'OnCollisionExit', {}, 'exit'); const { world, frame } = startWorld(f, { physics, collisions: true, behaviors: true }); const crateB = world.findByObjectId(b)!; frame(0.1); expect(world.log).toEqual([]); crateB.x = 50; // now overlapping frame(0.1); expect(world.log).toEqual(['enter']); frame(0.1); frame(0.1); expect(world.log).toEqual(['enter', 'stay', 'stay']); crateB.x = 500; frame(0.1); expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']); frame(0.1); expect(world.log).toHaveLength(4); }); }); }); describe('a switched-off behavior receives no events', () => { it('ignores keys and timers until it is switched back on', () => { const f = scene(); logOn(f, 'OnKeyDown', { key: 'a' }, 'down'); logOn(f, 'OnTimer', { interval: 0.5 }, 'tick'); const { world, input, frame } = startWorld(f, { behaviors: true }); const logic = world.entities.find((e) => e.name === 'Logic')!; logic.props.enabled = false; input.press('a'); frame(1); expect(world.log).toEqual([]); logic.props.enabled = true; input.release('a'); input.press('a'); frame(1); // The timer starts counting when the behavior runs again – no backlog from the off period. expect(world.log).toEqual(['down', 'tick', 'tick']); }); }); /** Every lifecycle event logs its own name, so `world.log` reads as the order they fired in. */ function lifecycle(): ReturnType { const f = scene(); for (const event of ['OnStart', 'OnEnable', 'OnUpdate', 'OnDisable', 'OnDestroy']) logOn(f, event, {}, event.slice(2).toLowerCase()); return f; } describe('lifecycle events', () => { it('fires On start, then On enable, then the first On update', () => { const { world, frame } = startWorld(lifecycle(), { behaviors: true }); frame(); expect(world.log).toEqual(['start', 'enable', 'update']); }); it('fires On disable when switched off and On enable – not On start – when switched back on', () => { const f = lifecycle(); const { world, frame } = startWorld(f, { behaviors: true }); const logic = world.entities.find((e) => e.name === 'Logic')!; frame(); logic.props.enabled = false; frame(); frame(); expect(world.log).toEqual(['start', 'enable', 'update', 'disable']); logic.props.enabled = true; frame(); expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'enable', 'update']); }); it('treats a parent being switched off the same way', () => { const f = lifecycle(); const { world, frame } = startWorld(f, { behaviors: true }); const box = world.findByObjectId(f.box)!; frame(); box.props.enabled = false; frame(); expect(world.log).toEqual(['start', 'enable', 'update', 'disable']); box.props.enabled = true; frame(); expect(world.log.slice(4)).toEqual(['enable', 'update']); }); it('fires On disable then On destroy when the owner is destroyed, in the same frame', () => { const f = lifecycle(); // `On key down → Destroy(Owner)`: the behavior destroys the box it hangs below, itself included. const edit = graphEditor(f, f.behavior); const key = edit.addNode('OnKeyDown', { key: 'x' }); const owner = edit.addNode('Owner'); const destroy = edit.addNode('Destroy'); edit.wire([ [key, 'exec', destroy, 'exec'], [owner, 'object', destroy, 'object'], ]); const { world, input, frame, behaviors } = startWorld(f, { behaviors: true }); frame(); expect(behaviors!.runningIds()).toHaveLength(1); input.press('x'); frame(); // The frame that destroys it still runs to its end – the farewell comes after this frame's update. expect(world.log).toEqual(['start', 'enable', 'update', 'update', 'disable', 'destroy']); expect(behaviors!.runningIds()).toEqual([]); expect(world.entities.some((e) => e.name === 'Logic')).toBe(false); frame(); expect(world.log).toHaveLength(6); }); it('skips On disable for a behavior destroyed while it was off', () => { const f = lifecycle(); const { world, frame } = startWorld(f, { behaviors: true }); const logic = world.entities.find((e) => e.name === 'Logic')!; logic.props.enabled = false; frame(); world.remove(logic); frame(); expect(world.log).toEqual(['start', 'enable', 'disable', 'destroy']); }); it('runs On disable and On destroy for every instance when the world is torn down', () => { const f = lifecycle(); place(f, f.behavior.id, { x: 0, y: 0 }); const { world, frame } = startWorld(f, { behaviors: true }); frame(); world.destroy(); expect(world.log).toEqual([ 'start', 'enable', 'start', 'enable', 'update', 'update', 'disable', 'destroy', 'disable', 'destroy', ]); }); });