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

+58 -6
View File
@@ -2,6 +2,7 @@ import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { SceneAsset } from '../core/model/Asset';
import { createEmptyProject, findScene, type Project } from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize';
export type Selection =
| { kind: 'asset'; assetId: string }
@@ -19,7 +20,9 @@ export type EditorEvent =
| 'selection'
| 'mode'
| 'save'
| 'notice';
| 'notice'
/** The running world advanced a frame (play mode): values may have changed without a command. */
| 'runtime';
type Listener = () => void;
@@ -27,6 +30,10 @@ type Listener = () => void;
* Single source of truth for the editor: the project, undo stack, selection, mode and
* save status. Panels subscribe to coarse events and refresh themselves; there is no
* framework and no virtual DOM.
*
* **Play mode edits a copy.** `enterPlay()` swaps `project` for a deep clone (same ids) with
* its own undo stack; every panel and command keeps working on it, the running world mirrors
* it, and `exitPlay()` throws the copy away and restores the saved model untouched.
*/
export class EditorState {
project: Project = createEmptyProject('', 'Untitled');
@@ -37,6 +44,8 @@ export class EditorState {
saveStatus: SaveStatus = 'saved';
/** Last user-facing message (errors, hints). */
notice = '';
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
private edit: { project: Project; commands: CommandStack } | null = null;
private readonly listeners = new Map<EditorEvent, Set<Listener>>();
@@ -54,6 +63,8 @@ export class EditorState {
// --- project lifecycle ---------------------------------------------------------------
loadProject(project: Project, revision: number): void {
this.edit = null;
this.mode = 'edit';
this.project = project;
this.revision = revision;
this.commands = new CommandStack(project);
@@ -63,6 +74,39 @@ export class EditorState {
this.emit('project');
this.emit('selection');
this.emit('save');
this.emit('mode');
}
/**
* Switches to play mode: from now on `project` is a runtime copy of the model. Returns
* that copy (the world is instantiated from it). Selection is kept – ids are identical.
*/
enterPlay(): Project {
if (this.mode === 'play') return this.project;
const copy = deserializeProject(serializeProject(this.project), this.project.id);
this.edit = { project: this.project, commands: this.commands };
this.project = copy;
this.commands = new CommandStack(copy);
this.mode = 'play';
this.emit('mode');
this.emit('project');
return copy;
}
/** Leaves play mode: the runtime copy is discarded, the saved model comes back unchanged. */
exitPlay(): void {
if (this.mode !== 'play') return;
if (this.edit) {
this.project = this.edit.project;
this.commands = this.edit.commands;
this.edit = null;
}
this.mode = 'edit';
this.emit('mode');
this.emit('project');
this.validateSelection();
// A save postponed while playing runs now.
this.emit('save');
}
get activeScene(): SceneAsset | undefined {
@@ -71,9 +115,11 @@ export class EditorState {
// --- editing -------------------------------------------------------------------------
/** Runs a command through the undo stack. Returns false (and sets a notice) when invalid. */
/**
* Runs a command through the undo stack. Returns false (and sets a notice) when invalid.
* In play mode the command changes the runtime copy only (see `enterPlay`).
*/
execute(command: Command): boolean {
if (this.mode === 'play') return false;
try {
this.commands.execute(command);
} catch (error) {
@@ -88,13 +134,13 @@ export class EditorState {
}
undo(): void {
if (this.mode === 'play' || !this.commands.undo()) return;
if (!this.commands.undo()) return;
this.validateSelection();
this.markDirty();
}
redo(): void {
if (this.mode === 'play' || !this.commands.redo()) return;
if (!this.commands.redo()) return;
this.validateSelection();
this.markDirty();
}
@@ -107,9 +153,15 @@ export class EditorState {
this.emit('project');
}
/** The world ticked: panels showing live values refresh (no model event, nothing to save). */
runtimeTick(): void {
this.emit('runtime');
}
private markDirty(): void {
this.validateSelection();
this.saveStatus = 'dirty';
// Play-mode edits live in the runtime copy and are never saved.
if (this.mode === 'edit') this.saveStatus = 'dirty';
this.emit('project');
this.emit('save');
}