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
+570 -26

No files matched your search

+37 -4
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);
}
}
this.fireCollisions(instance, i, ctx, dt);
this.fireAnimationsFinished(instance, i, ctx);
// 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;
},
};
}
}