Editing while playing: play runs on a runtime copy, new Game tab (Scene | Game | Graph)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 16:34:26 +02:00
1 parent cb8885359f
commit c3a533c4e5
14 files changed
+408 -118

No files matched your search

+47 -34
View File
@@ -21,7 +21,7 @@ import { EditorState } from './state';
const API_ENDPOINT = '/game-maker/api/index.php';
type CentreTab = 'scene' | 'graph';
type CentreTab = 'scene' | 'graph' | 'game';
/** Composes panels, viewport, graph editor and runtime into the one application that edits and plays. */
export async function startEditor(app: Application, root: VStack): Promise<void> {
@@ -37,7 +37,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
* inspect state. Harmless in production (read-only access).
*/
function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
const { viewport, graphEditor } = editor;
const { viewport, gameView, graphEditor } = editor;
const viewportCenter = () => {
const p = viewport.getGlobalPosition();
return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 };
@@ -64,6 +64,7 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
locate,
viewportCenter,
viewportInfo: () => viewport.debugInfo(),
gameInfo: () => gameView.debugInfo(),
layerOf: (id: string) => viewport.layerOf(id),
drawOrder: () => viewport.drawOrder(),
graph: {
@@ -84,7 +85,10 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
}
class Editor {
/** Scene tab: the editable model view (live positions while playing). */
readonly viewport: Viewport;
/** Game tab: the running world through its camera. */
readonly gameView: Viewport;
readonly graphEditor: GraphEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
@@ -116,6 +120,7 @@ class Editor {
this.workspace = new HStack();
this.viewport = new Viewport(state);
this.gameView = new Viewport(state, { game: true });
this.graphEditor = new GraphEditor(state);
this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null;
this.graphEditor.timeProvider = () => this.world?.time ?? 0;
@@ -125,20 +130,24 @@ class Editor {
this.leftColumn.add(this.hierarchy, 1);
this.inspector = new Inspector(state);
// Centre: a tab strip (Scene | Graph) above the viewport or the graph editor.
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the graph editor or the game view.
this.centre = new VStack();
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
tabs.minHeight = Theme.controlHeight + 8;
this.tabButtons = {
scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }),
graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }),
game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }),
};
tabs.add(this.tabButtons.scene);
tabs.add(this.tabButtons.game);
tabs.add(this.tabButtons.graph);
this.centre.add(tabs);
this.centre.add(this.viewport, 1);
this.centre.add(this.graphEditor, 1);
this.centre.add(this.gameView, 1);
this.graphEditor.visible = false;
this.gameView.visible = false;
this.tabButtons.scene.setToggled(true);
this.leftColumn.minWidth = this.leftWidth;
@@ -164,19 +173,20 @@ class Editor {
attachAutosave(state, store);
this.installShortcuts();
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
state.on('mode', () => this.applyMode());
// Selecting a graph node switches to the graph; selecting a behavior does not force it.
state.on('selection', () => {
if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
});
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
state.on('project', () => this.world?.syncFromModel());
}
showTab(tab: CentreTab): void {
this.tab = tab;
this.viewport.visible = tab === 'scene';
this.graphEditor.visible = tab === 'graph';
this.tabButtons.scene.setToggled(tab === 'scene');
this.tabButtons.graph.setToggled(tab === 'graph');
this.gameView.visible = tab === 'game';
for (const [key, button] of Object.entries(this.tabButtons)) button.setToggled(key === tab);
this.centre.relayout();
ui().focus.validate();
}
@@ -199,6 +209,7 @@ class Editor {
}
private async open(id: string): Promise<void> {
if (this.world) this.stop();
const loaded = await this.store.load(id);
this.state.loadProject(loaded.project, loaded.revision);
const url = new URL(location.href);
@@ -238,6 +249,7 @@ class Editor {
}
private async deleteCurrentProject(): Promise<void> {
if (this.world) this.stop();
await this.store.delete(this.state.project.id);
const list = await this.store.list();
if (list[0]) await this.open(list[0].id);
@@ -257,47 +269,59 @@ class Editor {
// --- play mode -------------------------------------------------------------------------
/**
* Play: the state switches to a runtime copy of the project, the world is built from that
* copy and shown in the Game tab. All panels stay open and edit the copy – the world picks
* those edits up immediately, and nothing reaches the saved model.
*/
private play(): void {
const scene = this.state.activeScene;
if (!scene) return;
ui().focus.blur();
this.world = World.instantiate(this.state.project, scene.id);
const runtime = this.state.enterPlay();
const world = World.instantiate(runtime, scene.id);
this.world = world;
this.input.attach();
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
const collisions = new CollisionSystem();
this.behaviors = new BehaviorSystem(this.state.project, this.input, collisions);
this.world.addSystem(collisions);
this.world.addSystem(this.behaviors);
this.viewport.setWorld(this.world);
this.state.setMode('play');
// Play always starts on the scene; the graph tab stays reachable for live tracing.
this.showTab('scene');
this.state.setNotice(this.world.firstOfType('camera') ? 'Playing · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
if (this.world.stopRequested) this.stop();
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
world.addSystem(collisions);
world.addSystem(this.behaviors);
this.gameView.setWorld(world);
this.showTab('game');
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
if (world.stopRequested) this.stop();
}
/** Stop: the world and the runtime copy are discarded; the saved model returns untouched. */
private stop(): void {
const world = this.world;
this.world = null;
this.behaviors = null;
this.input.detach();
ui().focus.blur();
world?.destroy();
this.viewport.setWorld(null);
this.state.setMode('edit');
this.gameView.setWorld(null);
this.state.exitPlay();
if (this.tab === 'game') this.showTab('scene');
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
this.state.setNotice(lastError ?? '');
}
private tick(dt: number): void {
this.measureFps(dt);
if (!this.world) return;
this.world.tick(Math.min(dt, 0.1));
const world = this.world;
if (!world) return;
world.tick(Math.min(dt, 0.1));
this.input.endFrame();
if (this.world.stopRequested) {
if (world.stopRequested) {
this.stop();
return;
}
this.viewport.redraw();
// Mirror what the systems did into the runtime copy so the panels show live values.
// A destroyed entity removes its object – that is a structural change the lists must see.
if (world.syncToModel()) this.state.preview();
this.state.runtimeTick();
if (this.graphEditor.visible) this.graphEditor.redraw();
}
@@ -325,16 +349,6 @@ class Editor {
};
}
private applyMode(): void {
const editing = this.state.mode === 'edit';
this.leftColumn.visible = editing;
this.inspector.visible = editing;
this.leftSplitter.visible = editing;
this.rightSplitter.visible = editing;
this.workspace.relayout();
ui().focus.validate();
}
// --- keyboard --------------------------------------------------------------------------
private installShortcuts(): void {
@@ -344,7 +358,6 @@ class Editor {
this.stop();
return true;
}
if (this.state.mode !== 'edit') return false;
if (mod && event.key.toLowerCase() === 'z') {
if (event.shiftKey) this.state.redo();
else this.state.undo();
@@ -367,7 +380,7 @@ class Editor {
}
return s !== null;
}
if (mod && event.key === 'Enter') {
if (mod && event.key === 'Enter' && this.state.mode === 'edit') {
this.play();
return true;
}