Until now only the editor toolbar could pause a running game. A pause menu inside the game had no way to stop the world while its own graph kept running, and there was no slow motion. - Five new nodes: Pause game, Resume game, Is game paused, Set time scale and Time scale. Pause and resume take effect on the next frame. - A game pause stops world time, physics, animations, particles and shader time. Only behaviors with the new "Run while paused" switch keep running. - Set time scale 0 freezes the world but graphs keep running, as agreed in the issue. - Play and Stop reset the time scale to 1, Load scene keeps it. The pause belongs to the scene, so Load scene and Restart unpause. - The toolbar pause stays separate and also freezes the menu. When the game pauses itself the status line reads "The game paused itself". - While paused, collision events are not reported, sounds keep playing, and the editor run ignores both nodes with a log line. Findings: - The first status line was longer than any before and got cut with an ellipsis. It is shorter now. - The e2e harness did not know the key "p". It does now. Tests: - ./check.sh: 133 files, 1968 tests, exit 0 (before 132 / 1952) - npm run build: exit 0 - GM_STEPS=18 fresh: ALL PASSED, 33 checks (before 26) - 16 new unit tests: 8 catalog cases, game-pause.test.ts, editor run - 7 new e2e checks in block 18i, driven by real keys in the game - Six counter-checks all bite: behavior filter, collision lock, world time, editor run, clock reset and the game clock in the browser - Check total 1217 in README is computed (1210 + 7), CI measures it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
6.5 KiB
TypeScript
180 lines
6.5 KiB
TypeScript
/**
|
||
* 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 `<event>(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 });
|
||
});
|
||
});
|