A game can pause itself and set its own time scale (#167)
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m48s

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>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 10:27:17 +02:00
1 parent 7063c8d074
commit 3427f84a55
22 files changed
+568 -24

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1210 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1217 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+8
View File
@@ -215,4 +215,12 @@ export interface NodeContext {
stop(): void;
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
restart(): void;
/** The global time scale the run goes at (#167): 1 is real time, 0 frozen. */
timeScale(): number;
/** For the rest of the run, the next scene included; Play and Stop set it back to 1. */
setTimeScale(scale: number): void;
/** Whether a behavior paused the game – not the editor's own pause, which runs no graph at all. */
isPaused(): boolean;
/** From the next frame on, only behaviors that run while paused are ticked. */
setPaused(paused: boolean): void;
}
@@ -0,0 +1,77 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { bool, exec, num, withDefault } from '../shared';
const EDITOR_RUN = 'The editor run ignores it and says so in the console.';
export const SetTimeScale: NodeDef = {
type: 'SetTimeScale',
label: 'Set time scale',
category: 'action',
description:
'Sets how fast the game runs: 1 is real time, 0.5 slow motion, 2 twice as fast, 0 frozen. Movement, ' +
'physics, animations, particles and shader time all follow it, and the graphs keep running at 0. ' +
'Holds for the rest of the run, the next scene included; Play starts at 1. ' +
EDITOR_RUN,
inputs: [exec(), withDefault(num('scale'), 1)],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.setTimeScale(Math.max(0, asNumber(inputs.scale)));
return { next: 'exec' };
},
};
export const TimeScale: NodeDef = {
type: 'TimeScale',
label: 'Time scale',
category: 'data',
description: 'How fast the game runs, as Set time scale left it: 1 is real time.',
inputs: [],
outputs: [num('scale')],
params: [],
read: (ctx) => ({ scale: ctx.timeScale() }),
};
export const PauseGame: NodeDef = {
type: 'PauseGame',
label: 'Pause game',
category: 'action',
description:
'Pauses the game from the next frame on: the world time, physics, animations, particles and shader ' +
'time stand still, and only behaviors with "Run while paused" keep running – a pause menu needs ' +
'one, or nothing can resume. Sounds play on. The next scene starts unpaused. ' +
EDITOR_RUN,
inputs: [exec()],
outputs: [exec()],
params: [],
run: (ctx) => {
ctx.setPaused(true);
return { next: 'exec' };
},
};
export const ResumeGame: NodeDef = {
type: 'ResumeGame',
label: 'Resume game',
category: 'action',
description: 'Lets a game paused by Pause game run on from the next frame, where it stood.',
inputs: [exec()],
outputs: [exec()],
params: [],
run: (ctx) => {
ctx.setPaused(false);
return { next: 'exec' };
},
};
export const IsGamePaused: NodeDef = {
type: 'IsGamePaused',
label: 'Is game paused',
category: 'data',
description: 'True while Pause game holds the game – what lets one key both pause and resume.',
inputs: [],
outputs: [bool('paused')],
params: [],
read: (ctx) => ({ paused: ctx.isPaused() }),
};
+6
View File
@@ -2,6 +2,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry';
import { Destroy } from './action/Destroy';
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
import { Emit } from './action/Emit';
import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
import { LoadScene } from './action/LoadScene';
import { Log } from './action/Log';
import { IgnoreOneWay } from './action/IgnoreOneWay';
@@ -236,6 +237,11 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Log,
StopGame,
RestartGame,
PauseGame,
ResumeGame,
IsGamePaused,
SetTimeScale,
TimeScale,
];
for (const def of BUILT_IN_NODES) registerNode(def);
+4 -1
View File
@@ -405,7 +405,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
...COLLISION_LAYER_ROWS,
],
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
behavior: [
{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false },
{ key: 'runWhilePaused', label: 'Run while paused', kind: 'boolean', default: false },
],
// Every row is declared on the asset itself – see `schemaForBag`.
variables: [],
// The states are the asset's graph (see StateMachineAsset), not a property.
+5 -1
View File
@@ -39,9 +39,13 @@ export class GameClock {
return step;
}
/** Back to second zero. Anything holding an absolute second of this clock has to start over. */
/**
* Back to second zero and real time – a run starts there and leaves nothing behind (#167).
* Anything holding an absolute second of this clock has to start over.
*/
reset(): void {
this.seconds = 0;
this.scale = 1;
}
}
+6 -4
View File
@@ -6,7 +6,7 @@ import { pluginsOf } from '../core/store/projectPlugins';
import type { FontStore } from '../core/store/FontStore';
import type { SoundStore } from '../core/store/SoundStore';
import type { TextureStore } from '../core/store/TextureStore';
import { gameClock } from '../core/time';
import { gameClock, timeStep } from '../core/time';
import {
clamp,
HStack,
@@ -702,8 +702,9 @@ export class Editor implements PlayHost {
this.measureFps(dt);
// One step for everything below: a world gets exactly the seconds the particle, shader and
// animation clocks moved, so the global time scale cannot pull them apart.
const step = gameClock.advance(dt, this.state.paused);
this.play.tick(step);
// The game's own pause holds the clock as well, so particles and shaders freeze with the world.
const step = gameClock.advance(dt, this.state.paused || this.play.isGamePaused);
this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused));
this.editorRun.tick(step);
this.viewport.animate();
this.gameView.animate();
@@ -782,10 +783,11 @@ export class Editor implements PlayHost {
* is the scene the run is *in* – the only way a check can see a `Load scene` happen, since the
* swap leaves the editor's own active scene alone.
*/
playInfo(): { playing: boolean; paused: boolean; time: number; sceneId: string } {
playInfo(): { playing: boolean; paused: boolean; gamePaused: boolean; time: number; sceneId: string } {
return {
playing: this.play.isPlaying,
paused: this.play.isPaused,
gamePaused: this.play.isGamePaused,
time: this.play.time,
sceneId: this.play.sceneId,
};
+7
View File
@@ -141,6 +141,13 @@ export class EditorRun {
console.warn('[editor run] Load scene only works while playing');
world.loadSceneRequested = '';
}
if (world.paused) {
// Paused, the run could only be resumed by a behavior that runs while paused – and in the
// editor the author would see a scene that stopped for no visible reason.
world.log.push('Pause game is ignored in the editor run');
console.warn('[editor run] Pause game only works while playing');
world.paused = false;
}
if (world.restartRequested) {
this.stop();
this.stale = true;
+26 -5
View File
@@ -72,6 +72,11 @@ export class PlaySession {
return this.world !== null && this.state.paused;
}
/** Paused by a behavior (Pause game) rather than by the toolbar – the game's own pause. */
get isGamePaused(): boolean {
return this.world?.paused ?? false;
}
/** Pause or resume; does nothing while not playing. */
togglePause(): void {
if (!this.world) return;
@@ -128,7 +133,11 @@ export class PlaySession {
const world = World.instantiate(runtime, scene.id);
this.world = world;
this.input.attach();
this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, physics });
this.behaviors = addGameSystems(world, runtime, this.input, {
audio: soundLibrary,
physics,
clock: gameClock,
});
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(this.playNotice(), 'quiet');
@@ -150,7 +159,12 @@ export class PlaySession {
old.destroy();
const world = World.instantiate(runtime, sceneId);
this.world = world;
this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, carried, physics });
this.behaviors = addGameSystems(world, runtime, this.input, {
audio: soundLibrary,
carried,
physics,
clock: gameClock,
});
this.host.gameView.setWorld(world);
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
@@ -176,6 +190,7 @@ export class PlaySession {
private playNotice(): string {
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops';
if (this.world?.paused) return 'The game paused itself · Esc stops';
return this.world?.activeCamera()
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
: 'No camera is switched on – nothing is rendered · Esc stops';
@@ -200,8 +215,12 @@ export class PlaySession {
this.state.setNotice(lastError ?? '', 'error');
}
/** `step` is what the global clock moved this frame – already scaled and capped. */
tick(step: number): void {
/**
* `step` is what the global clock moved this frame – already scaled and capped. `awake` is the
* step it would have moved without the game's own pause, which stops the clock but not the
* behaviors that run while paused.
*/
tick(step: number, awake: number): void {
const world = this.world;
if (!world) return;
if (this.state.paused) {
@@ -211,8 +230,10 @@ export class PlaySession {
world.dropPointerEdges();
return;
}
world.tick(step);
const wasPaused = world.paused;
world.tick(world.paused ? awake : step);
this.input.endFrame();
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) {
this.stop();
return;
+2 -2
View File
@@ -815,11 +815,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
walk(app.stage);
return n;
},
/** The global clock: the second it stands at, the scale set, and the one a pause makes of it. */
/** The global clock: the second it stands at, the scale set, and the one a pause – either kind – makes of it. */
globalTime: () => ({
now: gameClock.now,
scale: gameClock.scale,
effective: effectiveScale(gameClock.scale, state.paused),
effective: effectiveScale(gameClock.scale, state.paused || editor.playInfo().gamePaused),
}),
/** Sets the global time scale – there is no slider for it in the GUI yet. */
setTimeScale: (scale: number) => {
+18 -2
View File
@@ -60,6 +60,13 @@ export class World {
* two scenes in a row is not a thing anyone means.
*/
loadSceneRequested = '';
/**
* Set by a behavior (Pause game / Resume game, #167). The world time, the physics and the
* animations stand still, and only behaviors that run while paused are ticked. Belongs to the
* world, so the next scene starts unpaused.
*/
paused = false;
private frameFrozen = false;
/**
* Mouse state in world units, fed by the game view (the only place with the camera
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
@@ -268,9 +275,18 @@ export class World {
return this;
}
/** Whether the frame being ticked is a paused one – read once when it began, see `tick`. */
get frozen(): boolean {
return this.frameFrozen;
}
tick(dt: number): void {
this.elapsed += dt;
for (const system of this.systems) system.update(this, dt);
// A pause asked for halfway through a frame starts with the next one, so every behavior of
// this frame sees the same world.
this.frameFrozen = this.paused;
if (!this.frameFrozen) this.elapsed += dt;
for (const system of this.systems)
if (!this.frameFrozen || system.runsWhilePaused) system.update(this, dt);
// The pointer edges belong to exactly one tick, whatever the systems did with them.
this.dropPointerEdges();
}
+35 -2
View File
@@ -102,6 +102,15 @@ export interface GameSystemOptions {
* collisions are detected only, which is what the editor run still does.
*/
physics?: Rapier | null;
/**
* The clock whose scale `Set time scale` turns (#167). Only a played game hands it over: the editor
* run shares the clock with the previews, and a behavior there must not slow the editor down.
*/
clock?: TimeScaled | null;
}
export interface TimeScaled {
scale: number;
}
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
@@ -109,11 +118,11 @@ export function addGameSystems(
world: World,
project: Project,
input: Input,
{ shouldRun, audio = SILENT_AUDIO, carried = null, physics = null }: GameSystemOptions = {},
{ shouldRun, audio = SILENT_AUDIO, carried = null, physics = null, clock = null }: GameSystemOptions = {},
): BehaviorSystem {
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
const animations = new AnimationSystem();
const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun);
const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio, clock }, shouldRun);
if (carried) behaviors.carryOver(carried);
world.addSystem(collisions);
world.addSystem(animations);
@@ -127,6 +136,12 @@ export interface EventSources {
animations?: AnimationSystem | null;
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
audio?: AudioSink | null;
clock?: TimeScaled | null;
}
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
export function runsWhilePaused(props: PropertyBag): boolean {
return props.runWhilePaused === true;
}
/** The behavior failure that stopped a run, if that is why it stopped. */
@@ -135,6 +150,8 @@ export function behaviorError(world: World): string | undefined {
}
export class BehaviorSystem implements System {
/** A paused frame still ticks the behaviors that run while paused (see `update`). */
readonly runsWhilePaused = true;
private instances: Instance[] = [];
private world: World | null = null;
private sharedState: SharedState = emptySharedState();
@@ -147,6 +164,7 @@ export class BehaviorSystem implements System {
private readonly collisions: CollisionSource | null;
private readonly animations: AnimationSystem | null;
private readonly audio: AudioSink;
private readonly clock: TimeScaled | null;
constructor(
private readonly project: Project,
@@ -161,6 +179,7 @@ export class BehaviorSystem implements System {
this.collisions = sources.collisions ?? null;
this.animations = sources.animations ?? null;
this.audio = sources.audio ?? SILENT_AUDIO;
this.clock = sources.clock ?? null;
}
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
@@ -375,8 +394,10 @@ export class BehaviorSystem implements System {
update(world: World, dt: number): void {
this.syncInstances(world);
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
const frozen = world.frozen;
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
if (frozen && !runsWhilePaused(instance.entity.props)) continue;
if (!this.switched(instance)) continue;
this.run(
instance,
@@ -394,8 +415,11 @@ export class BehaviorSystem implements System {
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
}
}
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
if (!frozen) {
this.fireCollisions(instance, i, ctx, dt);
this.fireAnimationsFinished(instance, i, ctx);
}
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
@@ -674,6 +698,15 @@ export class BehaviorSystem implements System {
restart: () => {
world.restartRequested = true;
},
timeScale: () => this.clock?.scale ?? 1,
setTimeScale: (scale) => {
if (this.clock) this.clock.scale = scale;
else world.log.push(`Set time scale is ignored in the editor run (${scale})`);
},
isPaused: () => world.paused,
setPaused: (paused) => {
world.paused = paused;
},
};
}
}
+2
View File
@@ -4,5 +4,7 @@ import type { World } from '../World';
export interface System {
start?(world: World): void;
update(world: World, dt: number): void;
/** Ticked in a frame a behavior paused as well (#167); the others stand still in it. */
runsWhilePaused?: boolean;
stop?(world: World): void;
}
+1 -1
View File
@@ -1136,7 +1136,7 @@ describe('behavior variables and shader parameters as rows (#104)', () => {
it("offers a behavior's variables and no position, and a variable `x` is the variable", () => {
const { f, moverObject, walk } = rig();
expect(keysOn(f, walk, moverObject)).toEqual(['enabled', 'runInEditor', 'speed', 'x']);
expect(keysOn(f, walk, moverObject)).toEqual(['enabled', 'runInEditor', 'runWhilePaused', 'speed', 'x']);
const row = addRow(f, walk, 'x', ramp, moverObject);
expect(trackLabel(f.project, current(f, walk), rowOf(current(f, walk), row))).toBe('Mover · x');
const { world, frame } = startWorld(f, { animations: true });
+1 -1
View File
@@ -38,7 +38,7 @@ function wallAndMover() {
describe('the enabled switch', () => {
it('is a common property of every placeable type, defaults to on and overrides per object', () => {
expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true });
expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled', 'runInEditor']);
expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled', 'runInEditor', 'runWhilePaused']);
expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false);
const f = makeSpriteScene();
+179
View File
@@ -0,0 +1,179 @@
/**
* 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 });
});
});
+2 -2
View File
@@ -452,7 +452,7 @@ describe('variable commands', () => {
stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity'));
expect(behavior.variables[0]!.key).toBe('velocity');
expect(behavior.props).toEqual({ enabled: true, runInEditor: false, velocity: 0 });
expect(behavior.props).toEqual({ enabled: true, runInEditor: false, runWhilePaused: false, velocity: 0 });
expect(object(logic).overrides).toEqual({ velocity: 5 });
expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]);
stack.undo();
@@ -477,7 +477,7 @@ describe('behavior serialization', () => {
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
expect(loaded.variables).toEqual(behavior.variables);
expect(loaded.graph).toEqual(behavior.graph);
expect(loaded.props).toEqual({ enabled: true, runInEditor: false, speed: 0 });
expect(loaded.props).toEqual({ enabled: true, runInEditor: false, runWhilePaused: false, speed: 0 });
const raw = serializeProject(project) as {
assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[];
+45
View File
@@ -1218,6 +1218,51 @@ const CASES: Record<string, Case[]> = {
],
StopGame: [{ name: 'ends play and the flow', next: null, after: (ctx) => expect(ctx.stopped).toBe(true) }],
RestartGame: [{ name: 'ends the flow – the world restarts before the next tick', next: null }],
PauseGame: [
{ name: 'pauses the game and flows on', next: 'exec', after: (ctx) => expect(ctx.paused).toBe(true) },
],
ResumeGame: [
{
name: 'lets a paused game run on and flows on',
context: { paused: true } as Partial<NodeContext>,
next: 'exec',
after: (ctx) => expect(ctx.paused).toBe(false),
},
],
IsGamePaused: [
{ name: 'false while the game runs', outputs: { paused: false } },
{
name: 'true while it is paused',
context: { paused: true } as Partial<NodeContext>,
outputs: { paused: true },
},
],
SetTimeScale: [
{
name: 'sets the scale and flows on',
inputs: { scale: 0.5 },
next: 'exec',
after: (ctx) => expect(ctx.scale).toBe(0.5),
},
{
name: '0 freezes the game',
inputs: { scale: 0 },
next: 'exec',
after: (ctx) => expect(ctx.scale).toBe(0),
},
{
name: 'a negative scale is 0 – time never runs backwards',
inputs: { scale: -2 },
after: (ctx) => expect(ctx.scale).toBe(0),
},
],
TimeScale: [
{
name: 'answers the scale the run goes at',
context: { scale: 0.25 } as Partial<NodeContext>,
outputs: { scale: 0.25 },
},
],
};
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
+15
View File
@@ -315,3 +315,18 @@ describe('shared variables of an editor run', () => {
expect(f.run.liveVariables(f.sharedId)).toBeNull();
});
});
describe('the game nodes in the editor run (#167)', () => {
it('ignores Pause game and says so: the scene keeps moving', () => {
const f = scene();
const edit = graphEditor(f, f.behavior);
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseGame'), 'exec');
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
f.frame(0.1);
f.frame(0.1);
expect(f.boxX()).toBeCloseTo(20);
expect(f.keyboard.world?.paused).toBe(false);
expect(f.keyboard.world?.log).toContain('Pause game is ignored in the editor run');
});
});
+22 -2
View File
@@ -30,7 +30,7 @@ import { createEmptyProject, findScene, type Project } from '../src/core/model/P
import type { SceneObject } from '../src/core/model/SceneObject';
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
import type { AudioSink } from '../src/runtime/systems/Audio';
import { BehaviorSystem } from '../src/runtime/systems/BehaviorSystem';
import { BehaviorSystem, type TimeScaled } from '../src/runtime/systems/BehaviorSystem';
import { readyPhysics } from '../src/runtime/physics/engine';
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
@@ -202,6 +202,9 @@ export type FakeContext = NodeContext & {
silenced: string[];
/** The scenes the context was asked to load, in order. */
loaded: string[];
/** The time scale and the pause the context holds, as the game nodes left them. */
scale: number;
paused: boolean;
};
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
@@ -245,6 +248,16 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
ctx.stopped = true;
},
restart: () => {},
scale: 1,
paused: false,
timeScale: () => ctx.scale,
setTimeScale: (scale) => {
ctx.scale = scale;
},
isPaused: () => ctx.paused,
setPaused: (paused) => {
ctx.paused = paused;
},
...overrides,
};
return ctx;
@@ -284,6 +297,8 @@ export interface WorldOptions {
behaviors?: boolean;
/** Where `Play sound` ends up; without one the run plays to nobody (see `SILENT_AUDIO`). */
audio?: AudioSink;
/** The clock `Set time scale` turns; without one the run is treated like the editor run. */
clock?: TimeScaled;
}
export interface StartedWorld {
@@ -314,7 +329,12 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S
if (collisions) world.addSystem(collisions);
if (animations) world.addSystem(animations);
const behaviors = options.behaviors
? new BehaviorSystem(fixture.project, input, { collisions, animations, audio: options.audio })
? new BehaviorSystem(fixture.project, input, {
collisions,
animations,
audio: options.audio,
clock: options.clock,
})
: null;
if (behaviors) world.addSystem(behaviors);
return {
+105
View File
@@ -4,6 +4,7 @@ import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
/**
* Pausing freezes world and clock and drops input instead of replaying it on resume. Brings its own
* self-drifting box, since the fixture's Mover only moves on arrow keys and a pause would look like idling.
* 18i is the game pausing and slowing itself from a graph (#167), through a pause menu that runs while paused.
*/
const DRIFT_PER_SECOND = 100;
@@ -30,6 +31,29 @@ const pauseGraph = {
],
};
/**
* The pause menu of 18i (#167), running while paused: P pauses or resumes, whichever Is game
* paused says is due, and S slows the game to half speed.
*/
const pauseMenuGraph = {
nodes: [
node('pm-p', 'OnKeyDown', 0, 0, { key: 'p' }),
node('pm-paused', 'IsGamePaused', 0, 120),
node('pm-branch', 'Branch', 300, 0),
node('pm-resume', 'ResumeGame', 560, -60),
node('pm-pause', 'PauseGame', 560, 60),
node('pm-s', 'OnKeyDown', 0, 260, { key: 's' }),
node('pm-slow', 'SetTimeScale', 300, 260, { scale: 0.5 }),
],
edges: [
edge('pm-e1', 'pm-p', 'exec', 'pm-branch', 'exec'),
edge('pm-e2', 'pm-paused', 'paused', 'pm-branch', 'condition'),
edge('pm-e3', 'pm-branch', 'then', 'pm-resume', 'exec'),
edge('pm-e4', 'pm-branch', 'else', 'pm-pause', 'exec'),
edge('pm-e5', 'pm-s', 'exec', 'pm-slow', 'exec'),
],
};
export async function pause(t) {
const {
check,
@@ -308,6 +332,87 @@ export async function pause(t) {
`run ended at ${fastTo.t.now.toFixed(2)} s → editor at ${afterStopClock.now.toFixed(2)} s`,
);
// 18i. (#167) The game pauses itself. A pause menu is a behavior that runs while paused: P asks
// Is game paused and pauses or resumes, S sets the time scale to 0.5. Everything is driven by
// keys in the running game, and the drifting box from 18a is what has to hold or slow down.
const menu = await scaffoldBehavior(t, {
assetId: 'pause-menu',
name: 'Pause menu',
graph: pauseMenuGraph,
boxId: 'pm-box',
boxName: 'Pause menu box',
logicId: 'pm-logic',
y: LANES.pause - 150,
props: { enabled: true, runInEditor: false, runWhilePaused: true },
});
await select({ kind: 'object', holderId: 'scene1', objectId: 'pm-logic' });
check('a behavior object offers "Run while paused" in the inspector', await has('Run while paused'));
await t.play();
await sleep(300);
const drift = async (ms) => {
const from = { x: await boxX(), t: (await clock()).now };
await sleep(ms);
return { x: (await boxX()) - from.x, t: (await clock()).now - from.t };
};
const normal = await drift(600);
await press('p');
await sleep(100);
const gameHeld = { info: await playInfo(), clock: await clock() };
const heldDrift = await drift(600);
check(
'Pause game from a key holds the box and the clock, and it is the game pausing, not the toolbar',
normal.x > 20 &&
gameHeld.info.gamePaused === true &&
gameHeld.info.paused === false &&
gameHeld.clock.effective === 0 &&
gameHeld.clock.scale === 1 &&
Math.abs(heldDrift.x) < 0.001 &&
Math.abs(heldDrift.t) < 0.001,
JSON.stringify({ normal, gameHeld, heldDrift }),
);
check('the status line says the game paused itself', await has('The game paused itself · Esc stops'));
await shot('36c-game-paused');
// Only a behavior that runs while paused can have heard this key: the drifting one stands still.
await press('p');
await sleep(100);
const resumedDrift = await drift(600);
check(
'the same key resumes it – the menu behavior kept running while everything else stood',
(await playInfo()).gamePaused === false && resumedDrift.x > 20,
JSON.stringify(resumedDrift),
);
await press('s');
await sleep(100);
const slowed = { clock: await clock(), drift: await drift(800) };
const rate = (d) => d.x / d.t;
check(
'Set time scale 0.5 halves the clock, and the box drifts as far per clock second as before',
slowed.clock.scale === 0.5 &&
slowed.clock.effective === 0.5 &&
slowed.drift.t > 0.2 &&
slowed.drift.t < 0.55 &&
Math.abs(rate(slowed.drift) - rate(resumedDrift)) < DRIFT_PER_SECOND * 0.1,
JSON.stringify({ slowed, resumedDrift }),
);
await stop();
const afterGameStop = await clock();
await t.play();
const replay = await clock();
await stop();
check(
'stopping ends the slow motion, and the next Play starts at real time',
afterGameStop.scale === 1 && replay.scale === 1 && replay.effective === 1,
JSON.stringify({ afterGameStop, replay }),
);
const menuLeft = await menu.remove();
check(
'the pause menu leaves nothing of its own behind',
menuLeft.objects === 0 && menuLeft.assets === 0,
JSON.stringify(menuLeft),
);
// 18h. Play discarded its copy, so the box is back at the position the model holds.
const left = await rig.remove();
check(
+1
View File
@@ -49,6 +49,7 @@ const KEYS = {
c: { key: 'c', code: 'KeyC', windowsVirtualKeyCode: 67 },
d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 },
g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
p: { key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80 },
s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 },
v: { key: 'v', code: 'KeyV', windowsVirtualKeyCode: 86 },
w: { key: 'w', code: 'KeyW', windowsVirtualKeyCode: 87 },