Which of two events ran first depended on hidden lists: the order of the behavior instances and the order in which nodes were created. A graph had no way to say that one On update must run before another, and an On update of one behavior could run before a key event of another. - A frame now runs event kind by event kind over all behaviors: On disable and On enable, key down, key up, key held, timer, collision enter, stay and exit, animation finished, click, mouse wheel and at last On update. - All 17 event nodes have a field Order, a whole number, default 0, negative allowed. Within one kind a lower Order runs earlier, no matter which behavior holds the node. - On a tie the current hierarchy decides from top to bottom, then the order in which the nodes were created. The old rule that a behavior enabled later goes to the end is gone. - The node shows Order: 5 only when the value is not 0, so existing graphs look the same. - The rule lives as pure functions in core/graph/eventOrder.ts. The interpreter can now fire a single event node. Changes in existing projects: - An object destroyed in a frame gets no further event in that frame. On disable and On destroy still come at the end of the frame. - New instances get all their On start first, then all On enable. Teardown runs all On disable first, then all On destroy. - State events follow right after the event kind that caused them. - Four unit tests had fixed the old order and are adjusted: two in behavior-events, one in physics-system, one in key-repeat. Tests: - ./check.sh: 191 files, 2784 tests, CHECK EXIT 0. - GM_STEPS=1-6: ALL PASSED, 280 checks. - Full e2e run (local): ALL PASSED, 1551 checks, measured. - tests/core/event-order.test.ts: 11 tests for the rule, the field on all 17 nodes, order across behaviors, key before update, two nodes in one graph, On start, hierarchy after late enable and reorder at runtime, state events, save and load. - e2e 6z-viii, 2 checks with real mouse and keyboard: Order -1 set in the inspector shows on the node, and a run logs B, A although B is lower in the hierarchy. - Counter-checks: 5 in unit tests and 1 in the browser, all fail as expected. The originals were restored and checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
7.6 KiB
TypeScript
194 lines
7.6 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 { 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.
|
||
// A set tile writes nothing either, and the maps on screen have to show it (#135).
|
||
const tiles = world.takeTileChange();
|
||
if (!write.values && !write.structure && (tiles || 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?.syncFromModel();
|
||
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);
|
||
}
|
||
}
|