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:
1 parent
cb8885359f
commit
c3a533c4e5
14 files changed
+408
-118
No files matched your search
+47
-34
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { EditorState } from './state';
|
||||
|
||||
/**
|
||||
* Debounced autosave: every model change schedules a save ~1 s later. Saves never run
|
||||
* during Play (the model does not change then anyway) and never overlap; a change that
|
||||
* arrives while a save is in flight triggers another save afterwards.
|
||||
* during Play (`state.project` is the runtime copy then; a pending save resumes on Stop)
|
||||
* and never overlap; a change that arrives while a save is in flight triggers another save
|
||||
* afterwards.
|
||||
*/
|
||||
export function attachAutosave(state: EditorState, store: ProjectStore, delayMs = 1000): { flush: () => Promise<void> } {
|
||||
let timer = 0;
|
||||
@@ -16,7 +17,7 @@ export function attachAutosave(state: EditorState, store: ProjectStore, delayMs
|
||||
pending = true;
|
||||
return saving;
|
||||
}
|
||||
if (state.saveStatus !== 'dirty' || !state.project.id) return;
|
||||
if (state.saveStatus !== 'dirty' || !state.project.id || state.mode === 'play') return;
|
||||
state.setSaveStatus('saving');
|
||||
saving = (async () => {
|
||||
try {
|
||||
|
||||
@@ -35,7 +35,6 @@ export class AssetPanel extends Panel {
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => this.refresh());
|
||||
state.on('selection', () => this.syncSelection());
|
||||
state.on('mode', () => this.setEnabled(state.mode === 'edit'));
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ export class HierarchyPanel extends Panel {
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => this.refresh());
|
||||
state.on('selection', () => this.syncSelection());
|
||||
state.on('mode', () => this.setEnabled(state.mode === 'edit'));
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../core/commands/colliderCommands';
|
||||
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
@@ -17,7 +18,7 @@ import { findAsset, findScene, findSceneObject } from '../../core/model/Project'
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
|
||||
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
|
||||
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
|
||||
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
|
||||
import type { EditorState, Selection } from '../state';
|
||||
|
||||
@@ -37,6 +38,8 @@ export class Inspector extends Panel {
|
||||
private builtFor: Selection = null;
|
||||
/** Describes the structure the fields were built for; a change means a rebuild. */
|
||||
private signature = '';
|
||||
/** Last live refresh (play mode), in ms – the world ticks far more often than the eye needs. */
|
||||
private lastLiveRefresh = 0;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ title: 'Inspector', padding: 0, gap: 0 });
|
||||
@@ -46,10 +49,22 @@ export class Inspector extends Panel {
|
||||
state.on('selection', () => this.rebuild());
|
||||
state.on('load', () => this.rebuild());
|
||||
state.on('project', () => this.refreshValues());
|
||||
state.on('mode', () => this.setEnabled(state.mode === 'edit'));
|
||||
state.on('runtime', () => this.refreshLive());
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play mode: show what the behaviors are doing to the selected object, ~10× a second.
|
||||
* Skipped while a field in here is being edited so the typed text is not overwritten.
|
||||
*/
|
||||
private refreshLive(): void {
|
||||
const now = performance.now();
|
||||
if (now - this.lastLiveRefresh < 100) return;
|
||||
for (let node: Container | null = ui().focus.focused; node; node = node.parent) if (node === this) return;
|
||||
this.lastLiveRefresh = now;
|
||||
this.refreshValues();
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.builtFor = this.state.selection;
|
||||
this.body.destroy({ children: true });
|
||||
|
||||
@@ -93,8 +93,9 @@ export class Toolbar extends HStack {
|
||||
const scenes = listScenes(state.project);
|
||||
this.sceneButton.setText(scene ? `${scene.name} ▾` : scenes.length ? 'Select scene ▾' : 'No scene ▾');
|
||||
const editing = state.mode === 'edit';
|
||||
this.undoButton.setEnabled(editing && state.commands.canUndo);
|
||||
this.redoButton.setEnabled(editing && state.commands.canRedo);
|
||||
// Undo/redo work in play mode too – on the runtime copy's own history.
|
||||
this.undoButton.setEnabled(state.commands.canUndo);
|
||||
this.redoButton.setEnabled(state.commands.canRedo);
|
||||
this.playButton.setText(editing ? '▶ Play' : '■ Stop');
|
||||
this.playButton.variant = editing ? 'primary' : 'danger';
|
||||
this.playButton.setEnabled(scene !== undefined);
|
||||
|
||||
+58
-6
@@ -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');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user