/** * 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 { AudioSwitch } from '../runtime/audioSwitch'; import { GameRun, type RunServices } from '../runtime/GameRun'; import { AnimationSystem } from '../runtime/systems/AnimationSystem'; 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 { VIDEO_WAIT_MS } from '../core/model/video'; import { frameWithin, gameClock } from '../core/time'; import { loadPhysics, readyPhysics } from '../runtime/physics/engine'; import { Input } from '../runtime/systems/Input'; import type { World } from '../runtime/World'; import { onFrame, ui } from '../ui'; import { soundLibrary } from '../viewport/SoundLibrary'; import { GameRecorder } from '../viewport/GameRecorder'; import { consoleLine, type MadeRecording } from '../viewport/RecordingTake'; import type { RecordingUploadOptions } from '../core/recording/recording'; import { videoLibrary } from '../viewport/VideoLibrary'; import { FrameMeter, type FrameTimes } from './frameMeter'; 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'; import { stopPreviews } from './music/transport'; /** 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; /** A recording of the game view is made (#405): it goes up, and into the project as the node asked. */ recordingSaved(recording: MadeRecording, options: RecordingUploadOptions): 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 }; /** Where the frames of this run go (#275). */ private readonly meter = new FrameMeter(); /** * Until when the run waits at its start for the Game tab's driven videos (#404), by * `performance.now()`; null once it goes. Its players are opened only at Play, and the first * frames of a cut showed white without them. */ private videoWait: number | null = null; /** Records the game view for `Start recording` (#405); one for the editor, so it outlives a restart. */ private readonly recorder: GameRecorder; /** The sound of the run, diverted while a recording not in real time goes (#423). */ private readonly audio = new AudioSwitch(soundLibrary); /** What a recording not in real time asked the run to move by this frame, see `recordingStep`. */ private recordStep: number | null = null; /** When the notice last told how far such a recording has got, by `performance.now()`, and whether it did. */ private toldPace = 0; private toldSteady = false; /** `step` asked for a frame; `stepping` is set for the frame that takes it. */ private stepAsked = false; private stepping = false; /** The object whose `Break` node paused the run, until it is resumed or stepped on. */ private breakAt: string | null = null; /** The time the agent wants the run held at (#435), on the world it named; a `Load scene` calls it off. */ private pausing: { world: World; time: number } | null = null; constructor( private readonly state: EditorState, private readonly host: PlayHost, ) { this.recorder = new GameRecorder({ view: () => host.gameView, log: (line, level) => { this.world?.log.push(line); consoleLine(line, level); }, saved: (recording, options) => host.recordingSaved(recording, options), audio: this.audio, }); // 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; } /** * Held at its start until the Game tab has a picture of its driven videos (#404), and while a * recording sets up its encoders. */ get isWaiting(): boolean { return this.videoWait !== null || this.recorder.isStarting || this.recordStep === 0; } /** * Called before the clock moves: the step a recording not in real time (#423) moves the run by * this frame, 0 while it holds the run; null when the run keeps the frame's own time. */ recordingStep(): number | null { this.recordStep = this.world && !this.state.paused && this.videoWait === null ? this.recorder.step() : null; return this.recordStep; } /** Pause or resume; before Play it switches whether Play starts paused. */ togglePause(): void { if (!this.world) { if (this.state.mode === 'edit') this.state.startPaused = !this.state.startPaused; return; } this.state.setPaused(!this.state.paused); this.breakAt = null; this.pausing = null; this.applyPause(true); ui().focus.blur(); } /** Holds the run once its time reaches `time` (#435), as if the pause button had been pressed; null calls it off. */ pauseAt(time: number | null): void { this.pausing = time === null || !this.world ? null : { world: this.world, time }; } /** Called before the clock moves: the frame cut short so the run does not go past the time `pauseAt` named. */ frameBeforePause(dt: number, scale: number): number { const pausing = this.pausing; if (!pausing || pausing.world !== this.world || this.state.paused || this.isGamePaused || this.isWaiting) return dt; return frameWithin(dt, scale, pausing.time - pausing.world.time); } /** Lets a paused run on by exactly one frame, at the next tick; does nothing unless paused. */ step(): void { if (this.isPaused) this.stepAsked = true; } /** * Called before the clock moves: true when this frame is the one `step` asked for. It then moves * the run by one fixed step, whatever the frame took, and stays paused afterwards. */ beginFrame(): boolean { this.stepping = this.stepAsked && this.isPaused; this.stepAsked = false; if (this.stepping) this.breakAt = null; return this.stepping; } /** Puts sounds, videos, the recording and the panels in line with the toolbar's pause. */ private applyPause(flush: boolean): void { const { paused } = this.state; // What you inspect while paused is exact, whatever the panels were holding back. if (paused && flush) 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(paused); videoLibrary.setPaused(paused); this.recorder.setPaused(paused); 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 run as the agent drives and reads it (#429); null when nothing is playing. */ agentRun(): { world: World; input: Input; shared: SharedState } | null { const world = this.world; return world ? { world, input: this.input, shared: this.behaviors?.shared ?? emptySharedState() } : null; } /** 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; } /** `paused`: whether the run starts held; a `Restart game` keeps what the run had instead. */ play(paused = this.state.startPaused): 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(); stopPreviews(); // 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: this.audio, physics, clock: gameClock, saves: saveArea(runtime.id, 'play'), recorder: this.recorder, pictures: { missing: (id) => this.host.gameView.missesVideoOf(id) }, breakpoint: (where) => (this.breakAt ??= where), }; this.run = GameRun.start(runtime, scene.id, this.input, services); videoLibrary.startRun(); this.updates = { structure: 0, values: 0 }; this.meter.reset(); this.timeSystems(this.run); this.input.attach(); this.host.gameView.setWorld(this.run.world); this.host.showGame(); this.waitForVideos(); this.recorder.prepare(); if (paused) { this.state.setPaused(true); this.applyPause(false); } else 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.timeSystems(run); this.host.gameView.setWorld(run.world); this.waitForVideos(); 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'); }, ); } /** The Game tab has drawn the new world once by now, which opened the players it shows. */ private waitForVideos(): void { this.videoWait = this.host.gameView.videosLoading() ? performance.now() + VIDEO_WAIT_MS : null; } /** Whether the run still waits for its videos; the frame it stops waiting, the notice says so. */ private waitsForVideos(): boolean { if (this.videoWait === null) return false; if (performance.now() < this.videoWait && this.host.gameView.videosLoading()) return true; this.videoWait = null; this.state.setNotice(this.playNotice(), 'quiet'); return false; } /** While a recording not in real time goes (#423), the notice tells how far it has got, twice a second. */ private tellPace(): void { const now = performance.now(); const steady = this.recordStep !== null; if (steady === this.toldSteady && (!steady || now - this.toldPace < 500)) return; this.toldSteady = steady; this.toldPace = now; this.state.setNotice(this.playNotice(), 'quiet'); } private playNotice(): string { if (this.videoWait !== null) return 'Loading the videos… · Esc stops'; const steady = this.recorder.steadyPace(); if (steady && !this.state.paused) return ( `Recording "${steady.name}" not in real time: ${steady.seconds.toFixed(1)} s of video, ` + `${steady.speed.toFixed(2)}x real time · Esc stops` ); if (this.state.paused) { const why = this.breakAt === null ? 'Paused' : `Break in ${this.breakAt}`; return `${why} · Ctrl+Enter resumes · Ctrl+Shift+Enter steps · 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'; } /** `restarting`: a `Restart game` plays on at once, and a recording goes on with it. */ stop(restarting = false): void { if (!restarting) { this.recorder.end(); this.recorder.setPaused(false); } // 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); this.audio.stop(''); this.recordStep = null; videoLibrary.endRun(); this.videoWait = null; this.stepAsked = false; this.breakAt = null; this.pausing = null; 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; this.tellPace(); if ( (this.state.paused && !this.stepping) || this.waitsForVideos() || this.recorder.isStarting || this.recordStep === 0 ) { // 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') { const held = this.state.paused || this.breakAt !== null; this.stop(true); this.play(held); return; } if (request === 'scene') { this.sceneLoaded(run); this.pauseAtBreak(); 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, and so is a step. this.follow((world.paused && !wasPaused) || this.stepping ? this.panels.flush() : update); this.pauseAtBreak(); this.pauseWhenDue(); } /** The run reached the time `pauseAt` named: it holds there. A pause that came first calls it off. */ private pauseWhenDue(): void { const pausing = this.pausing; if (!pausing) return; if (pausing.world !== this.world || this.state.paused) { this.pausing = null; return; } if (pausing.world.time < pausing.time - 1e-9) return; this.pausing = null; this.state.setPaused(true); this.applyPause(true); } /** * A `Break` node ran this frame: the run holds as if the pause button had been pressed. A step * stays held anyway, and the notice says whether it ran into a break. */ private pauseAtBreak(): void { if (this.state.paused) { if (this.stepping) this.state.setNotice(this.playNotice(), 'quiet'); } else if (this.breakAt !== null) { this.state.setPaused(true); this.applyPause(true); } } /** * Called once everything that draws the game view this frame is queued: a recording takes the * frame after all of it, behind another tab too. */ recordFrame(): void { if (!this.recorder.isRecording || !this.world) return; onFrame('recording', () => { if (!this.host.gameView.visible) this.host.gameView.redrawHidden(); this.recorder.capture(); }); } /** What became of the recordings (#405, test hook). */ recordingInfo(): ReturnType { return this.recorder.info(); } /** 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(); } /** The world tells the meter what each system took; a `Load scene` brings a world of its own. */ private timeSystems(run: GameRun): void { run.world.timeSystem = (system, ms) => { const area = system === run.behaviors ? 'behaviors' : system instanceof AnimationSystem ? 'animation' : 'physics'; this.meter.addSystem(area, ms); }; } /** * Closes the editor's frame for the meter (see `FrameMeter.endFrame`); true when a new average is * ready. Nothing is measured while nothing plays. */ measureFrame(dt: number, busy: number, drawn: number, rendered: number): boolean { return this.isPlaying && this.meter.endFrame(dt, busy, drawn, rendered); } /** How many entities run and where the frames went lately; null while nothing plays. */ performance(): { entities: number; times: FrameTimes | null } | null { const world = this.world; return world ? { entities: world.entities.length, times: this.meter.average } : null; } /** 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 }; }