/** * 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 { addGameSystems, 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 { World } from '../runtime/World'; import { ui } from '../ui'; import { soundLibrary } from '../viewport/SoundLibrary'; 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; graphEditor: GraphEditor; stateMachineEditor: StateMachineEditor; showGame(): void; /** Leave the game view if it is the one on screen. */ leaveGame(): void; } export class PlaySession { private world: World | null = null; private behaviors: BehaviorSystem | 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; constructor( private readonly state: EditorState, private readonly host: PlayHost, ) {} get isPlaying(): boolean { return this.world !== null; } get isPaused(): boolean { return this.world !== null && this.state.paused; } /** Pause or resume; does nothing while not playing. */ togglePause(): void { if (!this.world) return; this.state.setPaused(!this.state.paused); // 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?.syncDeclarations(); } /** 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 world = World.instantiate(runtime, scene.id); this.world = world; this.input.attach(); this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, physics }); this.host.gameView.setWorld(world); this.host.showGame(); this.state.setNotice(this.playNotice(), 'quiet'); if (world.stopRequested) this.stop(); } /** * Swaps the running scene for another one, inside the same run: the world is torn down and built * again, while what belongs to the run – the clock, the shared variables, the global machines – * carries on. The sounds of the old scene end with it, because the behaviors playing them are * destroyed; a voice that was started detached is nobody's and plays on. */ private loadScene(sceneId: string): void { const old = this.world; const runtime = this.state.runtimeProject; if (!old || !runtime) return; const carried = this.behaviors?.shared ?? null; const physics = readyPhysics(); old.destroy(); const world = World.instantiate(runtime, sceneId); this.world = world; this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, carried, physics }); this.host.gameView.setWorld(world); this.state.setNotice(this.playNotice(), 'quiet'); if (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'; 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.world = null; this.behaviors = 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. */ tick(step: number): void { const world = this.world; if (!world) return; 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; } world.tick(step); this.input.endFrame(); if (world.stopRequested) { this.stop(); return; } if (world.restartRequested) { this.stop(); this.play(); return; } if (world.loadSceneRequested) { this.loadScene(world.loadSceneRequested); return; } // Mirror what the systems did into the runtime copy so the panels show live values. if (world.syncToModel().structure) this.state.preview(); this.state.runtimeTick(); 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(); } 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: { ...e.props }, animations: structuredClone(e.animations ?? []), }; }), shared: this.behaviors?.shared ?? emptySharedState(), log: [...world.log], }; } }