/** * 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 { worldPositionOf } from '../core/graph'; import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem'; import { CollisionSystem } from '../runtime/systems/CollisionSystem'; import { Input } from '../runtime/systems/Input'; import { World } from '../runtime/World'; import { ui } from '../ui'; import type { GameViewport } from '../viewport/GameViewport'; import type { GraphEditor } from './graph/GraphEditor'; 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; }[]; log: string[]; } export interface PlayHost { gameView: GameViewport; graphEditor: GraphEditor; 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(); constructor( private readonly state: EditorState, private readonly host: PlayHost, ) {} get isPlaying(): boolean { return this.world !== null; } traceFor(assetId: string): Trace | null { return this.behaviors?.traceFor(assetId) ?? null; } get time(): number { return this.world?.time ?? 0; } syncFromModel(): void { this.world?.syncFromModel(); } play(): void { const scene = this.state.activeScene; if (!scene) return; ui().focus.blur(); const runtime = this.state.enterPlay(); const world = World.instantiate(runtime, scene.id); this.world = world; this.input.attach(); // Collisions are detected first so behaviors see this frame's enter/stay/exit events. const collisions = new CollisionSystem(); this.behaviors = new BehaviorSystem(runtime, this.input, collisions); world.addSystem(collisions); world.addSystem(this.behaviors); this.host.gameView.setWorld(world); this.host.showGame(); this.state.setNotice( world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops', ); if (world.stopRequested) this.stop(); } stop(): void { 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?.log.find((line) => line.startsWith('Error in behavior')); this.state.setNotice(lastError ?? ''); } tick(dt: number): void { const world = this.world; if (!world) return; world.tick(Math.min(dt, 0.1)); this.input.endFrame(); if (world.stopRequested) { this.stop(); return; } if (world.restartRequested) { this.stop(); this.play(); return; } // Mirror what the systems did into the runtime copy so the panels show live values. if (world.syncToModel()) this.state.preview(); this.state.runtimeTick(); if (this.host.graphEditor.visible) this.host.graphEditor.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 }, }; }), log: [...world.log], }; } }