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
@@ -16,20 +16,27 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|
||||
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
|
||||
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). |
|
||||
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
|
||||
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
||||
| **Play mode** | A `World` is instantiated from the active scene (one entity per scene object, resolved properties as mutable state). Systems tick it; nothing writes back. **Stop discards the world.** |
|
||||
| **Edit mode** | The Scene tab draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
||||
| **Play mode** | The editor switches to a **runtime copy** of the project (same ids). A `World` is instantiated from its active scene (one entity per scene object, resolved properties as mutable state) and shown in the **Game** tab. Systems tick it and mirror their changes into the copy; every panel stays open and edits the copy, which the world picks up immediately. **Stop discards world and copy** – the saved model is never touched. |
|
||||
|
||||
**The camera defines what is rendered.** A camera asset has a view size (`width` × `height`,
|
||||
default 640×360) and a `zoom`; the frame it covers in world units is `size / zoom`. In edit
|
||||
mode that frame is drawn as an outline. In play mode only that frame is rendered – fitted into
|
||||
the viewport with black letterboxing – and objects outside it are culled. A scene without a
|
||||
camera renders nothing in play mode. Play has no built-in behaviour (no input, no movement):
|
||||
default 640×360) and a `zoom`; the frame it covers in world units is `size / zoom`. The Scene
|
||||
tab draws that frame as an outline. The Game tab renders only that frame – fitted into the
|
||||
view with black letterboxing – and objects outside it are culled. A scene without a camera
|
||||
renders nothing in the Game tab. Play has no built-in behaviour (no input, no movement):
|
||||
everything that happens is defined by behavior graphs.
|
||||
|
||||
**Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the
|
||||
Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and
|
||||
inspector show and edit the running objects, the graph can be changed, undo/redo work on the
|
||||
play session's own history. All of it happens in the runtime copy: values a behavior changes
|
||||
appear as overrides (bold, ↺ resets to the asset value), objects a graph destroys vanish from
|
||||
the lists, and Esc/Stop brings back exactly what was saved. Autosave pauses while playing.
|
||||
|
||||
## Behaviors (node graphs)
|
||||
|
||||
A behavior asset holds a Blueprint-style graph edited in the **Graph** tab of the centre
|
||||
area (the **Scene** tab shows the viewport). Ports come in two kinds: **exec** (white
|
||||
area (the **Scene** tab shows the editable scene, the **Game** tab the running game). Ports come in two kinds: **exec** (white
|
||||
arrows, control flow) and **data** (coloured circles; `number`, `boolean`, `string`,
|
||||
`object`, `any`). Event nodes start a pulse; the interpreter follows exec edges and pulls
|
||||
data inputs lazily from connected outputs. Unconnected data inputs use the inline value
|
||||
@@ -59,7 +66,7 @@ shown on the node and editable in the inspector.
|
||||
the empty canvas pans. Everything is undoable.
|
||||
- **Selection-bound**: the Graph tab shows the behavior the current selection refers to
|
||||
(asset, placed object or node); with nothing or a non-behavior selected it is empty.
|
||||
- **Live view**: Play always switches to the Scene tab; switch back to Graph while playing and
|
||||
- **Live view**: Play always switches to the Game tab; switch to Graph while playing and
|
||||
the exec edges and nodes that just ran light up – the same canvas draws editor and game, so
|
||||
this costs nothing extra.
|
||||
- **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only
|
||||
@@ -132,7 +139,7 @@ src/core/ model, property schema, resolve, commands, store – no Pixi
|
||||
src/core/graph/ graph model, node registry + built-in nodes, interpreter (headless, unit-tested)
|
||||
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
|
||||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||||
src/viewport/ scene renderer: model in edit mode, world in play mode
|
||||
src/viewport/ scene renderer: Scene tab (model, editable) and Game tab (world through its camera)
|
||||
src/editor/ App composition, state, panels, inspector field factory
|
||||
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
|
||||
api/ PHP backend data/projects/ persisted projects
|
||||
|
||||
+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');
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { PropertyBag } from '../core/model/Properties';
|
||||
|
||||
/**
|
||||
* A live instance created from a scene object when Play starts. Holds the *resolved*
|
||||
* property values as mutable runtime state. Systems change entities freely; nothing here
|
||||
* is ever written back to the project model.
|
||||
* property values as mutable runtime state. Systems change entities freely; the world
|
||||
* mirrors them into the play-mode copy of the project (never the saved model) so the
|
||||
* editor panels can show and edit them while playing.
|
||||
*/
|
||||
export interface Entity {
|
||||
id: string;
|
||||
|
||||
+77
-30
@@ -6,11 +6,16 @@ import type { System } from './systems/System';
|
||||
|
||||
/**
|
||||
* The running game: entities instantiated from one scene plus the systems ticking them.
|
||||
* Created on Play, thrown away on Stop – the project model is never touched.
|
||||
*
|
||||
* The world mirrors the scene of the project it was built from – in the editor that is the
|
||||
* play-mode *copy* of the model, never the saved one. `syncFromModel()` pulls editor changes
|
||||
* (moved, placed, removed or re-parented objects, edited properties) into the entities;
|
||||
* `syncToModel()` pushes what the systems changed (positions, variables, destroyed entities)
|
||||
* back into that copy, so the panels show live values. Stop throws both away.
|
||||
*/
|
||||
export class World {
|
||||
readonly entities: Entity[] = [];
|
||||
readonly backgroundColor: number;
|
||||
backgroundColor = 0x000000;
|
||||
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
|
||||
stopRequested = false;
|
||||
/** Messages logged by behaviors (Log node), newest last. */
|
||||
@@ -18,40 +23,82 @@ export class World {
|
||||
private readonly systems: System[] = [];
|
||||
private elapsed = 0;
|
||||
|
||||
private constructor(readonly sceneId: string, backgroundColor: number) {
|
||||
this.backgroundColor = backgroundColor;
|
||||
}
|
||||
private constructor(readonly project: Project, readonly sceneId: string) {}
|
||||
|
||||
/** Builds a world from the given scene. Throws when the scene does not exist. */
|
||||
static instantiate(project: Project, sceneId: string): World {
|
||||
const scene = findScene(project, sceneId);
|
||||
if (!scene) throw new Error(`Scene ${sceneId} not found`);
|
||||
const background = scene.props.backgroundColor;
|
||||
const world = new World(sceneId, typeof background === 'number' ? background : 0x000000);
|
||||
const parentIds = new Map<Entity, string>();
|
||||
for (const object of scene.objects) {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
if (!asset || asset.type === 'scene') continue;
|
||||
const entity: Entity = {
|
||||
id: `e-${object.id}`,
|
||||
objectId: object.id,
|
||||
assetId: asset.id,
|
||||
type: asset.type,
|
||||
name: object.name || asset.name,
|
||||
x: object.position.x,
|
||||
y: object.position.y,
|
||||
z: object.position.z,
|
||||
props: resolveSceneObjectProps(asset, object),
|
||||
parent: null,
|
||||
};
|
||||
world.entities.push(entity);
|
||||
if (object.parentId) parentIds.set(entity, object.parentId);
|
||||
}
|
||||
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
|
||||
for (const [entity, parentId] of parentIds) entity.parent = world.findByObjectId(parentId) ?? null;
|
||||
if (!findScene(project, sceneId)) throw new Error(`Scene ${sceneId} not found`);
|
||||
const world = new World(project, sceneId);
|
||||
world.syncFromModel();
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the entities match the scene: one entity per placed object (created on demand,
|
||||
* dropped when the object is gone), positions, names and resolved properties taken from
|
||||
* the model, parent links re-established. Property bags are updated in place so systems
|
||||
* holding a reference keep seeing the current values.
|
||||
*/
|
||||
syncFromModel(): void {
|
||||
const scene = findScene(this.project, this.sceneId);
|
||||
if (!scene) return;
|
||||
const background = scene.props.backgroundColor;
|
||||
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
|
||||
const keep = new Set<Entity>();
|
||||
for (const object of scene.objects) {
|
||||
const asset = findAsset(this.project, object.assetId);
|
||||
if (!asset || asset.type === 'scene') continue;
|
||||
let entity = this.findByObjectId(object.id);
|
||||
if (!entity) {
|
||||
entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null };
|
||||
this.entities.push(entity);
|
||||
}
|
||||
entity.assetId = asset.id;
|
||||
entity.type = asset.type;
|
||||
entity.name = object.name || asset.name;
|
||||
entity.x = object.position.x;
|
||||
entity.y = object.position.y;
|
||||
entity.z = object.position.z;
|
||||
const resolved = resolveSceneObjectProps(asset, object);
|
||||
for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key];
|
||||
Object.assign(entity.props, resolved);
|
||||
keep.add(entity);
|
||||
}
|
||||
for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
|
||||
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
|
||||
for (const entity of this.entities) {
|
||||
const object = scene.objects.find((o) => o.id === entity.objectId);
|
||||
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the entities' state into the scene: positions, property values that differ from
|
||||
* what the model resolves (stored as overrides, so the inspector marks them) and removed
|
||||
* entities (their objects are dropped). Returns true when the object list changed.
|
||||
*/
|
||||
syncToModel(): boolean {
|
||||
const scene = findScene(this.project, this.sceneId);
|
||||
if (!scene) return false;
|
||||
const alive = new Set(this.entities.map((e) => e.objectId));
|
||||
const before = scene.objects.length;
|
||||
for (let i = scene.objects.length - 1; i >= 0; i--) {
|
||||
const object = scene.objects[i]!;
|
||||
const asset = findAsset(this.project, object.assetId);
|
||||
if (asset && asset.type !== 'scene' && !alive.has(object.id)) scene.objects.splice(i, 1);
|
||||
}
|
||||
for (const entity of this.entities) {
|
||||
const object = scene.objects.find((o) => o.id === entity.objectId);
|
||||
const asset = object && findAsset(this.project, entity.assetId);
|
||||
if (!object || !asset) continue;
|
||||
const p = object.position;
|
||||
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) object.position = { x: entity.x, y: entity.y, z: entity.z };
|
||||
const base = resolveSceneObjectProps(asset, object);
|
||||
for (const [key, value] of Object.entries(entity.props)) if (key in base && base[key] !== value) object.overrides[key] = value;
|
||||
}
|
||||
return scene.objects.length !== before;
|
||||
}
|
||||
|
||||
/** World position of an entity: local position plus those of all ancestors. */
|
||||
worldPosition(entity: Entity): { x: number; y: number; z: number } {
|
||||
return worldPositionOf(entity);
|
||||
|
||||
@@ -39,19 +39,30 @@ export class BehaviorSystem implements System {
|
||||
start(world: World): void {
|
||||
this.world = world;
|
||||
this.instances = [];
|
||||
this.syncInstances(world);
|
||||
}
|
||||
|
||||
/**
|
||||
* One instance per behavior entity: entities that appeared (placed while playing) get an
|
||||
* interpreter and their `On start`; instances whose entity is gone are dropped.
|
||||
*/
|
||||
private syncInstances(world: World): void {
|
||||
this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
|
||||
for (const entity of world.entities) {
|
||||
if (entity.type !== 'behavior') continue;
|
||||
if (entity.type !== 'behavior' || this.instances.some((i) => i.entity === entity)) continue;
|
||||
const asset = findAsset(this.project, entity.assetId);
|
||||
if (!asset || asset.type !== 'behavior') continue;
|
||||
const trace: Trace = { edges: new Map(), nodes: new Map() };
|
||||
const interpreter = new GraphInterpreter(asset.graph);
|
||||
interpreter.trace = this.traceListener(trace, world);
|
||||
this.instances.push({ entity, interpreter, trace });
|
||||
const instance = { entity, interpreter, trace };
|
||||
this.instances.push(instance);
|
||||
this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0);
|
||||
}
|
||||
for (const instance of this.instances) this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0);
|
||||
}
|
||||
|
||||
update(world: World, dt: number): void {
|
||||
this.syncInstances(world);
|
||||
const keyOf = (node: { params: Record<string, unknown> }) => String(node.params.key ?? '');
|
||||
for (const instance of this.instances) {
|
||||
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
|
||||
|
||||
+39
-18
@@ -60,16 +60,28 @@ interface View {
|
||||
const GRID_STEP = 100;
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
export interface ViewportOptions {
|
||||
/**
|
||||
* Game view: draws the running world through the scene's camera, no editing. The default
|
||||
* (scene view) draws the model – in play mode that is the runtime copy, so it shows the
|
||||
* live positions and stays fully editable.
|
||||
*/
|
||||
game?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the active scene (edit mode) or the running world (play mode) as coloured boxes.
|
||||
* Edit mode adds a grid, selection outline, drag-to-move, middle-drag panning and wheel
|
||||
* zoom. In play mode the scene's camera defines what is visible: only its frame is drawn,
|
||||
* everything outside it is masked and not rendered at all. Without a camera play mode
|
||||
* renders nothing.
|
||||
* Draws a scene as coloured boxes. The **scene view** shows the model with a grid, selection
|
||||
* outline, drag-to-move, middle-drag panning and wheel zoom – in edit and play mode alike.
|
||||
* The **game view** shows the running world: the scene's camera defines what is visible,
|
||||
* only its frame is drawn (letterboxed), everything outside it is masked and not rendered
|
||||
* at all. Without a camera the game view renders nothing.
|
||||
*/
|
||||
export class Viewport extends Widget {
|
||||
private readonly game: boolean;
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
/** Game view only: shown while no world is running. */
|
||||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||||
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
|
||||
private readonly content = new Container();
|
||||
private readonly world = new Container();
|
||||
@@ -93,23 +105,27 @@ export class Viewport extends Widget {
|
||||
private dragging: { objectId: string; sceneId: string; start: Position; pointer: Vec2; moved: boolean } | null = null;
|
||||
private visibleCount = 0;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
constructor(private readonly state: EditorState, options: ViewportOptions = {}) {
|
||||
super();
|
||||
this.game = options.game ?? false;
|
||||
this.world.addChild(this.objects, this.links, this.overlay);
|
||||
// Decorations must not swallow hits meant for the objects beneath them.
|
||||
this.links.eventMode = 'none';
|
||||
this.hint.eventMode = 'none';
|
||||
this.hint.anchor.set(0.5);
|
||||
this.content.addChild(this.world, this.gizmos);
|
||||
this.addChild(this.bg, this.grid, this.content, this.contentMask);
|
||||
this.addChild(this.bg, this.grid, this.content, this.contentMask, this.hint);
|
||||
this.content.mask = this.contentMask;
|
||||
this.cursor = 'default';
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (this.game) return;
|
||||
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && e.target === this && this.state.mode === 'edit') this.state.select(null);
|
||||
if (e.button === 0 && e.target === this) this.state.select(null);
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||||
if (this.panning) {
|
||||
@@ -143,7 +159,7 @@ export class Viewport extends Widget {
|
||||
this.on('pointerup', finish);
|
||||
this.on('pointerupoutside', finish);
|
||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||
if (this.state.mode !== 'edit') return;
|
||||
if (this.game) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const before = this.screenToWorld(local.x, local.y);
|
||||
const factor = Math.exp(-e.deltaY * 0.0015);
|
||||
@@ -159,9 +175,11 @@ export class Viewport extends Widget {
|
||||
state.on('load', () => { this.view = { x: 0, y: 0, zoom: 1 }; this.redraw(); });
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('mode', () => this.redraw());
|
||||
// Every tick moves things: redraw the live view when it is on screen.
|
||||
state.on('runtime', () => { if (this.visible) this.redraw(); });
|
||||
}
|
||||
|
||||
/** Switch between drawing the model (null) and a running world. */
|
||||
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.redraw();
|
||||
@@ -199,10 +217,10 @@ export class Viewport extends Widget {
|
||||
return { x: this.view.x, y: this.view.y, scale: this.view.zoom, rect: { x: 0, y: 0, w: this.w, h: this.h } };
|
||||
}
|
||||
|
||||
/** The active mapping, or null when playing a scene that has no camera (nothing to show). */
|
||||
/** The active mapping, or null when the game view has no world or no camera (nothing to show). */
|
||||
private currentView(): View | null {
|
||||
if (!this.playWorld) return this.editView();
|
||||
const camera = this.playWorld.firstOfType('camera');
|
||||
if (!this.game) return this.editView();
|
||||
const camera = this.playWorld?.firstOfType('camera');
|
||||
if (!camera) return null;
|
||||
const frame = cameraFrame(camera.props);
|
||||
const scale = Math.min(this.w / frame.w, this.h / frame.h);
|
||||
@@ -231,9 +249,10 @@ export class Viewport extends Widget {
|
||||
// --- drawing ---------------------------------------------------------------------------
|
||||
|
||||
private collectItems(): { items: DrawItem[]; background: number } {
|
||||
if (this.playWorld) {
|
||||
if (this.game) {
|
||||
// Symbols (camera, behaviors) are editor-only; the running game shows scene content.
|
||||
const world = this.playWorld;
|
||||
if (!world) return { items: [], background: LETTERBOX_COLOR };
|
||||
return {
|
||||
background: world.backgroundColor,
|
||||
items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })),
|
||||
@@ -260,21 +279,23 @@ export class Viewport extends Widget {
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const editing = !this.playWorld;
|
||||
const editing = !this.game;
|
||||
const view = this.currentView();
|
||||
|
||||
// Base fill: letterbox black in play mode, neutral in edit mode without a scene.
|
||||
// Base fill: letterbox black in the game view, neutral in the scene view without a scene.
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(editing ? Theme.colors.viewport : LETTERBOX_COLOR);
|
||||
this.drawGrid(editing);
|
||||
this.gizmos.clear();
|
||||
this.contentMask.clear();
|
||||
this.content.visible = view !== null;
|
||||
this.hint.visible = this.game && !this.playWorld;
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
this.visibleCount = 0;
|
||||
if (!view) return;
|
||||
|
||||
const { items, background } = this.collectItems();
|
||||
const { rect } = view;
|
||||
if (this.state.activeScene || this.playWorld) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
|
||||
if (this.game || this.state.activeScene) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
|
||||
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
|
||||
|
||||
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
|
||||
@@ -432,7 +453,7 @@ export class Viewport extends Widget {
|
||||
}
|
||||
|
||||
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (this.state.mode !== 'edit' || e.button !== 0) return;
|
||||
if (this.game || e.button !== 0) return;
|
||||
const scene = this.state.activeScene;
|
||||
const object = scene?.objects.find((o) => o.id === objectId);
|
||||
if (!scene || !object) return;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CommandStack } from '../../src/core/commands/CommandStack';
|
||||
import { ClearOverrideCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { createEmptyProject, findScene } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { World } from '../../src/runtime/World';
|
||||
|
||||
/** A scene with a 100×100 box asset and two placed boxes. */
|
||||
function setup() {
|
||||
const project = createEmptyProject('p', 'Sync');
|
||||
const stack = new CommandStack(project);
|
||||
const scene = new CreateAssetCommand('scene', 'Level');
|
||||
const sprite = new CreateAssetCommand('sprite', 'Box');
|
||||
stack.execute(scene);
|
||||
stack.execute(sprite);
|
||||
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
|
||||
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 });
|
||||
stack.execute(a);
|
||||
stack.execute(b);
|
||||
const sceneOf = () => findScene(project, scene.asset.id)!;
|
||||
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
|
||||
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, sceneOf, object };
|
||||
}
|
||||
|
||||
describe('world ↔ model synchronisation (editing while playing)', () => {
|
||||
it('syncFromModel picks up placed, moved, edited, re-parented and removed objects', () => {
|
||||
const { project, stack, sceneId, spriteId, a, b, object } = setup();
|
||||
const world = World.instantiate(project, sceneId);
|
||||
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
|
||||
|
||||
// Placed while playing → new entity, existing ones keep their identity.
|
||||
const entityA = world.findByObjectId(a)!;
|
||||
const c = new PlaceAssetCommand(sceneId, spriteId, { x: 50, y: 50 });
|
||||
stack.execute(c);
|
||||
world.syncFromModel();
|
||||
expect(world.entities).toHaveLength(3);
|
||||
expect(world.findByObjectId(a)).toBe(entityA);
|
||||
|
||||
// Property edits (override, asset value) and parenting are reflected; props bag keeps identity.
|
||||
const propsA = entityA.props;
|
||||
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 40));
|
||||
stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 70));
|
||||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||||
world.syncFromModel();
|
||||
expect(entityA.props).toBe(propsA);
|
||||
expect(entityA.props.width).toBe(40);
|
||||
expect(entityA.props.height).toBe(70);
|
||||
expect(world.findByObjectId(b)!.parent).toBe(entityA);
|
||||
expect(world.findByObjectId(b)!.x).toBe(object(b).position.x);
|
||||
|
||||
// Removed while playing → entity gone.
|
||||
stack.execute(new RemoveSceneObjectCommand(sceneId, c.objectId));
|
||||
world.syncFromModel();
|
||||
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it('syncToModel writes positions, changed values as overrides and destroyed entities back', () => {
|
||||
const { project, sceneId, a, b, object, sceneOf } = setup();
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const entityA = world.findByObjectId(a)!;
|
||||
entityA.x = 123;
|
||||
entityA.props.width = 999; // what a Set property node would do
|
||||
entityA.props.height = 100; // unchanged → no override
|
||||
expect(world.syncToModel()).toBe(false);
|
||||
expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 });
|
||||
expect(object(a).overrides).toEqual({ width: 999 });
|
||||
|
||||
// Round trip: the model now resolves to the runtime values, so a sync back changes nothing.
|
||||
world.syncFromModel();
|
||||
expect(entityA.x).toBe(123);
|
||||
expect(entityA.props.width).toBe(999);
|
||||
|
||||
// Resetting the override in the editor returns the entity to the asset value.
|
||||
new ClearOverrideCommand(sceneId, a, 'width').execute(project);
|
||||
world.syncFromModel();
|
||||
expect(entityA.props.width).toBe(100);
|
||||
|
||||
// Destroy (node) removes the entity; the sync drops its object – a structural change.
|
||||
world.remove(world.findByObjectId(b)!);
|
||||
expect(world.syncToModel()).toBe(true);
|
||||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
|
||||
});
|
||||
|
||||
it('a play-mode copy leaves the original model untouched', () => {
|
||||
const { project, sceneId, a, object } = setup();
|
||||
const copy = deserializeProject(serializeProject(project), project.id);
|
||||
const world = World.instantiate(copy, sceneId);
|
||||
world.findByObjectId(a)!.x = 500;
|
||||
world.remove(world.findByObjectId(a)!);
|
||||
world.syncToModel();
|
||||
expect(findScene(copy, sceneId)!.objects).toHaveLength(1);
|
||||
expect(object(a).position.x).toBe(0);
|
||||
expect(findScene(project, sceneId)!.objects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+34
-7
@@ -208,7 +208,7 @@ try {
|
||||
await sleep(300);
|
||||
s = JSON.parse(await stateJson());
|
||||
check('play mode entered', s.mode === 'play', JSON.stringify(s));
|
||||
let info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||
let info = await evaluate('JSON.stringify(__gm.gameInfo())');
|
||||
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
|
||||
await shot('08-playing');
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
@@ -231,11 +231,38 @@ try {
|
||||
const o1AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x`);
|
||||
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
|
||||
|
||||
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
|
||||
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
|
||||
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
|
||||
await clickText('▶ Play');
|
||||
await sleep(300);
|
||||
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
|
||||
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyOrder().length')) === 6);
|
||||
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' })`);
|
||||
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400; __gm.state.preview()`);
|
||||
await sleep(100);
|
||||
let liveO2 = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o2');
|
||||
check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||||
await shot('08b-editing-while-playing');
|
||||
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
|
||||
await sleep(150);
|
||||
const liveIds = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.map((e) => e.objectId);
|
||||
check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 4, JSON.stringify(liveIds));
|
||||
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||
await sleep(150);
|
||||
liveO2 = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o2');
|
||||
check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(300);
|
||||
const o2AfterEdit = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
||||
check('stop restores the saved model untouched by play-mode edits', o2AfterEdit === xBeforePlay && (await evaluate('__gm.state.activeScene.objects.length')) === 6, `x=${o2AfterEdit}`);
|
||||
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
|
||||
|
||||
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||||
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
|
||||
await clickText('▶ Play');
|
||||
await sleep(300);
|
||||
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||
info = await evaluate('JSON.stringify(__gm.gameInfo())');
|
||||
check('objects outside the camera frame are culled', JSON.parse(info).visibleItems === 1, info);
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(200);
|
||||
@@ -248,7 +275,7 @@ try {
|
||||
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 5);
|
||||
await clickText('▶ Play');
|
||||
await sleep(300);
|
||||
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||
info = await evaluate('JSON.stringify(__gm.gameInfo())');
|
||||
s = JSON.parse(await stateJson());
|
||||
check('play without camera renders nothing', s.mode === 'play' && !JSON.parse(info).rendering && JSON.parse(info).visibleItems === 0, info);
|
||||
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
|
||||
@@ -424,7 +451,7 @@ try {
|
||||
const restored = await graphCounts();
|
||||
check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
|
||||
|
||||
// 13e. Play always starts on the Scene tab; switching back to Graph while playing shows
|
||||
// 13e. Play always starts on the Game tab; switching to Graph while playing shows
|
||||
// the live highlight: hold ArrowRight → the exec edge is hot.
|
||||
const fpsEdit = await evaluate('__gm.fps()');
|
||||
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
|
||||
@@ -433,7 +460,7 @@ try {
|
||||
check('fps label is visible in the toolbar', fpsLabel === true);
|
||||
await clickText('▶ Play');
|
||||
await sleep(200);
|
||||
check('play switches to the scene tab', (await evaluate('__gm.tab()')) === 'scene');
|
||||
check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game');
|
||||
await sleep(600);
|
||||
const fpsPlay = await evaluate('__gm.fps()');
|
||||
check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`);
|
||||
@@ -584,8 +611,8 @@ try {
|
||||
const collisionWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
|
||||
check('On collision enter fired once with the other body', collisionWorld && collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld?.log));
|
||||
const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`);
|
||||
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||
check('colliders are not rendered in play mode', JSON.parse(info).visibleItems === spriteCount, `${info} sprites=${spriteCount}`);
|
||||
info = await evaluate('JSON.stringify(__gm.gameInfo())');
|
||||
check('colliders are not rendered in the game view', JSON.parse(info).visibleItems === spriteCount, `${info} sprites=${spriteCount}`);
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(200);
|
||||
|
||||
|
||||
Reference in new issue
Block a user