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
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -215,4 +215,12 @@ export interface NodeContext {
|
|||||||
stop(): void;
|
stop(): void;
|
||||||
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
|
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
|
||||||
restart(): void;
|
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() }),
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry';
|
|||||||
import { Destroy } from './action/Destroy';
|
import { Destroy } from './action/Destroy';
|
||||||
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
||||||
import { Emit } from './action/Emit';
|
import { Emit } from './action/Emit';
|
||||||
|
import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
|
||||||
import { LoadScene } from './action/LoadScene';
|
import { LoadScene } from './action/LoadScene';
|
||||||
import { Log } from './action/Log';
|
import { Log } from './action/Log';
|
||||||
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
||||||
@@ -236,6 +237,11 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
Log,
|
Log,
|
||||||
StopGame,
|
StopGame,
|
||||||
RestartGame,
|
RestartGame,
|
||||||
|
PauseGame,
|
||||||
|
ResumeGame,
|
||||||
|
IsGamePaused,
|
||||||
|
SetTimeScale,
|
||||||
|
TimeScale,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const def of BUILT_IN_NODES) registerNode(def);
|
for (const def of BUILT_IN_NODES) registerNode(def);
|
||||||
|
|||||||
@@ -405,7 +405,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
|||||||
...COLLISION_LAYER_ROWS,
|
...COLLISION_LAYER_ROWS,
|
||||||
],
|
],
|
||||||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
// 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`.
|
// Every row is declared on the asset itself – see `schemaForBag`.
|
||||||
variables: [],
|
variables: [],
|
||||||
// The states are the asset's graph (see StateMachineAsset), not a property.
|
// The states are the asset's graph (see StateMachineAsset), not a property.
|
||||||
|
|||||||
+5
-1
@@ -39,9 +39,13 @@ export class GameClock {
|
|||||||
return step;
|
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 {
|
reset(): void {
|
||||||
this.seconds = 0;
|
this.seconds = 0;
|
||||||
|
this.scale = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { pluginsOf } from '../core/store/projectPlugins';
|
|||||||
import type { FontStore } from '../core/store/FontStore';
|
import type { FontStore } from '../core/store/FontStore';
|
||||||
import type { SoundStore } from '../core/store/SoundStore';
|
import type { SoundStore } from '../core/store/SoundStore';
|
||||||
import type { TextureStore } from '../core/store/TextureStore';
|
import type { TextureStore } from '../core/store/TextureStore';
|
||||||
import { gameClock } from '../core/time';
|
import { gameClock, timeStep } from '../core/time';
|
||||||
import {
|
import {
|
||||||
clamp,
|
clamp,
|
||||||
HStack,
|
HStack,
|
||||||
@@ -702,8 +702,9 @@ export class Editor implements PlayHost {
|
|||||||
this.measureFps(dt);
|
this.measureFps(dt);
|
||||||
// One step for everything below: a world gets exactly the seconds the particle, shader and
|
// 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.
|
// animation clocks moved, so the global time scale cannot pull them apart.
|
||||||
const step = gameClock.advance(dt, this.state.paused);
|
// The game's own pause holds the clock as well, so particles and shaders freeze with the world.
|
||||||
this.play.tick(step);
|
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.editorRun.tick(step);
|
||||||
this.viewport.animate();
|
this.viewport.animate();
|
||||||
this.gameView.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
|
* 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.
|
* 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 {
|
return {
|
||||||
playing: this.play.isPlaying,
|
playing: this.play.isPlaying,
|
||||||
paused: this.play.isPaused,
|
paused: this.play.isPaused,
|
||||||
|
gamePaused: this.play.isGamePaused,
|
||||||
time: this.play.time,
|
time: this.play.time,
|
||||||
sceneId: this.play.sceneId,
|
sceneId: this.play.sceneId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ export class EditorRun {
|
|||||||
console.warn('[editor run] Load scene only works while playing');
|
console.warn('[editor run] Load scene only works while playing');
|
||||||
world.loadSceneRequested = '';
|
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) {
|
if (world.restartRequested) {
|
||||||
this.stop();
|
this.stop();
|
||||||
this.stale = true;
|
this.stale = true;
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ export class PlaySession {
|
|||||||
return this.world !== null && this.state.paused;
|
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. */
|
/** Pause or resume; does nothing while not playing. */
|
||||||
togglePause(): void {
|
togglePause(): void {
|
||||||
if (!this.world) return;
|
if (!this.world) return;
|
||||||
@@ -128,7 +133,11 @@ export class PlaySession {
|
|||||||
const world = World.instantiate(runtime, scene.id);
|
const world = World.instantiate(runtime, scene.id);
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.input.attach();
|
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.gameView.setWorld(world);
|
||||||
this.host.showGame();
|
this.host.showGame();
|
||||||
this.state.setNotice(this.playNotice(), 'quiet');
|
this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
@@ -150,7 +159,12 @@ export class PlaySession {
|
|||||||
old.destroy();
|
old.destroy();
|
||||||
const world = World.instantiate(runtime, sceneId);
|
const world = World.instantiate(runtime, sceneId);
|
||||||
this.world = world;
|
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.host.gameView.setWorld(world);
|
||||||
this.state.setNotice(this.playNotice(), 'quiet');
|
this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
if (world.stopRequested) this.stop();
|
if (world.stopRequested) this.stop();
|
||||||
@@ -176,6 +190,7 @@ export class PlaySession {
|
|||||||
|
|
||||||
private playNotice(): string {
|
private playNotice(): string {
|
||||||
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops';
|
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()
|
return this.world?.activeCamera()
|
||||||
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
|
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
|
||||||
: 'No camera is switched on – nothing is rendered · Esc stops';
|
: 'No camera is switched on – nothing is rendered · Esc stops';
|
||||||
@@ -200,8 +215,12 @@ export class PlaySession {
|
|||||||
this.state.setNotice(lastError ?? '', 'error');
|
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;
|
const world = this.world;
|
||||||
if (!world) return;
|
if (!world) return;
|
||||||
if (this.state.paused) {
|
if (this.state.paused) {
|
||||||
@@ -211,8 +230,10 @@ export class PlaySession {
|
|||||||
world.dropPointerEdges();
|
world.dropPointerEdges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
world.tick(step);
|
const wasPaused = world.paused;
|
||||||
|
world.tick(world.paused ? awake : step);
|
||||||
this.input.endFrame();
|
this.input.endFrame();
|
||||||
|
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
if (world.stopRequested) {
|
if (world.stopRequested) {
|
||||||
this.stop();
|
this.stop();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -815,11 +815,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
walk(app.stage);
|
walk(app.stage);
|
||||||
return n;
|
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: () => ({
|
globalTime: () => ({
|
||||||
now: gameClock.now,
|
now: gameClock.now,
|
||||||
scale: gameClock.scale,
|
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. */
|
/** Sets the global time scale – there is no slider for it in the GUI yet. */
|
||||||
setTimeScale: (scale: number) => {
|
setTimeScale: (scale: number) => {
|
||||||
|
|||||||
+18
-2
@@ -60,6 +60,13 @@ export class World {
|
|||||||
* two scenes in a row is not a thing anyone means.
|
* two scenes in a row is not a thing anyone means.
|
||||||
*/
|
*/
|
||||||
loadSceneRequested = '';
|
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
|
* 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.
|
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
|
||||||
@@ -268,9 +275,18 @@ export class World {
|
|||||||
return this;
|
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 {
|
tick(dt: number): void {
|
||||||
this.elapsed += dt;
|
// A pause asked for halfway through a frame starts with the next one, so every behavior of
|
||||||
for (const system of this.systems) system.update(this, dt);
|
// 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.
|
// The pointer edges belong to exactly one tick, whatever the systems did with them.
|
||||||
this.dropPointerEdges();
|
this.dropPointerEdges();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ export interface GameSystemOptions {
|
|||||||
* collisions are detected only, which is what the editor run still does.
|
* collisions are detected only, which is what the editor run still does.
|
||||||
*/
|
*/
|
||||||
physics?: Rapier | null;
|
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. */
|
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
|
||||||
@@ -109,11 +118,11 @@ export function addGameSystems(
|
|||||||
world: World,
|
world: World,
|
||||||
project: Project,
|
project: Project,
|
||||||
input: Input,
|
input: Input,
|
||||||
{ shouldRun, audio = SILENT_AUDIO, carried = null, physics = null }: GameSystemOptions = {},
|
{ shouldRun, audio = SILENT_AUDIO, carried = null, physics = null, clock = null }: GameSystemOptions = {},
|
||||||
): BehaviorSystem {
|
): BehaviorSystem {
|
||||||
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
||||||
const animations = new AnimationSystem();
|
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);
|
if (carried) behaviors.carryOver(carried);
|
||||||
world.addSystem(collisions);
|
world.addSystem(collisions);
|
||||||
world.addSystem(animations);
|
world.addSystem(animations);
|
||||||
@@ -127,6 +136,12 @@ export interface EventSources {
|
|||||||
animations?: AnimationSystem | null;
|
animations?: AnimationSystem | null;
|
||||||
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
||||||
audio?: AudioSink | null;
|
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. */
|
/** 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 {
|
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 instances: Instance[] = [];
|
||||||
private world: World | null = null;
|
private world: World | null = null;
|
||||||
private sharedState: SharedState = emptySharedState();
|
private sharedState: SharedState = emptySharedState();
|
||||||
@@ -147,6 +164,7 @@ export class BehaviorSystem implements System {
|
|||||||
private readonly collisions: CollisionSource | null;
|
private readonly collisions: CollisionSource | null;
|
||||||
private readonly animations: AnimationSystem | null;
|
private readonly animations: AnimationSystem | null;
|
||||||
private readonly audio: AudioSink;
|
private readonly audio: AudioSink;
|
||||||
|
private readonly clock: TimeScaled | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly project: Project,
|
private readonly project: Project,
|
||||||
@@ -161,6 +179,7 @@ export class BehaviorSystem implements System {
|
|||||||
this.collisions = sources.collisions ?? null;
|
this.collisions = sources.collisions ?? null;
|
||||||
this.animations = sources.animations ?? null;
|
this.animations = sources.animations ?? null;
|
||||||
this.audio = sources.audio ?? SILENT_AUDIO;
|
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`. */
|
/** 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 {
|
update(world: World, dt: number): void {
|
||||||
this.syncInstances(world);
|
this.syncInstances(world);
|
||||||
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
|
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
|
||||||
|
const frozen = world.frozen;
|
||||||
for (const instance of this.instances) {
|
for (const instance of this.instances) {
|
||||||
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
|
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
|
||||||
|
if (frozen && !runsWhilePaused(instance.entity.props)) continue;
|
||||||
if (!this.switched(instance)) continue;
|
if (!this.switched(instance)) continue;
|
||||||
this.run(
|
this.run(
|
||||||
instance,
|
instance,
|
||||||
@@ -394,8 +415,11 @@ export class BehaviorSystem implements System {
|
|||||||
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
|
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.fireCollisions(instance, i, ctx, dt);
|
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
|
||||||
this.fireAnimationsFinished(instance, i, ctx);
|
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
|
// 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).
|
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
|
||||||
const pointer = world.pointer;
|
const pointer = world.pointer;
|
||||||
@@ -674,6 +698,15 @@ export class BehaviorSystem implements System {
|
|||||||
restart: () => {
|
restart: () => {
|
||||||
world.restartRequested = true;
|
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 {
|
export interface System {
|
||||||
start?(world: World): void;
|
start?(world: World): void;
|
||||||
update(world: World, dt: number): 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;
|
stop?(world: World): void;
|
||||||
}
|
}
|
||||||
@@ -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", () => {
|
it("offers a behavior's variables and no position, and a variable `x` is the variable", () => {
|
||||||
const { f, moverObject, walk } = rig();
|
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);
|
const row = addRow(f, walk, 'x', ramp, moverObject);
|
||||||
expect(trackLabel(f.project, current(f, walk), rowOf(current(f, walk), row))).toBe('Mover · x');
|
expect(trackLabel(f.project, current(f, walk), rowOf(current(f, walk), row))).toBe('Mover · x');
|
||||||
const { world, frame } = startWorld(f, { animations: true });
|
const { world, frame } = startWorld(f, { animations: true });
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function wallAndMover() {
|
|||||||
describe('the enabled switch', () => {
|
describe('the enabled switch', () => {
|
||||||
it('is a common property of every placeable type, defaults to on and overrides per object', () => {
|
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('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);
|
expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false);
|
||||||
|
|
||||||
const f = makeSpriteScene();
|
const f = makeSpriteScene();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -452,7 +452,7 @@ describe('variable commands', () => {
|
|||||||
|
|
||||||
stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity'));
|
stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity'));
|
||||||
expect(behavior.variables[0]!.key).toBe('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(object(logic).overrides).toEqual({ velocity: 5 });
|
||||||
expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]);
|
expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]);
|
||||||
stack.undo();
|
stack.undo();
|
||||||
@@ -477,7 +477,7 @@ describe('behavior serialization', () => {
|
|||||||
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||||||
expect(loaded.variables).toEqual(behavior.variables);
|
expect(loaded.variables).toEqual(behavior.variables);
|
||||||
expect(loaded.graph).toEqual(behavior.graph);
|
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 {
|
const raw = serializeProject(project) as {
|
||||||
assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[];
|
assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[];
|
||||||
|
|||||||
@@ -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) }],
|
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 }],
|
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. */
|
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
|
||||||
|
|||||||
@@ -315,3 +315,18 @@ describe('shared variables of an editor run', () => {
|
|||||||
expect(f.run.liveVariables(f.sharedId)).toBeNull();
|
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
@@ -30,7 +30,7 @@ import { createEmptyProject, findScene, type Project } from '../src/core/model/P
|
|||||||
import type { SceneObject } from '../src/core/model/SceneObject';
|
import type { SceneObject } from '../src/core/model/SceneObject';
|
||||||
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
|
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
|
||||||
import type { AudioSink } from '../src/runtime/systems/Audio';
|
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 { readyPhysics } from '../src/runtime/physics/engine';
|
||||||
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
|
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
|
||||||
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
|
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
|
||||||
@@ -202,6 +202,9 @@ export type FakeContext = NodeContext & {
|
|||||||
silenced: string[];
|
silenced: string[];
|
||||||
/** The scenes the context was asked to load, in order. */
|
/** The scenes the context was asked to load, in order. */
|
||||||
loaded: string[];
|
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. */
|
/** 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;
|
ctx.stopped = true;
|
||||||
},
|
},
|
||||||
restart: () => {},
|
restart: () => {},
|
||||||
|
scale: 1,
|
||||||
|
paused: false,
|
||||||
|
timeScale: () => ctx.scale,
|
||||||
|
setTimeScale: (scale) => {
|
||||||
|
ctx.scale = scale;
|
||||||
|
},
|
||||||
|
isPaused: () => ctx.paused,
|
||||||
|
setPaused: (paused) => {
|
||||||
|
ctx.paused = paused;
|
||||||
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
return ctx;
|
return ctx;
|
||||||
@@ -284,6 +297,8 @@ export interface WorldOptions {
|
|||||||
behaviors?: boolean;
|
behaviors?: boolean;
|
||||||
/** Where `Play sound` ends up; without one the run plays to nobody (see `SILENT_AUDIO`). */
|
/** Where `Play sound` ends up; without one the run plays to nobody (see `SILENT_AUDIO`). */
|
||||||
audio?: AudioSink;
|
audio?: AudioSink;
|
||||||
|
/** The clock `Set time scale` turns; without one the run is treated like the editor run. */
|
||||||
|
clock?: TimeScaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartedWorld {
|
export interface StartedWorld {
|
||||||
@@ -314,7 +329,12 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S
|
|||||||
if (collisions) world.addSystem(collisions);
|
if (collisions) world.addSystem(collisions);
|
||||||
if (animations) world.addSystem(animations);
|
if (animations) world.addSystem(animations);
|
||||||
const behaviors = options.behaviors
|
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;
|
: null;
|
||||||
if (behaviors) world.addSystem(behaviors);
|
if (behaviors) world.addSystem(behaviors);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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;
|
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) {
|
export async function pause(t) {
|
||||||
const {
|
const {
|
||||||
check,
|
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`,
|
`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.
|
// 18h. Play discarded its copy, so the box is back at the position the model holds.
|
||||||
const left = await rig.remove();
|
const left = await rig.remove();
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const KEYS = {
|
|||||||
c: { key: 'c', code: 'KeyC', windowsVirtualKeyCode: 67 },
|
c: { key: 'c', code: 'KeyC', windowsVirtualKeyCode: 67 },
|
||||||
d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 },
|
d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 },
|
||||||
g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
|
g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
|
||||||
|
p: { key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80 },
|
||||||
s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 },
|
s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 },
|
||||||
v: { key: 'v', code: 'KeyV', windowsVirtualKeyCode: 86 },
|
v: { key: 'v', code: 'KeyV', windowsVirtualKeyCode: 86 },
|
||||||
w: { key: 'w', code: 'KeyW', windowsVirtualKeyCode: 87 },
|
w: { key: 'w', code: 'KeyW', windowsVirtualKeyCode: 87 },
|
||||||
|
|||||||
Reference in new issue
Block a user