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

+18 -3
View File
@@ -78,7 +78,12 @@ export class Editor implements PlayHost {
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
this.editorRun = new EditorRun(state);
this.editorRun = new EditorRun(state, {
// The game view is the one place where no key means anything to the editor, so that is where
// a behavior may have them – unless a widget in it is focused and wants them itself.
focused: () => this.tab === 'game' && ui().focus.focused === null,
onWorld: (world) => this.gameView.setEditorWorld(world),
});
this.projects = new ProjectSession(state, store, () => {
if (this.play.isPlaying) this.play.stop();
});
@@ -90,6 +95,7 @@ export class Editor implements PlayHost {
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
});
root.add(this.toolbar);
@@ -152,7 +158,11 @@ export class Editor implements PlayHost {
root.resize(app.screen.width, app.screen.height);
attachAutosave(state, store);
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
installShortcuts(state, {
play: () => this.play.play(),
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
});
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// A node, a frame and a template object exist only in their own tab, so selecting one
// switches to it; selecting the behavior, texture or container asset itself does not.
@@ -232,7 +242,12 @@ export class Editor implements PlayHost {
}
/** What is running without Play, for the check scripts. */
editorRunInfo(): { running: boolean; time: number; behaviors: string[] } {
editorRunInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } {
return this.editorRun.debugInfo();
}
/** Play state for the check scripts; `time` is the world clock a pause has to freeze. */
playInfo(): { playing: boolean; paused: boolean; time: number } {
return { playing: this.play.isPlaying, paused: this.play.isPaused, time: this.play.time };
}
}
+35 -6
View File
@@ -3,7 +3,7 @@
*
* The one thing that makes this different from Play: the world is built from the saved model, not
* from a copy. What such a behavior moves or writes is a real edit – it is saved and it is not on
* the undo stack. The keyboard and the mouse stay with the editor, so input nodes never fire here.
* the undo stack.
*/
import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
@@ -12,17 +12,31 @@ import { Input } from '../runtime/systems/Input';
import { type ModelWrite, World } from '../runtime/World';
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;
/** Never attached: the editor owns the keyboard, so `Is key down` is always false in here. */
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) {}
constructor(
private readonly state: EditorState,
private readonly hooks: EditorRunHooks,
) {}
/** The model changed: a command, a load, or leaving play mode. */
invalidate(): void {
@@ -42,17 +56,26 @@ export class EditorRun {
}
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(Math.min(dt, 0.1));
this.input.endFrame();
if (this.control(world)) return;
this.commit(world.syncToModel());
}
/** Facts for the check scripts: object ids of the behaviors currently running without Play. */
debugInfo(): { running: boolean; time: number; behaviors: string[] } {
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(),
};
}
@@ -71,13 +94,16 @@ export class EditorRun {
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 – which is why a behavior inside a placed container does not run outside play mode.
// here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`).
const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
const collisions = new CollisionSystem();
this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props));
fresh.addSystem(collisions);
fresh.addSystem(this.behaviors);
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);
}
@@ -106,8 +132,11 @@ export class EditorRun {
}
private stop(): void {
this.world?.destroy();
if (!this.world) return;
this.world.destroy();
this.world = null;
this.behaviors = null;
this.input.detach();
this.hooks.onWorld(null);
}
}
+27 -5
View File
@@ -51,6 +51,18 @@ export class PlaySession {
return this.world !== null;
}
get isPaused(): boolean {
return this.world !== null && this.state.paused;
}
/** Pause or resume; does nothing while not playing. */
togglePause(): void {
if (!this.world) return;
this.state.setPaused(!this.state.paused);
ui().focus.blur();
this.state.setNotice(this.playNotice());
}
traceFor(assetId: string): Trace | null {
return this.behaviors?.traceFor(assetId) ?? null;
}
@@ -78,14 +90,17 @@ export class PlaySession {
world.addSystem(this.behaviors);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(
world.activeCamera()
? 'Playing · edits are not saved · Esc stops'
: 'No camera is switched on – nothing is rendered · Esc stops',
);
this.state.setNotice(this.playNotice());
if (world.stopRequested) this.stop();
}
private playNotice(): string {
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops';
return this.world?.activeCamera()
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
: 'No camera is switched on – nothing is rendered · Esc stops';
}
stop(): void {
const world = this.world;
this.world = null;
@@ -103,6 +118,13 @@ export class PlaySession {
tick(dt: number): void {
const world = this.world;
if (!world) return;
if (this.state.paused) {
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
// resuming would deliver every one of them in a single frame.
this.input.endFrame();
world.dropPointerEdges();
return;
}
world.tick(Math.min(dt, 0.1));
this.input.endFrame();
if (world.stopRequested) {
+2
View File
@@ -178,6 +178,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
objectRect: (id: string) => viewport.objectRect(id),
/** The same for the Game tab – through the camera, so what the player sees. */
gameScreen: (id: string) => gameView.objectScreen(id),
gameCenter: () => centreOf(gameView),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) =>
@@ -213,6 +214,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
dropPreview: () => containerEditor.dropInfo(),
},
world: () => editor.worldSnapshot(),
playInfo: () => editor.playInfo(),
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
editorRun: () => editor.editorRunInfo(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
+11
View File
@@ -12,6 +12,8 @@ export interface ToolbarActions {
openSceneMenu(anchor: Widget): void;
/** Play in edit mode, Stop in play mode. */
togglePlay(): void;
/** Hold or resume the running world; only reachable in play mode. */
togglePause(): void;
}
const SAVE_LABELS: Record<SaveStatus, string> = {
@@ -30,6 +32,7 @@ export class Toolbar extends HStack {
private readonly undoButton: Button;
private readonly redoButton: Button;
private readonly playButton: Button;
private readonly pauseButton: Button;
private readonly status: Label;
private readonly notice: Label;
private readonly fps: Label;
@@ -75,6 +78,11 @@ export class Toolbar extends HStack {
onClick: () => actions.togglePlay(),
});
this.add(this.playButton);
this.pauseButton = new Button('❚❚ Pause', {
width: 110,
onClick: () => actions.togglePause(),
});
this.add(this.pauseButton);
this.add(new Spacer(), 1);
this.notice = new Label('', { style: 'small', align: 'right' });
@@ -114,6 +122,9 @@ export class Toolbar extends HStack {
this.playButton.variant = editing ? 'primary' : 'danger';
this.playButton.setEnabled(scene !== undefined);
this.playButton.invalidate();
this.pauseButton.setText(state.paused ? '▶ Resume' : '❚❚ Pause');
this.pauseButton.setEnabled(!editing);
this.pauseButton.invalidate();
this.projectName.setEnabled(editing);
this.sceneButton.setEnabled(editing);
this.status.setText(SAVE_LABELS[state.saveStatus]);
+6 -2
View File
@@ -6,6 +6,7 @@ import type { EditorState } from './state';
export interface ShortcutActions {
play(): void;
stop(): void;
togglePause(): void;
}
/**
@@ -42,8 +43,11 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
return s !== null;
}
if (mod && event.key === 'Enter' && state.mode === 'edit') {
actions.play();
// Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from
// the game, which owns the keyboard while playing.
if (mod && event.key === 'Enter') {
if (state.mode === 'edit') actions.play();
else actions.togglePause();
return true;
}
return false;
+16
View File
@@ -76,6 +76,12 @@ export class EditorState {
commands = new CommandStack(this.project);
selection: Selection = null;
mode: EditorMode = 'edit';
/**
* Play is on but the world is not being ticked. A sub-state of `mode === 'play'` rather than a
* third mode: everything that distinguishes playing from editing (the runtime copy, the
* keyboard, edits not being saved) is just as true while paused.
*/
paused = false;
saveStatus: SaveStatus = 'saved';
/** Last user-facing message (errors, hints). */
notice = '';
@@ -101,6 +107,7 @@ export class EditorState {
loadProject(project: Project, revision: number): void {
this.edit = null;
this.mode = 'edit';
this.paused = false;
this.project = project;
this.revision = revision;
this.commands = new CommandStack(project);
@@ -124,11 +131,19 @@ export class EditorState {
this.project = copy;
this.commands = new CommandStack(copy);
this.mode = 'play';
this.paused = false;
this.emit('mode');
this.emit('project');
return copy;
}
/** Ignored outside play mode. */
setPaused(paused: boolean): void {
if (this.mode !== 'play' || this.paused === paused) return;
this.paused = paused;
this.emit('mode');
}
/** Leaves play mode: the runtime copy is discarded, the saved model comes back unchanged. */
exitPlay(): void {
if (this.mode !== 'play') return;
@@ -138,6 +153,7 @@ export class EditorState {
this.edit = null;
}
this.mode = 'edit';
this.paused = false;
this.emit('mode');
this.emit('project');
this.validateSelection();