177 lines
6.9 KiB
TypeScript
177 lines
6.9 KiB
TypeScript
/** 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 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,
|
||
});
|
||
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.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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|