/** * The game's own pause and time scale (#167): Pause game holds the world from the next frame and * leaves only the behaviors that run while paused ticking; Set time scale turns the clock a played * game hands the behaviors, and nothing else. */ 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 { GameClock } from '../../src/core/time'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { COLLISION_SYSTEMS, graphEditor, makeBehavior, makeSpriteScene, place, startWorld, type SceneFixture, } from '../helpers'; /** Wires `(params) → Log(message)`. */ function logOn(f: SceneFixture, behavior: BehaviorAsset, event: string, message: string, params = {}): void { const edit = graphEditor(f, behavior); edit.connect(edit.addNode(event, params), 'exec', edit.addNode('Log', { message }), 'exec'); } /** * A box with two behaviors: "Game" logs every frame, "Menu" runs while paused, logs every frame and * toggles the pause on P – the one-key toggle a pause menu is built from. */ function pauseMenu() { const f = makeSpriteScene('Box'); const box = place(f, f.spriteId, { x: 0, y: 0 }); const game = makeBehavior(f, 'Game'); logOn(f, game, 'OnUpdate', 'game'); const menu = makeBehavior(f, 'Menu'); f.stack.execute(new SetAssetPropertyCommand(menu.id, 'runWhilePaused', true)); logOn(f, menu, 'OnUpdate', 'menu'); const edit = graphEditor(f, menu); const key = edit.addNode('OnKeyDown', { key: 'p' }); const paused = edit.addNode('IsGamePaused'); const branch = edit.addNode('Branch'); const resume = edit.addNode('ResumeGame'); const pause = edit.addNode('PauseGame'); edit.wire([ [key, 'exec', branch, 'exec'], [paused, 'paused', branch, 'condition'], [branch, 'then', resume, 'exec'], [branch, 'else', pause, 'exec'], ]); place(f, game.id, { x: 0, y: 0 }, box); place(f, menu.id, { x: 0, y: 0 }, box); return startWorld(f, { behaviors: true }); } describe('Pause game', () => { it('holds the world from the next frame, and only a behavior that runs while paused is ticked', () => { const { world, input, frame } = pauseMenu(); frame(0.1); expect(world.log).toEqual(['game', 'menu']); input.press('p'); frame(0.1); // The frame that asked still runs whole: the pause starts with the next one. expect(world.log.slice(2)).toEqual(['game', 'menu']); expect(world.paused).toBe(true); const heldAt = world.time; world.log.length = 0; frame(0.1); frame(0.1); expect(world.log).toEqual(['menu', 'menu']); expect(world.time).toBe(heldAt); input.release('p'); input.press('p'); frame(0.1); expect(world.paused).toBe(false); world.log.length = 0; frame(0.1); expect(world.log).toEqual(['game', 'menu']); expect(world.time).toBeCloseTo(heldAt + 0.1); }); it('switches nothing off: a paused behavior runs on from where it stood, without an On disable', () => { const f = makeSpriteScene('Box'); const box = place(f, f.spriteId, { x: 0, y: 0 }); const game = makeBehavior(f, 'Game'); logOn(f, game, 'OnDisable', 'disabled'); logOn(f, game, 'OnEnable', 'enabled'); logOn(f, game, 'OnTimer', 'timer', { interval: 0.25 }); place(f, game.id, { x: 0, y: 0 }, box); const { world, frame } = startWorld(f, { behaviors: true }); frame(0.1); frame(0.1); world.paused = true; for (let i = 0; i < 10; i++) frame(0.1); world.paused = false; frame(0.1); // 0.3 s of unpaused time: the timer fired once, and the paused second did not count. expect(world.log).toEqual(['enabled', 'timer']); }); }); describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { beforeAll(async () => { await loadPhysics(); }); it('tells a behavior running while paused no collision again: nothing collides in a held world', () => { const f = makeSpriteScene('Crate'); const a = place(f, f.spriteId, { x: 0, y: 0 }); const b = place(f, f.spriteId, { x: 50, y: 0 }); f.stack.execute(new AddColliderCommand(f.sceneId, a)); f.stack.execute(new AddColliderCommand(f.sceneId, b)); const hit = makeBehavior(f, 'Hit'); f.stack.execute(new SetAssetPropertyCommand(hit.id, 'runWhilePaused', true)); logOn(f, hit, 'OnCollisionEnter', 'enter'); logOn(f, hit, 'OnCollisionStay', 'stay'); place(f, hit.id, { x: 0, y: 0 }, a); const { world, frame } = startWorld(f, { physics, collisions: true, behaviors: true }); frame(0.1); frame(0.1); expect(world.log).toEqual(['enter', 'stay']); world.paused = true; frame(0.1); frame(0.1); expect(world.log).toEqual(['enter', 'stay']); world.paused = false; frame(0.1); expect(world.log).toEqual(['enter', 'stay', 'stay']); }); }); describe('Set time scale', () => { /** `On start → Set time scale(scale)`, and `On update → Log(Time scale)`. */ function scaler(scale: number) { const f = makeSpriteScene('Box'); const box = place(f, f.spriteId, { x: 0, y: 0 }); const behavior = makeBehavior(f, 'Scaler'); const edit = graphEditor(f, behavior); const start = edit.addNode('OnStart'); const set = edit.addNode('SetTimeScale', { scale }); const update = edit.addNode('OnUpdate'); const read = edit.addNode('TimeScale'); const log = edit.addNode('Log'); edit.wire([ [start, 'exec', set, 'exec'], [update, 'exec', log, 'exec'], [read, 'scale', log, 'message'], ]); place(f, behavior.id, { x: 0, y: 0 }, box); return f; } it('turns the clock a played game hands over, and Time scale reads it back', () => { const clock = new GameClock(); const { world, frame } = startWorld(scaler(0.5), { behaviors: true, clock }); frame(); expect(clock.scale).toBe(0.5); expect(world.log).toEqual(['0.5']); }); it('is ignored without a clock – the editor run, which shares its clock with the previews', () => { const { world, frame } = startWorld(scaler(0.5), { behaviors: true }); frame(); expect(world.log).toEqual(['Set time scale is ignored in the editor run (0.5)', '1']); }); it('ends with the run: resetting the clock puts it back to real time', () => { const clock = new GameClock(); clock.scale = 0.25; clock.advance(0.05); clock.reset(); expect({ now: clock.now, scale: clock.scale }).toEqual({ now: 0, scale: 1 }); }); });