/** * Owns everything that only exists while the project is playing: the world, its systems, the * keyboard and the per-frame step. Play never touches the saved model – the world is built * from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away. */ import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph'; import { namedValue } from '../core/graph/values'; import { GameRun, type RunServices } from '../runtime/GameRun'; import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem'; import type { AnimationPlayer } from '../core/model/animation'; import type { PropertyBag } from '../core/model/Properties'; import { gameClock } from '../core/time'; import { loadPhysics, readyPhysics } from '../runtime/physics/engine'; import { Input } from '../runtime/systems/Input'; import type { World } from '../runtime/World'; import { ui } from '../ui'; import { soundLibrary } from '../viewport/SoundLibrary'; import { saveArea } from './saves'; import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle'; import type { GameViewport } from './viewport/GameViewport'; import type { GraphEditor } from './graph/GraphEditor'; import type { LiveMachineState } from './graph/GraphCanvas'; import type { StateMachineEditor } from './statemachine/StateMachineEditor'; import type { EditorState } from './state'; /** Facts about the running world for the check scripts. */ export interface WorldSnapshot { entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record; animations: AnimationPlayer[]; }[]; /** What `global` declarations share this run – no entity carries it, so it is reported apart. */ shared: SharedState; log: string[]; } export interface PlayHost { gameView: GameViewport; /** The scene view: while a run holds the panels back, it still draws every frame it is on screen. */ viewport: { visible: boolean; redraw(): void }; graphEditor: GraphEditor; stateMachineEditor: StateMachineEditor; showGame(): void; /** Leave the game view if it is the one on screen. */ leaveGame(): void; } export class PlaySession { private run: GameRun | null = null; private readonly input = new Input(); /** Set while Play waits for the physics engine, so a second press does not queue a second run. */ private waiting = false; /** When the panels catch up with the run (#265). */ private readonly panels = new PanelClock(); /** How often the panels were brought up to date this run (test hook). */ private updates = { structure: 0, values: 0 }; constructor( private readonly state: EditorState, private readonly host: PlayHost, ) { // An object picked during a run is looked at now: the hierarchy has to list it to show it picked. state.on('selection', () => { if (this.world && this.panels.holdsStructure) this.follow(this.panels.flush()); }); } private get world(): World | null { return this.run?.world ?? null; } private get behaviors(): BehaviorSystem | null { return this.run?.behaviors ?? null; } get isPlaying(): boolean { return this.world !== null; } get isPaused(): boolean { 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; this.state.setPaused(!this.state.paused); // What you inspect while paused is exact, whatever the panels were holding back. if (this.state.paused) this.follow(this.panels.flush()); // A held note is part of the picture a pause freezes: the sounds stop where they are and carry // on mid-note, rather than playing to their end over a still image. soundLibrary.setSuspended(this.state.paused); ui().focus.blur(); this.state.setNotice(this.playNotice(), 'quiet'); } traceFor(assetId: string): Trace | null { return this.behaviors?.traceFor(assetId) ?? null; } /** Which state each running instance of a machine holds; empty when nothing is playing. */ machineStates(machineId: string): LiveMachineState[] { return this.behaviors?.machineStates(machineId) ?? []; } get time(): number { return this.world?.time ?? 0; } /** The scene the run is in – not the editor's active one, which a `Load scene` leaves alone. */ get sceneId(): string { return this.world?.sceneId ?? ''; } syncFromModel(): void { this.world?.syncFromModel(); this.behaviors?.syncFromModel(); } /** The values a Variables asset shares in this run; null when nothing is playing. */ liveVariables(assetId: string): PropertyBag | null { return this.behaviors?.liveVariables(assetId) ?? null; } play(): void { const scene = this.state.sceneToPlay(); if (!scene) return; // The editor loads the engine right after opening, so it is almost always there already; a game // without it would collide differently, so Play waits for it rather than running without (#146). const physics = readyPhysics(); if (!physics) { this.waitForPhysics(); return; } ui().focus.blur(); // A run starts at second zero, whatever the editor's previews have been counting up to. gameClock.reset(); const runtime = this.state.enterPlay(); const services: RunServices = { audio: soundLibrary, physics, clock: gameClock, saves: saveArea(runtime.id, 'play'), }; this.run = GameRun.start(runtime, scene.id, this.input, services); this.updates = { structure: 0, values: 0 }; this.input.attach(); this.host.gameView.setWorld(this.run.world); this.host.showGame(); this.state.setNotice(this.playNotice(), 'quiet'); if (this.run.world.stopRequested) this.stop(); } /** A `Load scene` swapped the world (see `GameRun.loadScene`). */ private sceneLoaded(run: GameRun): void { this.host.gameView.setWorld(run.world); this.state.setNotice(this.playNotice(), 'quiet'); if (run.world.stopRequested) this.stop(); } private waitForPhysics(): void { if (this.waiting) return; this.waiting = true; this.state.setNotice('Loading the physics engine…', 'quiet'); loadPhysics().then( () => { this.waiting = false; if (this.state.mode === 'edit') this.play(); }, (error: unknown) => { this.waiting = false; const reason = error instanceof Error ? error.message : String(error); console.error('[physics] Play needs the physics engine, which could not be loaded:', error); this.state.setNotice(`Play needs the physics engine, which could not be loaded: ${reason}`, 'error'); }, ); } 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'; } stop(): void { // The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene. gameClock.reset(); // And its sounds end with it, looping music included – nothing may outlive the world. soundLibrary.setSuspended(false); soundLibrary.stopAll(); const world = this.world; this.run = null; this.input.detach(); ui().focus.blur(); world?.destroy(); this.host.gameView.setWorld(null); this.state.exitPlay(); this.host.leaveGame(); const lastError = world && behaviorError(world); this.state.setNotice(lastError ?? '', 'error'); } /** * `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 run = this.run; if (!run) return; const world = run.world; if (this.state.paused) { // Keys and clicks that happen while paused are dropped instead of piling up – otherwise // resuming would deliver every one of them in a single frame. this.input.endFrame(); world.dropPointerEdges(); return; } const wasPaused = world.paused; world.held = this.state.heldObjectIds; const request = run.tick(step, awake); if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet'); if (request === 'stop') { this.stop(); return; } if (request === 'restart') { this.stop(); this.play(); return; } if (request === 'scene') { this.sceneLoaded(run); return; } // Mirror what the systems did into the runtime copy every frame, even while the panels wait: an // edit made during the run lands on the model, and it must not land on an old picture of it. const structure = world.syncToModel().structure; const update = this.panels.frame( panelPolicy(this.state.project.whilePlaying), performance.now(), structure, ); // A game that pauses itself is looked at like one paused from the toolbar. this.follow(world.paused && !wasPaused ? this.panels.flush() : update); } /** Brings the panels up to date as far as `update` says; the game's own picture never waits. */ private follow(update: PanelUpdate): void { if (update.structure) { this.updates.structure++; this.state.preview(); } if (update.values) { this.updates.values++; this.state.runtimeTick(); } else { // The views draw on `runtime`, and what is on screen of the game has to move every frame. if (this.host.gameView.visible) this.host.gameView.redraw(); if (this.host.viewport.visible) this.host.viewport.redraw(); } if (!update.graphs) return; if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw(); // The machine tab shows the state a run is in, so it repaints for the same reason the graph does. if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw(); } /** How often the panels caught up with this run, by kind (test hook). */ panelUpdates(): { structure: number; values: number } { return { ...this.updates }; } /** The check scripts get the run as JSON, so an object a bag holds is handed over as its name. */ snapshot(): WorldSnapshot | null { const world = this.world; if (!world) return null; return { entities: world.entities.map((e) => { const w = worldPositionOf(e); return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: namedBag(e.props), animations: structuredClone(e.animations ?? []), }; }), shared: namedShared(this.behaviors?.shared ?? emptySharedState()), log: [...world.log], }; } } function namedBag(bag: PropertyBag): Record { const out: Record = {}; for (const [key, value] of Object.entries(bag)) out[key] = namedValue(value); return out; } function namedShared(shared: SharedState): SharedState { const variables: Record = {}; for (const [id, bag] of Object.entries(shared.variables)) variables[id] = namedBag(bag) as PropertyBag; return { variables, states: shared.states }; }