Files
feuerwerk/src/editor/EditorRun.ts
T
adminandClaude Opus 5 41772e53f7
game-maker / suite (push) Successful in 8m21s
game-maker / gate (push) Failing after 3h5m52s
Variables assets can keep their values from one run to the next (#166)
A game needs a high score or an unlocked level to survive the end of
a run. As agreed in the issue there are no save or load nodes. A
Variables asset gets a switch instead, and the existing Variables
nodes read and write the values as before.

- A "Persistent" checkbox in the Variables inspector. Toggling it is
  one undo step, and absent means not persistent.
- The behavior system loads the saved values at start and writes them
  after every frame that changed one, so a closed tab loses nothing.
- Values live in localStorage per project, not in the project file.
  Another browser or person starts fresh.
- Play and "Run in editor" save apart, so trying a behavior in the
  editor leaves the Play save alone.
- Saved values are read through the current declaration. A removed
  variable drops out, a new one starts from its default, and a changed
  kind is converted.
- "Clear saved values" in the inspector clears this asset in both
  areas. The main menu gets "Clear saved data" for the whole project.
  Clearing takes effect from the next run on.

Decisions:
- The switch covers the whole asset, not single variables. Mixing
  both means two assets.
- A changed default in the inspector shows only after the save is
  cleared. That is what persistent means.

Findings:
- The first e2e run died at "debug hook present" right after a Vulkan
  error from Chrome. The second run passed, so it was a GPU flake at
  start-up.

Tests:
- ./check.sh: 140 files, 2070 tests, exit 0 (before 138 / 2055).
- 15 new unit tests in tests/core/saved-values.test.ts and
  tests/editor/saves.test.ts.
- GM_STEPS=32,46 fresh: ALL PASSED, 24 checks. Six new checks in
  module 32 go through the UI. Module 46 knows the new menu entry.
- Counter-checks both fail as expected. Loading switched off: 2 unit
  tests and 2 e2e checks fail. Editor run writing into the Play area:
  1 e2e check fails.
- Check total 1278 (1272 measured by CI at bbbf341, plus 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:23:57 +02:00

192 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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);
}
}