Hold a running game, and let the editor run reach into containers

Pause is a sub-state of play, not a third mode: the toolbar button and
Ctrl+Enter stop ticking the world, the clock included. A held frame still
drops the input edges, so resuming does not fire a pause worth of keys in
one go.

The editor run missed the case it is most wanted for. hasEditorRun only
looked at the stored scene objects, so a behavior inside a placed
container never started one; the world now builds from resolveObjects and
writes back through objectRef, keeping the move as an instance edit
instead of baking the instance out.

Keys and clicks go to the run while the Game tab is on screen, where no
key means anything to the editor, and are handed back on leaving it -
held keys released, so nothing keeps walking unpressed.
This commit is contained in:
root committed 2026-09-09 18:16:28 +02:00
1 parent 8f31afac37
commit 9c4148dc1b
19 files changed
+838 -78

No files matched your search

+21 -6
View File
@@ -1,17 +1,22 @@
/**
* Keyboard state tracker for play mode. Records which keys are held and which went down /
* Keyboard state tracker for a running world. Records which keys are held and which went down /
* up since the last `endFrame()`, so systems can react to edges as well as to held keys.
*/
export class Input {
private readonly down = new Set<string>();
private readonly pressed = new Set<string>();
private readonly released = new Set<string>();
/** See `attach`: while this answers false the keys belong to someone else. */
private accepts: () => boolean = () => true;
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
if (!this.accepts()) return;
this.press(e.key);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => this.release(e.key);
private readonly onKeyUp = (e: KeyboardEvent) => {
if (this.accepts()) this.release(e.key);
};
/** Losing the window releases every key, so nothing stays "held" behind the user's back. */
private readonly onBlur = () => this.releaseAll();
@@ -32,16 +37,26 @@ export class Input {
this.down.clear();
}
attach(): void {
/**
* Starts listening on the window. `accepts` decides per event whether the key belongs to this
* world at all, so a rejected key keeps its default and reaches the editor instead. A gate that
* closes while a key is held leaves it held: the caller has to `releaseAll()` on the change.
*/
attach(accepts: () => boolean = () => true): void {
this.accepts = accepts;
if (typeof window === 'undefined') return; // headless: tests drive the state machine directly
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp);
window.addEventListener('blur', this.onBlur);
}
detach(): void {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
}
this.accepts = () => true;
this.down.clear();
this.pressed.clear();
this.released.clear();