A game can pause itself and set its own time scale (#167)
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:
1 parent
7063c8d074
commit
3427f84a55
22 files changed
+570
-26
No files matched your search
+18
-2
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user