diff --git a/README.md b/README.md index 321da0ab..39e42835 100644 --- a/README.md +++ b/README.md @@ -16,20 +16,27 @@ Live: · Widget playground: { @@ -37,7 +37,7 @@ export async function startEditor(app: Application, root: VStack): Promise * 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 { + 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 { + 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; } diff --git a/src/editor/autosave.ts b/src/editor/autosave.ts index 6b13c6ed..1bc87434 100644 --- a/src/editor/autosave.ts +++ b/src/editor/autosave.ts @@ -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 } { 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 { diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index a107f55e..2cb50106 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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(); } diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 2cde2263..b3bf995d 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -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(); } diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts index ae6b711d..4ddd7f42 100644 --- a/src/editor/panels/Inspector.ts +++ b/src/editor/panels/Inspector.ts @@ -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 }); diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index e5164466..e792dd81 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -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); diff --git a/src/editor/state.ts b/src/editor/state.ts index 0529f30b..af3ade2a 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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>(); @@ -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'); } diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index f2135378..72b483b6 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -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; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 182b9972..cbae1bb7 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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(); - 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(); + 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); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index b74ad812..aa001ff1 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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(node.params.key ?? ''); for (const instance of this.instances) { if (!world.entities.includes(instance.entity)) continue; // destroyed during play diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index 9854a933..2714fce5 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -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; diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts new file mode 100644 index 00000000..c260651c --- /dev/null +++ b/tests/core/world-sync.test.ts @@ -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); + }); +}); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index f1652012..7f382ed7 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -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);