diff --git a/README.md b/README.md index 7096888f..6064d8d6 100644 --- a/README.md +++ b/README.md @@ -46,19 +46,35 @@ the lists, objects a graph spawns (`Spawn` node) appear in them – selectable, only in the runtime copy –, and Esc/Stop brings back exactly what was saved. Autosave pauses while playing. +**Pausing.** The toolbar's *Pause* button (or `Ctrl+Enter`) holds the running world: it is no +longer ticked, so behaviors, timers and collisions stand still and the world clock stops with +them. It is a sub-state of play mode, not a third mode – the runtime copy, the panels and the +keyboard behave exactly as while playing, and everything the editor can do during play it can +do during a hold. Keys and clicks that arrive while it is held are **dropped** rather than +queued, so resuming does not fire a frame's worth of input at once. Stop clears the hold, so a +paused game can never outlive its run. + **Running without Play.** A behavior switched to **Run in editor** goes on running while the project is not playing (`src/editor/EditorRun.ts`, ticked by the Editor next to the play session). The switch is an ordinary property, so it can be set on the behavior asset and overridden per object. What it runs is a `World` over the **saved** model, and that is the whole difference to Play: a position or variable it changes is a real edit – it is autosaved, the panels show it, and **undo does not take it back**. Which is why the switch is off by default and why it belongs on -behaviors that arrange or build content rather than on ones that simulate. What it does *not* get: +behaviors that arrange or build content rather than on ones that simulate. -- **input** – the keyboard and the mouse stay with the editor, so `On key …`, `On click` and - `Is key down` never fire; `On start`, `On update`, `On timer` and collisions do. -- **the content of a placed container** – writing a container's content out as plain objects is - itself an edit (see *Containers*), so the editor run instantiates without it, and a behavior - inside a placed container does not run outside Play. +**Input goes to the tab you are looking at.** While the **Scene** tab is on screen the keyboard and +the mouse are the editor's – typing a name or walking the hierarchy with the arrow keys must not +steer a game at the same time. In the **Game** tab, where no key means anything to the editor, they +reach the run instead: `On key …`, `Is key down` and `On click` work there, and the tab says so in +its top-left corner. Leaving the tab (or focusing a field in it) hands the keyboard back and lets +every held key go, so nothing keeps walking with nobody pressing anything. What the run does *not* +get: + +- **structural changes to the content of a placed container** – such content is resolved from the + template, not stored (see *Containers*), and the editor run must not bake it out, since that is + itself an edit. Its behaviors do run, and what they move or set is kept as an `instanceEdits` + entry on the instance; but a `Destroy` of resolved content is dropped from the running world + without being saved, and comes back the next time the world is rebuilt. - **a second life** – only the behaviors of the **loaded** scene run, and only in edit mode; Play stops the editor run, since that world runs every behavior anyway. An exception in a graph, or a `Stop game` node, ends the run and puts the reason in the notice line instead of repeating it @@ -369,9 +385,11 @@ template and drops the edit, **Reset to container** drops the edit alone. Two places do spell the content out. **Unpack** does it on purpose – the objects become ordinary ones and stop following. And **play mode bakes** every placed container into stored objects, in -the throwaway copy of the model, because a running behavior may move or destroy one of them and a -resolved object has nowhere to remember that. Both keep the derived ids, so the selection and a -check script survive them. +the throwaway copy of the model, because a running behavior may destroy one of them and a resolved +object has nowhere to remember that. Both keep the derived ids, so the selection and a check script +survive them. A world built **without** baking (the editor run, `WorldOptions.bakeContainers`) runs +that content all the same and writes through `objectRef` into the instance edits; only a removal +has nowhere to go there. ## Screens without scene switching @@ -470,7 +488,7 @@ npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # where the browser runs; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 353 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 368 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end ``` @@ -559,7 +577,8 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi ## Keyboard `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+G` create -container from the selection · `Ctrl+Enter` play · `Esc` stop · `Ctrl+V` import an image from +container from the selection · `Ctrl+Enter` play, and pause/resume while playing · `Esc` stop · +`Ctrl+V` import an image from the clipboard as a texture (ignored while a text field has focus) · `Tab` cycles fields · number fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a step-1 field lands on whole numbers), `↑`/`↓` step, shown with three decimals (`ui/math.ts`, diff --git a/src/core/properties/editorRun.ts b/src/core/properties/editorRun.ts index 37aed0d7..32f23d97 100644 --- a/src/core/properties/editorRun.ts +++ b/src/core/properties/editorRun.ts @@ -5,6 +5,7 @@ * the switch useful (a behavior can build or arrange content) and why it is off by default. */ import type { ObjectTree } from '../model/hierarchy'; +import { resolveObjects } from '../model/instance'; import type { PropertyBag } from '../model/Properties'; import { findAsset, type Project } from '../model/Project'; import { resolveSceneObjectProps } from './resolve'; @@ -13,9 +14,12 @@ export function runsInEditor(props: PropertyBag): boolean { return props.runInEditor === true; } -/** Whether a holder contains any behavior asking to run outside play mode. */ +/** + * Whether a holder contains any behavior asking to run outside play mode – inside a placed + * container as well, which is where such a behavior usually sits (the player of a level). + */ export function hasEditorRun(project: Project, holder: ObjectTree): boolean { - return holder.objects.some((object) => { + return resolveObjects(project, holder).some((object) => { const asset = findAsset(project, object.assetId); return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(asset, object)); }); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 550102a0..04970229 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -78,7 +78,12 @@ export class Editor implements PlayHost { this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.play = new PlaySession(state, this); - this.editorRun = new EditorRun(state); + this.editorRun = new EditorRun(state, { + // The game view is the one place where no key means anything to the editor, so that is where + // a behavior may have them – unless a widget in it is focused and wants them itself. + focused: () => this.tab === 'game' && ui().focus.focused === null, + onWorld: (world) => this.gameView.setEditorWorld(world), + }); this.projects = new ProjectSession(state, store, () => { if (this.play.isPlaying) this.play.stop(); }); @@ -90,6 +95,7 @@ export class Editor implements PlayHost { openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor), openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), + togglePause: () => this.play.togglePause(), }); root.add(this.toolbar); @@ -152,7 +158,11 @@ export class Editor implements PlayHost { root.resize(app.screen.width, app.screen.height); attachAutosave(state, store); - installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() }); + installShortcuts(state, { + play: () => this.play.play(), + stop: () => this.play.stop(), + togglePause: () => this.play.togglePause(), + }); app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); // A node, a frame and a template object exist only in their own tab, so selecting one // switches to it; selecting the behavior, texture or container asset itself does not. @@ -232,7 +242,12 @@ export class Editor implements PlayHost { } /** What is running without Play, for the check scripts. */ - editorRunInfo(): { running: boolean; time: number; behaviors: string[] } { + editorRunInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } { return this.editorRun.debugInfo(); } + + /** Play state for the check scripts; `time` is the world clock a pause has to freeze. */ + playInfo(): { playing: boolean; paused: boolean; time: number } { + return { playing: this.play.isPlaying, paused: this.play.isPaused, time: this.play.time }; + } } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index e11fadbe..3d943daa 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -3,7 +3,7 @@ * * The one thing that makes this different from Play: the world is built from the saved model, not * from a copy. What such a behavior moves or writes is a real edit – it is saved and it is not on - * the undo stack. The keyboard and the mouse stay with the editor, so input nodes never fire here. + * the undo stack. */ import { hasEditorRun, runsInEditor } from '../core/properties/editorRun'; import { BehaviorSystem } from '../runtime/systems/BehaviorSystem'; @@ -12,17 +12,31 @@ import { Input } from '../runtime/systems/Input'; import { type ModelWrite, World } from '../runtime/World'; import type { EditorState } from './state'; +export interface EditorRunHooks { + /** + * Whether the keyboard and the mouse currently belong to the game rather than to the editor. + * Without such a gate, typing a name would steer the game at the same time. + */ + focused: () => boolean; + /** The world the run just built, null when it stopped. Lets the game view route clicks into it. */ + onWorld: (world: World | null) => void; +} + export class EditorRun { private world: World | null = null; private behaviors: BehaviorSystem | null = null; - /** Never attached: the editor owns the keyboard, so `Is key down` is always false in here. */ private readonly input = new Input(); + /** Last answer of `hooks.focused`, to let held keys go when the game view loses them. */ + private hadFocus = false; /** The model changed, so *whether* anything runs is open again, not just the world's contents. */ private stale = true; /** Set while our own write travels through the listeners, so it does not count as a change. */ private writing = false; - constructor(private readonly state: EditorState) {} + constructor( + private readonly state: EditorState, + private readonly hooks: EditorRunHooks, + ) {} /** The model changed: a command, a load, or leaving play mode. */ invalidate(): void { @@ -42,17 +56,26 @@ export class EditorRun { } const world = this.world; if (!world) return; + // A key held while the game view loses the keyboard would stay held for good, and the object + // would keep walking with nobody pressing anything. + const focus = this.hooks.focused(); + if (focus !== this.hadFocus) { + this.hadFocus = focus; + this.input.releaseAll(); + } world.tick(Math.min(dt, 0.1)); + this.input.endFrame(); if (this.control(world)) return; this.commit(world.syncToModel()); } /** Facts for the check scripts: object ids of the behaviors currently running without Play. */ - debugInfo(): { running: boolean; time: number; behaviors: string[] } { + debugInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } { return { running: this.world !== null, time: this.world?.time ?? 0, behaviors: this.behaviors?.runningIds() ?? [], + keyboard: this.world !== null && this.hooks.focused(), }; } @@ -71,13 +94,16 @@ export class EditorRun { this.stop(); const project = this.state.project; // Baking a container's content into the scene would be an edit nobody asked for, so it is off - // here – which is why a behavior inside a placed container does not run outside play mode. + // here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`). const fresh = World.instantiate(project, scene.id, { bakeContainers: false }); const collisions = new CollisionSystem(); this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props)); fresh.addSystem(collisions); fresh.addSystem(this.behaviors); this.world = fresh; + this.hadFocus = this.hooks.focused(); + this.input.attach(this.hooks.focused); + this.hooks.onWorld(fresh); // `On start` already ran while the system started, and it may have failed. this.control(fresh); } @@ -106,8 +132,11 @@ export class EditorRun { } private stop(): void { - this.world?.destroy(); + if (!this.world) return; + this.world.destroy(); this.world = null; this.behaviors = null; + this.input.detach(); + this.hooks.onWorld(null); } } diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index ce76fa38..a5b1dd5b 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -51,6 +51,18 @@ export class PlaySession { return this.world !== null; } + get isPaused(): boolean { + return this.world !== null && this.state.paused; + } + + /** Pause or resume; does nothing while not playing. */ + togglePause(): void { + if (!this.world) return; + this.state.setPaused(!this.state.paused); + ui().focus.blur(); + this.state.setNotice(this.playNotice()); + } + traceFor(assetId: string): Trace | null { return this.behaviors?.traceFor(assetId) ?? null; } @@ -78,14 +90,17 @@ export class PlaySession { world.addSystem(this.behaviors); this.host.gameView.setWorld(world); this.host.showGame(); - this.state.setNotice( - world.activeCamera() - ? 'Playing · edits are not saved · Esc stops' - : 'No camera is switched on – nothing is rendered · Esc stops', - ); + this.state.setNotice(this.playNotice()); if (world.stopRequested) this.stop(); } + private playNotice(): string { + if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops'; + return this.world?.activeCamera() + ? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops' + : 'No camera is switched on – nothing is rendered · Esc stops'; + } + stop(): void { const world = this.world; this.world = null; @@ -103,6 +118,13 @@ export class PlaySession { tick(dt: number): void { const world = this.world; if (!world) return; + if (this.state.paused) { + // Keys and clicks that happen while paused are dropped instead of piling up – otherwise + // resuming would deliver every one of them in a single frame. + this.input.endFrame(); + world.dropPointerEdges(); + return; + } world.tick(Math.min(dt, 0.1)); this.input.endFrame(); if (world.stopRequested) { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 9d0741a1..b7750fb5 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -178,6 +178,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E objectRect: (id: string) => viewport.objectRect(id), /** The same for the Game tab – through the camera, so what the player sees. */ gameScreen: (id: string) => gameView.objectScreen(id), + gameCenter: () => centreOf(gameView), graph: { current: () => graphEditor.currentAssetId, portAt: (nodeId: string, port: string, isOutput: boolean) => @@ -213,6 +214,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E dropPreview: () => containerEditor.dropInfo(), }, world: () => editor.worldSnapshot(), + playInfo: () => editor.playInfo(), /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ editorRun: () => editor.editorRunInfo(), /** Rows the hierarchy shows – collapsed subtrees are not in here. */ diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index f1342408..eac25cd3 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -12,6 +12,8 @@ export interface ToolbarActions { openSceneMenu(anchor: Widget): void; /** Play in edit mode, Stop in play mode. */ togglePlay(): void; + /** Hold or resume the running world; only reachable in play mode. */ + togglePause(): void; } const SAVE_LABELS: Record = { @@ -30,6 +32,7 @@ export class Toolbar extends HStack { private readonly undoButton: Button; private readonly redoButton: Button; private readonly playButton: Button; + private readonly pauseButton: Button; private readonly status: Label; private readonly notice: Label; private readonly fps: Label; @@ -75,6 +78,11 @@ export class Toolbar extends HStack { onClick: () => actions.togglePlay(), }); this.add(this.playButton); + this.pauseButton = new Button('❚❚ Pause', { + width: 110, + onClick: () => actions.togglePause(), + }); + this.add(this.pauseButton); this.add(new Spacer(), 1); this.notice = new Label('', { style: 'small', align: 'right' }); @@ -114,6 +122,9 @@ export class Toolbar extends HStack { this.playButton.variant = editing ? 'primary' : 'danger'; this.playButton.setEnabled(scene !== undefined); this.playButton.invalidate(); + this.pauseButton.setText(state.paused ? '▶ Resume' : '❚❚ Pause'); + this.pauseButton.setEnabled(!editing); + this.pauseButton.invalidate(); this.projectName.setEnabled(editing); this.sceneButton.setEnabled(editing); this.status.setText(SAVE_LABELS[state.saveStatus]); diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 698aaaa3..c51a0e42 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -6,6 +6,7 @@ import type { EditorState } from './state'; export interface ShortcutActions { play(): void; stop(): void; + togglePause(): void; } /** @@ -42,8 +43,11 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId); return s !== null; } - if (mod && event.key === 'Enter' && state.mode === 'edit') { - actions.play(); + // Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from + // the game, which owns the keyboard while playing. + if (mod && event.key === 'Enter') { + if (state.mode === 'edit') actions.play(); + else actions.togglePause(); return true; } return false; diff --git a/src/editor/state.ts b/src/editor/state.ts index 0ec90194..c903bab7 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -76,6 +76,12 @@ export class EditorState { commands = new CommandStack(this.project); selection: Selection = null; mode: EditorMode = 'edit'; + /** + * Play is on but the world is not being ticked. A sub-state of `mode === 'play'` rather than a + * third mode: everything that distinguishes playing from editing (the runtime copy, the + * keyboard, edits not being saved) is just as true while paused. + */ + paused = false; saveStatus: SaveStatus = 'saved'; /** Last user-facing message (errors, hints). */ notice = ''; @@ -101,6 +107,7 @@ export class EditorState { loadProject(project: Project, revision: number): void { this.edit = null; this.mode = 'edit'; + this.paused = false; this.project = project; this.revision = revision; this.commands = new CommandStack(project); @@ -124,11 +131,19 @@ export class EditorState { this.project = copy; this.commands = new CommandStack(copy); this.mode = 'play'; + this.paused = false; this.emit('mode'); this.emit('project'); return copy; } + /** Ignored outside play mode. */ + setPaused(paused: boolean): void { + if (this.mode !== 'play' || this.paused === paused) return; + this.paused = paused; + this.emit('mode'); + } + /** Leaves play mode: the runtime copy is discarded, the saved model comes back unchanged. */ exitPlay(): void { if (this.mode !== 'play') return; @@ -138,6 +153,7 @@ export class EditorState { this.edit = null; } this.mode = 'edit'; + this.paused = false; this.emit('mode'); this.emit('project'); this.validateSelection(); diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 3dd052bc..34581388 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -2,8 +2,8 @@ import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/gra import { newId } from '../core/ids'; import type { Asset } from '../core/model/Asset'; import { isPlaceableType } from '../core/model/AssetTypes'; -import { bakeInstances, materializeInstance } from '../core/model/instance'; -import { findObject, treeOrder } from '../core/model/hierarchy'; +import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; +import { treeOrder } from '../core/model/hierarchy'; import { descendantsBelow } from '../core/model/walk'; import { findAsset, findScene, type Project } from '../core/model/Project'; import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; @@ -16,8 +16,8 @@ import type { System } from './systems/System'; export interface WorldOptions { /** * Whether a placed container's content may be written into the scene (see `syncFromModel`). - * Baking is a real, irreversible edit, so the editor run switches it off – at the price of - * behaviors inside a placed container not running there. + * Baking is a real, irreversible edit, so the editor run switches it off; its content still runs, + * but what it writes goes into the instance edits and it cannot destroy itself for good. */ bakeContainers?: boolean; } @@ -31,11 +31,11 @@ export interface ModelWrite { /** * The running game: entities instantiated from one scene plus the systems ticking them. * - * 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. + * The world mirrors the scene of the project it was built from: while playing that is a copy of + * the model, thrown away on stop; the editor run builds on the saved one, where the writes stay. + * `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, so the panels show live values. */ export class World { readonly entities: Entity[] = []; @@ -93,14 +93,15 @@ export class World { syncFromModel(): void { const scene = findScene(this.project, this.sceneId); if (!scene) return; - // Entities are built from stored objects, and a behavior may move or destroy one of them – - // which content resolved from a container template could not remember. So it is written out - // once, into the play-mode copy of the model that is thrown away afterwards. + // Baking spells a placed container's content out as stored objects, which is what lets a + // behavior destroy one of them for good. Play does it (on its throwaway copy); the editor run + // must not, so there the content stays resolved and `objectRef` writes into the instance edits. if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true; const background = scene.props.backgroundColor; this.backgroundColor = typeof background === 'number' ? background : 0x000000; + const objects = new Map(resolveObjects(this.project, scene).map((o) => [o.id, o])); const keep = new Set(); - for (const object of scene.objects) { + for (const object of objects.values()) { const asset = findAsset(this.project, object.assetId); if (!asset || !isPlaceableType(asset.type)) continue; keep.add(this.materialize(object, asset)); @@ -109,7 +110,7 @@ export class World { 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 = findObject(scene, entity.objectId); + const object = objects.get(entity.objectId); entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null; } } @@ -179,7 +180,7 @@ export class World { /** * 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). + * entities (their objects are dropped – only stored ones, see `WorldOptions.bakeContainers`). */ syncToModel(): ModelWrite { const scene = findScene(this.project, this.sceneId); @@ -194,21 +195,22 @@ export class World { const asset = findAsset(this.project, object.assetId); if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1); } - for (const entity of this.entities) { - const object = findObject(scene, entity.objectId); - const asset = object && findAsset(this.project, entity.assetId); - if (!object || !asset) continue; + for (const object of resolveObjects(this.project, scene)) { + const entity = this.findByObjectId(object.id); + const asset = entity && findAsset(this.project, entity.assetId); + if (!entity || !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 }; - values = true; - } + const moved = p.x !== entity.x || p.y !== entity.y || p.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; - values = true; - } + const drifted = Object.keys(entity.props).filter((k) => k in base && base[k] !== entity.props[k]); + if (!moved && drifted.length === 0) continue; + // Only now, because a ref for resolved content costs a resolve of its own – and because a + // direct write to such an object would land in the freshly built copy and be lost. + const ref = objectRef(this.project, scene, object.id); + if (!ref) continue; + if (moved) ref.setPosition({ x: entity.x, y: entity.y, z: entity.z }); + for (const key of drifted) ref.setOverride(key, entity.props[key]); + values = true; } return { structure: spawned || scene.objects.length !== before, values }; } @@ -227,6 +229,11 @@ export class World { this.elapsed += dt; for (const system of this.systems) system.update(this, dt); // The pointer edges belong to exactly one tick, whatever the systems did with them. + this.dropPointerEdges(); + } + + /** Forgets a pending click edge without running a tick (used while paused). */ + dropPointerEdges(): void { this.pointer.pressed = false; this.pointer.released = false; } diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts index a23a253b..3469d701 100644 --- a/src/runtime/systems/Input.ts +++ b/src/runtime/systems/Input.ts @@ -1,17 +1,22 @@ /** - * Keyboard state tracker for play mode. Records which keys are held and which went down / + * Keyboard state tracker for a running world. Records which keys are held and which went down / * up since the last `endFrame()`, so systems can react to edges as well as to held keys. */ export class Input { private readonly down = new Set(); private readonly pressed = new Set(); private readonly released = new Set(); + /** See `attach`: while this answers false the keys belong to someone else. */ + private accepts: () => boolean = () => true; private readonly onKeyDown = (e: KeyboardEvent) => { if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return; + if (!this.accepts()) return; this.press(e.key); if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault(); }; - private readonly onKeyUp = (e: KeyboardEvent) => this.release(e.key); + private readonly onKeyUp = (e: KeyboardEvent) => { + if (this.accepts()) this.release(e.key); + }; /** Losing the window releases every key, so nothing stays "held" behind the user's back. */ private readonly onBlur = () => this.releaseAll(); @@ -32,16 +37,26 @@ export class Input { this.down.clear(); } - attach(): void { + /** + * Starts listening on the window. `accepts` decides per event whether the key belongs to this + * world at all, so a rejected key keeps its default and reaches the editor instead. A gate that + * closes while a key is held leaves it held: the caller has to `releaseAll()` on the change. + */ + attach(accepts: () => boolean = () => true): void { + this.accepts = accepts; + if (typeof window === 'undefined') return; // headless: tests drive the state machine directly window.addEventListener('keydown', this.onKeyDown); window.addEventListener('keyup', this.onKeyUp); window.addEventListener('blur', this.onBlur); } detach(): void { - window.removeEventListener('keydown', this.onKeyDown); - window.removeEventListener('keyup', this.onKeyUp); - window.removeEventListener('blur', this.onBlur); + if (typeof window !== 'undefined') { + window.removeEventListener('keydown', this.onKeyDown); + window.removeEventListener('keyup', this.onKeyUp); + window.removeEventListener('blur', this.onBlur); + } + this.accepts = () => true; this.down.clear(); this.pressed.clear(); this.released.clear(); diff --git a/src/viewport/GameViewport.ts b/src/viewport/GameViewport.ts index 13bf87dd..5ebac780 100644 --- a/src/viewport/GameViewport.ts +++ b/src/viewport/GameViewport.ts @@ -16,22 +16,30 @@ const LETTERBOX_COLOR = 0x000000; export class GameViewport extends WorldViewport { private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') }); + private readonly editorRunCaption = new Text({ + text: 'Behaviors run here · keys and clicks go to the game', + style: textStyle('muted'), + }); private playWorld: World | null = null; + /** The editor run's world: it takes the mouse, but it is never what this view draws. */ + private editorWorld: World | null = null; constructor(state: EditorState) { super(state); this.hint.eventMode = 'none'; this.hint.anchor.set(0.5); this.addChild(this.hint); + this.editorRunCaption.eventMode = 'none'; + this.addChild(this.editorRunCaption); this.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button !== 0) return; this.reportPointer(e); - this.playWorld?.setPointerDown(true); + this.pointerWorld()?.setPointerDown(true); }); this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e)); - this.on('pointerup', () => this.playWorld?.setPointerDown(false)); - this.on('pointerupoutside', () => this.playWorld?.setPointerDown(false)); + this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false)); + this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false)); } /** null = not playing. */ @@ -40,6 +48,17 @@ export class GameViewport extends WorldViewport { this.redraw(); } + /** null = no behavior is running outside play mode. */ + setEditorWorld(world: World | null): void { + this.editorWorld = world; + this.redraw(); + } + + /** Where the mouse is reported: the running game, or the behaviors running without it. */ + private pointerWorld(): World | null { + return this.playWorld ?? this.editorWorld; + } + /** * Skipped while the tab is hidden: the preview costs a collect of the whole scene and would * otherwise run on every selection change behind another tab. Showing the tab relayouts the @@ -83,11 +102,14 @@ export class GameViewport extends WorldViewport { protected override beforeItems(view: View | null): void { this.hint.visible = view === null; this.hint.position.set(this.w / 2, this.h / 2); + // Which tab holds the keyboard is otherwise nowhere to be seen. + this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld; + this.editorRunCaption.position.set(8, 6); } /** Outside the camera frame counts as "not in view": that region is letterbox, not game. */ private reportPointer(e: FederatedPointerEvent): void { - const world = this.playWorld; + const world = this.pointerWorld(); const view = this.currentView(); if (!world || !view) return; const local = this.toLocal(e.global); diff --git a/tests/editor/editor-run.test.ts b/tests/editor/editor-run.test.ts index 81402d63..df2c1ba6 100644 --- a/tests/editor/editor-run.test.ts +++ b/tests/editor/editor-run.test.ts @@ -4,13 +4,16 @@ */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; +import { instanceChildId } from '../../src/core/model/instance'; import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; -import type { BehaviorAsset } from '../../src/core/model/Asset'; +import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset'; import { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; import { findAsset } from '../../src/core/model/Project'; import { EditorRun } from '../../src/editor/EditorRun'; +import type { World } from '../../src/runtime/World'; import { EditorState } from '../../src/editor/state'; import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers'; @@ -37,7 +40,12 @@ function scene() { const state = new EditorState(); state.loadProject(f.project, 1); - const run = new EditorRun(state); + /** Stands in for the game view being the focused tab; flipped by the keyboard tests. */ + const keyboard = { focused: false, world: null as World | null }; + const run = new EditorRun(state, { + focused: () => keyboard.focused, + onWorld: (world) => (keyboard.world = world), + }); return { ...f, behavior, @@ -45,6 +53,7 @@ function scene() { logic, state, run, + keyboard, /** One frame, after telling the run that the model may have changed (the Editor's wiring). */ frame(dt = 1 / 60) { run.invalidate(); @@ -54,6 +63,30 @@ function scene() { }; } +/** + * The shape a real level has: the box and its behavior packed into a container asset and placed + * once, so the scene stores the container object alone and the content is resolved (`instance.ts`). + */ +function containerScene() { + const f = scene(); + const create = new CreateContainerCommand(f.sceneId, [f.box]); + f.stack.execute(create); + const asset = () => findAsset(f.project, create.assetId) as ContainerAsset; + const [tBox, tLogic] = asset().objects.map((t) => t.id) as [string, string]; + const child = (templateId: string) => instanceChildId(create.objectId, templateId); + return { + ...f, + containerId: create.assetId, + tBox, + tLogic, + child, + template: (id: string) => asset().objects.find((t) => t.id === id)!, + instanceEdits: () => f.sceneOf().objects[0]!.instanceEdits, + switchOn: () => f.state.execute(new SetOverrideCommand(f.sceneId, child(tLogic), 'runInEditor', true)), + boxX: () => f.object(child(tBox)).position.x, + }; +} + describe('the run-in-editor switch', () => { it('is a behavior property, off unless a scene object asks for it', () => { const f = scene(); @@ -71,7 +104,7 @@ describe('the run-in-editor switch', () => { it('leaves everything alone until it is switched on', () => { const f = scene(); for (let i = 0; i < 10; i++) f.frame(); - expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [] }); + expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [], keyboard: false }); expect(f.boxX()).toBe(0); }); @@ -146,3 +179,74 @@ describe('the run-in-editor switch', () => { expect(f.boxX()).toBeCloseTo(10); }); }); + +describe('a behavior inside a placed container', () => { + it('is found although the scene only stores the container object', () => { + const f = containerScene(); + expect(f.sceneOf().objects).toHaveLength(1); + expect(hasEditorRun(f.project, f.sceneOf())).toBe(false); + + f.switchOn(); + expect(hasEditorRun(f.project, f.sceneOf())).toBe(true); + }); + + it('drives the content, and the move is kept as an instance edit instead of baking it out', () => { + const f = containerScene(); + f.switchOn(); + f.state.setSaveStatus('saved'); + + f.frame(0.1); + expect(f.run.debugInfo().behaviors).toEqual([f.child(f.tLogic)]); + expect(f.boxX()).toBeCloseTo(10); + expect(f.state.saveStatus).toBe('dirty'); + + // Baking would spell the content out as stored objects and drop every instance edit, so what + // moved has to land on the instance – where the template still reaches the rest of it. + expect(f.sceneOf().objects).toHaveLength(1); + expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(10); + + f.frame(0.1); + expect(f.boxX()).toBeCloseTo(20); + expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(20); + }); + + it('leaves the template alone, so a second instance of the same level stays put', () => { + const f = containerScene(); + const second = place(f, f.containerId, { x: 500, y: 0 }); + f.switchOn(); + + f.frame(0.1); + expect(f.boxX()).toBeCloseTo(10); + // The switch is an edit of the first instance only: the same box in the second one has neither + // the behavior running nor the movement. + expect(f.object(instanceChildId(second, f.tBox)).position.x).toBe(0); + expect(f.template(f.tBox).position.x).toBe(0); + }); +}); + +describe('the keyboard of an editor run', () => { + it('is the game view’s, and only while something is running there', () => { + const f = containerScene(); + expect(f.run.debugInfo().keyboard).toBe(false); + + f.switchOn(); + f.frame(0.1); + // Running, but the editor still owns the keys: the game view is not the focused tab. + expect(f.run.debugInfo()).toMatchObject({ running: true, keyboard: false }); + + f.keyboard.focused = true; + expect(f.run.debugInfo().keyboard).toBe(true); + }); + + it('hands the world to the game view while it runs, so clicks can reach it', () => { + const f = containerScene(); + f.switchOn(); + f.frame(0.1); + expect(f.keyboard.world).not.toBeNull(); + + f.state.execute(new ClearOverrideCommand(f.sceneId, f.child(f.tLogic), 'runInEditor')); + f.frame(0.1); + f.frame(0.1); + expect(f.keyboard.world).toBeNull(); + }); +}); diff --git a/tests/editor/pause.test.ts b/tests/editor/pause.test.ts new file mode 100644 index 00000000..cc7fa15f --- /dev/null +++ b/tests/editor/pause.test.ts @@ -0,0 +1,92 @@ +/** + * Pausing play. The hold itself is a sub-state of play mode, so these tests watch that it can + * only exist while playing and that leaving play always clears it – a stuck "paused" would + * leave the editor unable to run anything. + */ +import { describe, expect, it } from 'vitest'; +import { EditorState } from '../../src/editor/state'; +import { makeSpriteScene, place, startWorld } from '../helpers'; + +function playing() { + const f = makeSpriteScene('Box'); + place(f, f.spriteId, { x: 0, y: 0 }); + const state = new EditorState(); + state.loadProject(f.project, 1); + state.enterPlay(); + return { f, state }; +} + +describe('pausing the game', () => { + it('holds and releases while playing', () => { + const { state } = playing(); + expect(state.paused).toBe(false); + state.setPaused(true); + expect(state.paused).toBe(true); + state.setPaused(false); + expect(state.paused).toBe(false); + }); + + it('cannot be paused while editing', () => { + const f = makeSpriteScene('Box'); + const state = new EditorState(); + state.loadProject(f.project, 1); + state.setPaused(true); + expect(state.paused).toBe(false); + }); + + it('tells the toolbar so its button can change label', () => { + const { state } = playing(); + let changes = 0; + state.on('mode', () => (changes += 1)); + state.setPaused(true); + expect(changes).toBe(1); + // Setting the same value again is not a change and must not redraw. + state.setPaused(true); + expect(changes).toBe(1); + }); + + it('is over when play is over, so the next run starts moving', () => { + const { state } = playing(); + state.setPaused(true); + state.exitPlay(); + expect(state.paused).toBe(false); + state.enterPlay(); + expect(state.paused).toBe(false); + }); + + it('does not survive loading another project', () => { + const { state } = playing(); + state.setPaused(true); + state.loadProject(makeSpriteScene('Other').project, 1); + expect(state.paused).toBe(false); + }); +}); + +describe('the world while it is not ticked', () => { + it('keeps its clock and its positions', () => { + const f = makeSpriteScene('Box'); + place(f, f.spriteId, { x: 0, y: 0 }); + const { world } = startWorld(f); + world.tick(0.5); + const box = world.entities[0]!; + const time = world.time; + const x = box.x; + // What a paused frame does: no tick at all. + expect(world.time).toBe(time); + expect(box.x).toBe(x); + world.tick(0.5); + expect(world.time).toBeCloseTo(time + 0.5, 5); + }); + + it('forgets a click that arrived while nothing was ticking', () => { + const f = makeSpriteScene('Box'); + place(f, f.spriteId, { x: 0, y: 0 }); + const { world } = startWorld(f); + world.setPointerDown(true); + expect(world.pointer.pressed).toBe(true); + world.dropPointerEdges(); + expect(world.pointer.pressed).toBe(false); + // The button is still held – only the one-frame edge is gone. + expect(world.pointer.down).toBe(true); + }); +}); diff --git a/todo-notes/done/game-pause.md b/todo-notes/done/game-pause.md new file mode 100644 index 00000000..2e3674b2 --- /dev/null +++ b/todo-notes/done/game-pause.md @@ -0,0 +1,58 @@ +die möglichkeit das spiel zu pausieren und fortzusetzen + +--- + +**Umgesetzt (2026-09-09).** In der Toolbar steht neben *Play* ein zweiter Knopf, der zwischen +**Pause** und **Resume** wechselt; dasselbe macht **`Ctrl+Enter`** waehrend des Spielens. Gehalten +heisst: die Welt wird **nicht mehr getickt**. Damit stehen Behaviors, Timer und Kollisionen still +— und die Weltuhr steht mit ihnen, was fuer `On timer` der eigentliche Punkt ist. + +**Pause ist ein Unterzustand von Play, kein dritter Modus** (`EditorState.paused`). Alles, was +Play von Edit unterscheidet — die Laufzeitkopie, die eigene Undo-Historie, dass nichts gespeichert +wird —, gilt waehrend der Pause unveraendert; ein dritter `EditorMode` haette jede vorhandene +Abfrage `mode === 'play'` zu einer Fallunterscheidung gemacht. Der Zustand wird beim Verlassen von +Play, beim Betreten und beim Laden eines Projekts geloescht: **eine Pause kann ihren Lauf nie +ueberleben**, sonst stuende der Editor still und niemand wuesste warum. + +**Eingaben waehrend der Pause werden verworfen, nicht gesammelt.** Ein gehaltener Frame ruft +weiterhin `input.endFrame()` und `world.dropPointerEdges()` auf. Ohne das haetten sich die +Tastatur- und Klick-Flanken der ganzen Pause angestaut und waeren beim Fortsetzen in einem +einzigen Frame losgegangen — aus zehn Sekunden Pause waere ein Sprung geworden, den niemand +ausgeloest hat. + +**Warum `Ctrl+Enter` und kein Buchstabe:** waehrend des Spielens gehoert die Tastatur dem Spiel. +Ein `P` oder die Leertaste waere dem Spiel dauerhaft weggenommen. `Ctrl+Enter` startet Play schon +bisher, haelt es jetzt und laesst es weiterlaufen; `Esc` stoppt wie gehabt. + +**Belegt** — sieben Unit-Tests (`tests/editor/pause.test.ts`; ohne die Regeln fallen drei davon +um, gegengeprueft) und neun E2E-Checks in Modul 18. Der Test-Hook `__gm.playInfo()` nennt dafuer +neu die **Weltuhr**, sonst waere "steht wirklich still" nicht von "sieht gerade gleich aus" zu +unterscheiden: + +``` +PASS the box drifts on its own while playing, with nothing held down + – x 46.66 → 98.33, {"playing":true,"paused":false,"time":0.4666} +PASS pausing stops the world where it is: the box and the clock both stand still + – x 99.99 → 99.99, clock 1.00 → 1.00 over 1 s +PASS resuming lets it move on from where it stopped, without catching up the held time + – x 99.99 → 141.65, clock 1.00 → 1.40 +PASS a key pressed while paused is swallowed instead of firing on resume + – 0 hits before, 0 while held, 0 after resuming +PASS the same key does fire once the game is running again – 0 → 1 +PASS stopping clears the hold, so the next play runs + – after stop {"playing":false,"paused":false,"time":0}, x 96.66 → 138.33 +``` + +Der erste Check ist die Voraussetzung des zweiten: ohne nachgewiesene Bewegung waere "steht still" +trivial gruen. Der letzte Check derselbe Gedanke fuer die Tastatur — ohne ihn waere "keine Taste +hat gefeuert" auch dann gruen, wenn der Graph ueberhaupt nie feuert. Die Uhr im dritten Check ist +der Beweis gegen das Nachholen: sie laeuft bei **1.00** weiter, nicht bei den ~3.5, die seit dem +Anhalten vergangen sind. + +**Offen geblieben — bewusst.** Ein **`Pause game`-Node** fuer den Graphen (passend zu `Stop game` +und `Restart game`) waere der naechste Schritt, damit ein Spiel sich selbst anhalten kann +(Pausenmenue, Game Over). Ich habe dazu gefragt, aber keine Antwort bekommen, und einen +game-facing Node nicht im Alleingang eingefuehrt: ein Node ist dauerhafte API, und ohne einen +Weg zurueck **aus dem Spiel heraus** waere ein so pausiertes Spiel nur ueber die Toolbar wieder +zu starten. Beides — Node und ein fester Weiter-Weg im Spiel — laesst sich auf dem jetzigen +Stand billig nachziehen: `state.setPaused(true)` ist alles, was ein Node tun muesste. diff --git a/todo-notes/game-pause.md b/todo-notes/game-pause.md deleted file mode 100644 index f4f9fbfd..00000000 --- a/todo-notes/game-pause.md +++ /dev/null @@ -1 +0,0 @@ -die möglichkeit das spiel zu pausieren und fortzusetzen diff --git a/tools/e2e/17-editor-run.mjs b/tools/e2e/17-editor-run.mjs index 740b3927..e2bfc605 100644 --- a/tools/e2e/17-editor-run.mjs +++ b/tools/e2e/17-editor-run.mjs @@ -4,12 +4,28 @@ import { edge, node } from '../lib/builders.mjs'; * "Run in editor": a behavior that keeps running while the project is not playing, and whose * work lands in the saved project. * - * The step brings its own box and its own behavior instead of using the fixture's Mover: that one - * moves along the arrow keys, and the editor run deliberately never sees the keyboard. Everything - * it adds is removed again at the end. + * The step brings its own box and its own behavior instead of using the fixture's Mover, so that + * the drifting checks measure nothing but time. Everything it adds is removed again at the end. */ const DRIFT_PER_SECOND = 100; +const WALK_PER_SECOND = 200; +/** Must stay in step with `GameViewport.editorRunCaption`. */ +const EDITOR_RUN_CAPTION = 'Behaviors run here · keys and clicks go to the game'; + +/** `On key held(ArrowRight) → Move(owner)`: moves only while that key reaches the world. */ +const walkGraph = { + nodes: [ + node('k-held', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' }), + node('k-owner', 'Owner', 0, 120), + node('k-move', 'Move', 340, 0, { dx: 1, scale: WALK_PER_SECOND }), + ], + edges: [ + edge('k-e1', 'k-held', 'exec', 'k-move', 'exec'), + edge('k-e2', 'k-held', 'dt', 'k-move', 'dt'), + edge('k-e3', 'k-owner', 'object', 'k-move', 'object'), + ], +}; /** `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts right, without any input. */ const driftGraph = { @@ -26,7 +42,7 @@ const driftGraph = { }; export async function editorRun(t) { - const { check, sleep, evaluate, json, shot, click, mutate, undo, object, api, projectId } = t; + const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId } = t; // 18a. A box with a drifting behavior below it. Written into the model directly: what is under // test is the switch, not another way of building a graph. @@ -166,15 +182,153 @@ export async function editorRun(t) { `overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`, ); + // 18g. The case a real project has: the behavior sits inside a *placed container* – a level + // packed up once and dropped into the scene. The scene stores the container object alone, + // so this only works if the run resolves the template instead of expecting stored objects. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; - scene.objects = scene.objects.filter((o) => o.id !== 'er-box' && o.id !== 'er-logic'); - p.assets = p.assets.filter((a) => a.id !== 'drifter'); + const proto = scene.objects[0]; + const make = (id, assetId, parentId, y) => { + const o = structuredClone(proto); + Object.assign(o, { + id, + assetId, + name: '', + position: { x: 0, y, z: 0 }, + parentId, + overrides: {}, + instanceEdits: {}, + }); + return o; + }; + p.assets.push({ + id: 'er-level', + type: 'container', + name: 'Editor level', + props: { enabled: true }, + objects: [make('t-box', 'crate', '', 800), make('t-logic', 'drifter', 't-box', 0)], + }); + const instance = make('er-inst', 'er-level', '', 0); + instance.instanceEdits = { 't-logic': { position: {}, overrides: { runInEditor: true } } }; + scene.objects.push(instance); + `); + await sleep(600); + + const packedX = async () => (await object('er-inst~t-box')).position.x; + const packedStart = await packedX(); + const packedRun = await json('__gm.editorRun()'); + await sleep(600); + const packedNow = await packedX(); + check( + 'a behavior inside a placed container runs too, although the scene stores no object for it', + packedRun.behaviors.includes('er-inst~t-logic') && packedNow - packedStart > DRIFT_PER_SECOND * 0.3, + `${JSON.stringify(packedRun.behaviors)} x ${packedStart} → ${packedNow}`, + ); + + const stored = await json(`__gm.state.activeScene.objects.map((o) => o.id)`); + const edits = await json(`__gm.state.activeScene.objects.find((o) => o.id === 'er-inst').instanceEdits`); + const template = await json( + `__gm.state.project.assets.find((a) => a.id === 'er-level').objects.find((o) => o.id === 't-box').position`, + ); + check( + 'and what it moved is kept as an instance edit – the instance is not baked out, the template not touched', + stored.includes('er-inst') && + !stored.some((id) => id.startsWith('er-inst~')) && + edits['t-box'].position.x > packedStart && + template.x === 0, + `stored=${JSON.stringify(stored.filter((id) => id.startsWith('er-')))} edit.x=${edits['t-box']?.position?.x} template.x=${template.x}`, + ); + + // 18h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the + // arrow keys walking the hierarchy must not steer a game at the same time. The game tab is + // where no key means anything to the editor, so that is where a behavior may have them. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const proto = scene.objects[0]; + const asset = structuredClone(p.assets.find((a) => a.id === 'drifter')); + Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} }); + p.assets.push(asset); + const make = (id, assetId, parentId, y, overrides) => { + const o = structuredClone(proto); + Object.assign(o, { + id, + assetId, + name: '', + position: { x: 0, y, z: 0 }, + parentId, + overrides, + instanceEdits: {}, + }); + return o; + }; + scene.objects.push( + make('er-walkbox', 'crate', '', 1200, {}), + make('er-walker', 'walker', 'er-walkbox', 0, { runInEditor: true }), + ); + `); + await sleep(600); + + const walkX = async () => (await object('er-walkbox')).position.x; + const onScene = await json('__gm.editorRun()'); + const sceneBefore = await walkX(); + await holdKey('ArrowRight', 500); + await sleep(200); + check( + 'a key pressed while the scene is on screen belongs to the editor, not to the run', + onScene.running === true && + onScene.keyboard === false && + (await json('__gm.tab()')) === 'scene' && + (await walkX()) === sceneBefore, + `${JSON.stringify(onScene)} x=${sceneBefore} → ${await walkX()}`, + ); + + await evaluate(`__gm.showTab('game')`); + await sleep(300); + // Clicking the view first: a checkbox left focused by an earlier check would still want the keys. + const gameCentre = await json('__gm.gameCenter()'); + await click(gameCentre.x, gameCentre.y); + await sleep(200); + const onGame = await json('__gm.editorRun()'); + const gameBefore = await walkX(); + await holdKey('ArrowRight', 500); + await sleep(200); + const gameAfter = await walkX(); + check( + 'in the game tab the same key reaches the behavior and the box walks', + onGame.keyboard === true && gameAfter - gameBefore > WALK_PER_SECOND * 0.3, + `${JSON.stringify(onGame)} x ${gameBefore} → ${gameAfter}`, + ); + const caption = await json(`__gm.locate(${JSON.stringify(EDITOR_RUN_CAPTION)})`); + check( + 'and the tab says so, instead of leaving the keyboard a guess', + caption !== null, + JSON.stringify(caption), + ); + await shot('37-editor-run-keys'); + + await evaluate(`__gm.showTab('scene')`); + await sleep(300); + const backOnScene = await walkX(); + await holdKey('ArrowRight', 400); + await sleep(200); + check( + 'leaving the tab takes the keyboard back, and a key held across it does not stay held', + (await json('__gm.editorRun()')).keyboard === false && (await walkX()) === backOnScene, + `x=${backOnScene} → ${await walkX()}`, + ); + + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('er-')); + p.assets = p.assets.filter((a) => !['drifter', 'walker', 'er-level'].includes(a.id)); `); await sleep(300); check( 'the step leaves nothing of its own behind', - (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0, + (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0 && + (await json('__gm.editorRun()')).running === false, ); } diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs new file mode 100644 index 00000000..eb6324c3 --- /dev/null +++ b/tools/e2e/18-pause.mjs @@ -0,0 +1,185 @@ +import { edge, node } from '../lib/builders.mjs'; + +/** + * Pausing and resuming play: the world stops where it is, the clock stops with it, and input that + * arrives while it is held is dropped instead of firing all at once on resume. + * + * The step brings its own box and its own behavior. The fixture's Mover needs the arrow keys to + * move at all, so a hold would be indistinguishable from simply not pressing anything; this one + * drifts on its own. Everything it adds is removed again at the end. + */ + +const DRIFT_PER_SECOND = 100; +const KEY_LOG = 'arrow down'; + +/** + * Two independent things in one graph: `On update → Move` drifts the owner right without any + * input, and `On key down → Log` leaves a mark for every *edge* the world saw – which is what a + * pause has to swallow. + */ +const pauseGraph = { + nodes: [ + node('p-update', 'OnUpdate', 0, 0), + node('p-owner', 'Owner', 0, 120), + node('p-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }), + node('p-key', 'OnKeyDown', 0, 260, { key: 'ArrowRight' }), + node('p-log', 'Log', 340, 260, { message: KEY_LOG }), + ], + edges: [ + edge('p-e1', 'p-update', 'exec', 'p-move', 'exec'), + edge('p-e2', 'p-update', 'dt', 'p-move', 'dt'), + edge('p-e3', 'p-owner', 'object', 'p-move', 'object'), + edge('p-e4', 'p-key', 'exec', 'p-log', 'exec'), + ], +}; + +export async function pause(t) { + const { check, sleep, json, shot, clickText, press, entity, world, mutate, stop } = t; + const playInfo = () => json('__gm.playInfo()'); + const boxX = async () => (await entity('p-box')).x; + const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length; + + // 19a. A box with a drifting behavior below it, written into the model directly: what is under + // test is the pause, not another way of building a graph. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const proto = scene.objects[0]; + const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); + Object.assign(asset, { + id: 'pauser', + name: 'Pauser', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: ${JSON.stringify(pauseGraph)}, + }); + p.assets.push(asset); + const make = (id, assetId, parentId, y) => { + const o = structuredClone(proto); + Object.assign(o, { + id, + assetId, + name: id === 'p-box' ? 'Pause box' : 'Pauser', + position: { x: 0, y, z: 0 }, + parentId, + overrides: {}, + instanceEdits: {}, + }); + return o; + }; + scene.objects.push(make('p-box', 'crate', '', 300), make('p-logic', 'pauser', 'p-box', 0)); + `); + await sleep(400); + + await t.play(); + const running = await playInfo(); + const beforeDrift = await boxX(); + await sleep(500); + const drifted = await boxX(); + // The precondition of everything below: without movement, "it stood still" is trivially true. + check( + 'the box drifts on its own while playing, with nothing held down', + running.playing === true && running.paused === false && drifted > beforeDrift + 20, + `x ${beforeDrift} → ${drifted}, ${JSON.stringify(running)}`, + ); + + // 19b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps + // ticking while the picture happens to look the same would pass a position check alone. + await clickText('❚❚ Pause'); + await sleep(200); + const held = await playInfo(); + const heldX = await boxX(); + await sleep(1000); + const stillHeld = await playInfo(); + check( + 'pausing stops the world where it is: the box and the clock both stand still', + held.paused === true && + stillHeld.playing === true && + Math.abs((await boxX()) - heldX) < 0.001 && + Math.abs(stillHeld.time - held.time) < 0.05, + `x ${heldX} → ${await boxX()}, clock ${held.time.toFixed(2)} → ${stillHeld.time.toFixed(2)} over 1 s`, + ); + // `locate` throws on a missing label, so the button that must be *gone* is asked for through + // the hook, which answers with null. + const labelShown = (text) => json(`__gm.locate(${JSON.stringify(text)}) !== null`); + check( + 'the button offers to resume while it is held, and no longer to pause', + (await labelShown('▶ Resume')) === true && (await labelShown('❚❚ Pause')) === false, + `resume=${await labelShown('▶ Resume')} pause=${await labelShown('❚❚ Pause')}`, + ); + await shot('36-paused'); + + // 19c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for + // each of them. The count before proves the graph reacts at all. + const hitsBefore = await keyHits(); + await press('ArrowRight'); + await sleep(1000); + const hitsWhilePaused = await keyHits(); + await press('ArrowRight'); + await press('ArrowRight'); + await sleep(200); + + // 19d. Ctrl+Enter resumes, the same shortcut that started play. + await press('Enter', { ctrl: true }); + await sleep(400); + const resumed = await playInfo(); + const resumedX = await boxX(); + check( + 'resuming lets it move on from where it stopped, without catching up the held time', + resumed.paused === false && + resumedX > heldX && + // A second of pause plus the pressing above: catching up would show as a jump far beyond + // what the resumed run itself had time for. + resumedX < heldX + DRIFT_PER_SECOND * 1.2, + `x ${heldX} → ${resumedX}, clock ${stillHeld.time.toFixed(2)} → ${resumed.time.toFixed(2)}`, + ); + const hitsAfter = await keyHits(); + check( + 'a key pressed while paused is swallowed instead of firing on resume', + hitsWhilePaused === hitsBefore && hitsAfter === hitsBefore, + `${hitsBefore} hits before, ${hitsWhilePaused} while held, ${hitsAfter} after resuming`, + ); + // Without this the check above would also pass on a graph that never logs anything at all. + await press('ArrowRight'); + await sleep(300); + check( + 'the same key does fire once the game is running again', + (await keyHits()) === hitsBefore + 1, + `${hitsBefore} → ${await keyHits()}`, + ); + + // 19e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts + // moving. A pause left standing would leave the editor unable to run anything. + await press('Enter', { ctrl: true }); + await sleep(200); + check('Ctrl+Enter holds it again', (await playInfo()).paused === true); + await stop(); + const afterStop = await playInfo(); + await t.play(); + await sleep(500); + const restartedX = await boxX(); + await sleep(400); + check( + 'stopping clears the hold, so the next play runs', + afterStop.playing === false && + afterStop.paused === false && + (await playInfo()).paused === false && + (await boxX()) > restartedX, + `after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`, + ); + await stop(); + await sleep(200); + + // 19f. Play discarded its copy, so the box is back at the position the model holds. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'p-box' && o.id !== 'p-logic'); + p.assets = p.assets.filter((a) => a.id !== 'pauser'); + `); + await sleep(300); + check( + 'the step leaves nothing of its own behind', + (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('p-')).length`)) === 0, + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 770d9f31..31eaef95 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -20,6 +20,7 @@ import { overflow } from './14-overflow.mjs'; import { aspect } from './15-aspect.mjs'; import { containerTab } from './16-container.mjs'; import { editorRun } from './17-editor-run.mjs'; +import { pause } from './18-pause.mjs'; export const STEPS = [ hierarchy, @@ -39,4 +40,5 @@ export const STEPS = [ aspect, containerTab, editorRun, + pause, ];