/** Runs "run in editor" behaviors outside play mode, on a world built from the saved model, not a copy. */ import { findAsset } from '../core/model/Project'; import type { PropertyBag } from '../core/model/Properties'; import { hasEditorRun, runsInEditor } from '../core/properties/editorRun'; import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem'; import { Input } from '../runtime/systems/Input'; import { type ModelWrite, World } from '../runtime/World'; import { soundLibrary } from '../viewport/SoundLibrary'; import type { LiveMachineState } from './graph/GraphCanvas'; import { saveArea } from './saves'; import type { EditorState } from './state'; export interface EditorRunHooks { /** * Whether the keyboard and the mouse currently belong to the game rather than to the editor. * Without such a gate, typing a name would steer the game at the same time. */ focused: () => boolean; /** The world the run just built, null when it stopped. Lets the game view route clicks into it. */ onWorld: (world: World | null) => void; } export class EditorRun { private world: World | null = null; private behaviors: BehaviorSystem | null = null; private readonly input = new Input(); /** Last answer of `hooks.focused`, to let held keys go when the game view loses them. */ private hadFocus = false; /** The model changed, so *whether* anything runs is open again, not just the world's contents. */ private stale = true; /** Set while our own write travels through the listeners, so it does not count as a change. */ private writing = false; constructor( private readonly state: EditorState, private readonly hooks: EditorRunHooks, ) {} /** The model changed: a command, a load, or leaving play mode. */ invalidate(): void { if (!this.writing) this.stale = true; } /** `step` is what the global clock moved this frame – already scaled and capped. */ tick(step: number): void { if (this.state.mode !== 'edit') { // Playing runs every behavior anyway – and on the runtime copy, which this world is not on. this.stop(); this.stale = true; return; } if (this.stale) { this.stale = false; this.refresh(); } const world = this.world; if (!world) return; // A key held while the game view loses the keyboard would stay held for good, and the object // would keep walking with nobody pressing anything. const focus = this.hooks.focused(); if (focus !== this.hadFocus) { this.hadFocus = focus; this.input.releaseAll(); } world.tick(step); this.input.endFrame(); if (this.control(world)) return; const write = world.syncToModel(); this.commit(write); // A shared value belongs to no object, so a frame that only moved one writes nothing here and // the panels would never hear of it. Asking who is looking keeps that to the frames it helps. if (!write.values && !write.structure && this.showsVariables()) this.state.runtimeTick(); } /** Which state each instance of a machine holds in this run; empty when no editor run is on. */ machineStates(machineId: string): LiveMachineState[] { return this.behaviors?.machineStates(machineId) ?? []; } /** The values a Variables asset shares in this run; null when no editor run is on. */ liveVariables(assetId: string): PropertyBag | null { return this.behaviors?.liveVariables(assetId) ?? null; } private showsVariables(): boolean { const selection = this.state.selection; if (selection?.kind !== 'asset') return false; return findAsset(this.state.project, selection.assetId)?.type === 'variables'; } /** Whether a world is up – what tells the editor its picture moves on its own. */ get isRunning(): boolean { return this.world !== null; } /** Facts for the check scripts: object ids of the behaviors currently running without Play. */ debugInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } { return { running: this.world !== null, time: this.world?.time ?? 0, behaviors: this.behaviors?.runningIds() ?? [], keyboard: this.world !== null && this.hooks.focused(), }; } /** Builds, keeps or drops the world, depending on what the scene now asks for. */ private refresh(): void { const scene = this.state.activeScene; if (!scene || !hasEditorRun(this.state.project, scene)) { this.stop(); return; } const world = this.world; if (world && world.project === this.state.project && world.sceneId === scene.id) { world.syncFromModel(); this.behaviors?.syncDeclarations(); return; } this.stop(); const project = this.state.project; // Baking a container's content into the scene would be an edit nobody asked for, so it is off // here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`). const fresh = World.instantiate(project, scene.id, { bakeContainers: false }); this.behaviors = addGameSystems(fresh, project, this.input, { shouldRun: (e) => runsInEditor(e.props), audio: soundLibrary, saves: saveArea(project.id, 'editor'), }); this.world = fresh; this.hadFocus = this.hooks.focused(); this.input.attach(this.hooks.focused); this.hooks.onWorld(fresh); // `On start` already ran while the system started, and it may have failed. this.control(fresh); } /** True when the run ended: a behavior failed, stopped the game, or asked to start over. */ private control(world: World): boolean { if (world.loadSceneRequested) { // The editor run is the scene you are editing, brought to life – swapping it for another one // would mean editing a scene you are not looking at. Said once per ask, not once per frame. world.log.push(`Load scene is ignored in the editor run (${world.loadSceneRequested})`); 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; return true; } if (!world.stopRequested) return false; const error = behaviorError(world); this.stop(); this.state.setNotice(error ?? 'A behavior running in the editor stopped it.', 'error'); return true; } private commit(write: ModelWrite): void { if (!write.values && !write.structure) return; this.writing = true; this.state.runtimeEdit(); // Objects appeared or vanished: the panels have to rebuild, not just re-read their values. if (write.structure) this.state.preview(); this.writing = false; } /** Ends the run for good, sounds and world included: the editor is going away (#177). */ shutDown(): void { this.stop(); this.stale = false; } private stop(): void { if (!this.world) return; // Its sounds end with it, for the reason a played run's do – an editor run is restarted by any // edit, and a loop left behind would pile up one voice per edit. soundLibrary.stopAll(); this.world.destroy(); this.world = null; this.behaviors = null; this.input.detach(); this.hooks.onWorld(null); } }