diff --git a/README.md b/README.md index 9a3be8c2..9c599b45 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 0f490afe..fd82f653 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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; } diff --git a/src/core/graph/nodes/action/gameTimeNodes.ts b/src/core/graph/nodes/action/gameTimeNodes.ts new file mode 100644 index 00000000..c8cff0c2 --- /dev/null +++ b/src/core/graph/nodes/action/gameTimeNodes.ts @@ -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() }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 592bdc1a..77befa3e 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 53579ab7..dd21a911 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -405,7 +405,10 @@ const ASSET_SCHEMAS: Record = { ...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. diff --git a/src/core/time.ts b/src/core/time.ts index a2df6dc8..cba0102d 100644 --- a/src/core/time.ts +++ b/src/core/time.ts @@ -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; } } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index aa4364d8..8db5b6d2 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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, }; diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 2fe08404..86f06449 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -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; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 700aa31e..f1e1f112 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index b7e20194..153def6f 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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) => { diff --git a/src/runtime/World.ts b/src/runtime/World.ts index b7a545c3..d023b51e 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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(); } diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index c378f5aa..3c28fa33 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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; + }, }; } } diff --git a/src/runtime/systems/System.ts b/src/runtime/systems/System.ts index fcca427c..62ec9eb9 100644 --- a/src/runtime/systems/System.ts +++ b/src/runtime/systems/System.ts @@ -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; } diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 8c77fa55..4e7a365d 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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 }); diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts index 9c4c484f..02710844 100644 --- a/tests/core/enabled.test.ts +++ b/tests/core/enabled.test.ts @@ -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(); diff --git a/tests/core/game-pause.test.ts b/tests/core/game-pause.test.ts new file mode 100644 index 00000000..671bf1fc --- /dev/null +++ b/tests/core/game-pause.test.ts @@ -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 `(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 }); + }); +}); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 457fcf91..b4c8de94 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -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[] } }[]; diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 4572287e..21a267c5 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1218,6 +1218,51 @@ const CASES: Record = { ], 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, + 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, + 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, + outputs: { scale: 0.25 }, + }, + ], }; /** Two distinct handles that share a name – `Equals` must still tell them apart. */ diff --git a/tests/editor/editor-run.test.ts b/tests/editor/editor-run.test.ts index 092eebdd..4f17072d 100644 --- a/tests/editor/editor-run.test.ts +++ b/tests/editor/editor-run.test.ts @@ -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'); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 448e44eb..32ca12f0 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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 = {}): 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 { diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index 3b7bd98f..e6a9bce3 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -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( diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 52d5c08b..c0a78abd 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -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 },