diff --git a/README.md b/README.md index cdd508ba..3b6f223b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2049 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2056 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index aadb05b7..08422b5f 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -399,4 +399,6 @@ export interface NodeContext { startRecording(settings: RecordingSettings): void; /** Ends the recording and saves it; without one, only the log hears of it. */ stopRecording(): void; + /** Pauses the editor's Play after this frame, as its pause button does; elsewhere nothing. */ + breakpoint(): void; } diff --git a/src/core/graph/nodes/action/gameTimeNodes.ts b/src/core/graph/nodes/action/gameTimeNodes.ts index c8cff0c2..f759dcfe 100644 --- a/src/core/graph/nodes/action/gameTimeNodes.ts +++ b/src/core/graph/nodes/action/gameTimeNodes.ts @@ -65,6 +65,23 @@ export const ResumeGame: NodeDef = { }, }; +export const Break: NodeDef = { + type: 'Break', + label: 'Break', + category: 'action', + description: + "Pauses the editor's Play once this frame is done, as the pause button in the top bar does, to " + + 'look at the game the moment something happens. Next frame steps on from there, Ctrl+Enter ' + + 'resumes. A released game and the editor run go past it.', + inputs: [exec()], + outputs: [exec()], + params: [], + run: (ctx) => { + ctx.breakpoint(); + return { next: 'exec' }; + }, +}; + export const IsGamePaused: NodeDef = { type: 'IsGamePaused', label: 'Is game paused', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index a311c454..b2d38400 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -4,7 +4,7 @@ import { Destroy } from './action/Destroy'; import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes'; import { PlayComposition, StopComposition } from './action/compositionNodes'; import { Emit } from './action/Emit'; -import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes'; +import { Break, IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes'; import { LoadScene } from './action/LoadScene'; import { Log } from './action/Log'; import { IgnoreOneWay } from './action/IgnoreOneWay'; @@ -334,6 +334,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ PauseGame, ResumeGame, IsGamePaused, + Break, SetTimeScale, TimeScale, ]; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 65bd886a..011cbbcf 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -13,7 +13,7 @@ import type { VideoStore } from '../core/store/VideoStore'; import { StreamKeeper } from './sound/StreamKeeper'; import type { TextureStore } from '../core/store/TextureStore'; import type { UsersStore } from '../core/store/UsersStore'; -import { gameClock, timeStep } from '../core/time'; +import { FIXED_STEP, gameClock, timeStep } from '../core/time'; import { clamp, HStack, @@ -482,6 +482,7 @@ export class Editor implements PlayHost { ]), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), togglePause: () => this.play.togglePause(), + step: () => this.play.step(), openAccountMenu: (anchor) => this.openAccountMenu(anchor, account), }); root.add(this.toolbar); @@ -618,6 +619,7 @@ export class Editor implements PlayHost { play: () => this.play.play(), stop: () => this.play.stop(), togglePause: () => this.play.togglePause(), + step: () => this.play.step(), copy: () => (this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) || (this.keyTab() === 'pattern' && this.patternEditor.canvas.copy()) || @@ -1404,8 +1406,12 @@ export class Editor implements PlayHost { // animation clocks moved, so the global time scale cannot pull them apart. // The game's own pause holds the clock as well, so particles and shaders freeze with the world, // and so does a run that waits for its videos at the start (#404). - const step = gameClock.advance(dt, this.state.paused || this.play.isGamePaused || this.play.isWaiting); - this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused)); + // A single step out of the pause moves by one fixed step, however long the frame took. + const stepping = this.play.beginFrame(); + const frame = stepping ? FIXED_STEP : dt; + const held = this.state.paused && !stepping; + const step = gameClock.advance(frame, held || this.play.isGamePaused || this.play.isWaiting); + this.play.tick(step, timeStep(frame, gameClock.scale, held)); this.editorRun.tick(step); this.viewport.animate(sceneDraws); this.gameView.animate(gameDraws); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 7330b379..5e0eb177 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -81,6 +81,11 @@ export class PlaySession { private videoWait: number | null = null; /** Records the game view for `Start recording` (#405); one for the editor, so it outlives a restart. */ private readonly recorder: GameRecorder; + /** `step` asked for a frame; `stepping` is set for the frame that takes it. */ + private stepAsked = false; + private stepping = false; + /** The object whose `Break` node paused the run, until it is resumed or stepped on. */ + private breakAt: string | null = null; constructor( private readonly state: EditorState, @@ -126,18 +131,44 @@ export class PlaySession { return this.videoWait !== null; } - /** Pause or resume; does nothing while not playing. */ + /** Pause or resume; before Play it switches whether Play starts paused. */ togglePause(): void { - if (!this.world) return; + if (!this.world) { + if (this.state.mode === 'edit') this.state.startPaused = !this.state.startPaused; + return; + } this.state.setPaused(!this.state.paused); + this.breakAt = null; + this.applyPause(true); + ui().focus.blur(); + } + + /** Lets a paused run on by exactly one frame, at the next tick; does nothing unless paused. */ + step(): void { + if (this.isPaused) this.stepAsked = true; + } + + /** + * Called before the clock moves: true when this frame is the one `step` asked for. It then moves + * the run by one fixed step, whatever the frame took, and stays paused afterwards. + */ + beginFrame(): boolean { + this.stepping = this.stepAsked && this.isPaused; + this.stepAsked = false; + if (this.stepping) this.breakAt = null; + return this.stepping; + } + + /** Puts sounds, videos, the recording and the panels in line with the toolbar's pause. */ + private applyPause(flush: boolean): void { + const { paused } = this.state; // What you inspect while paused is exact, whatever the panels were holding back. - if (this.state.paused) this.follow(this.panels.flush()); + if (paused && flush) this.follow(this.panels.flush()); // A held note is part of the picture a pause freezes: the sounds stop where they are and carry // on mid-note, rather than playing to their end over a still image. - soundLibrary.setSuspended(this.state.paused); - videoLibrary.setPaused(this.state.paused); - this.recorder.setPaused(this.state.paused); - ui().focus.blur(); + soundLibrary.setSuspended(paused); + videoLibrary.setPaused(paused); + this.recorder.setPaused(paused); this.state.setNotice(this.playNotice(), 'quiet'); } @@ -169,7 +200,8 @@ export class PlaySession { return this.behaviors?.liveVariables(assetId) ?? null; } - play(): void { + /** `paused`: whether the run starts held; a `Restart game` keeps what the run had instead. */ + play(paused = this.state.startPaused): void { const scene = this.state.sceneToPlay(); if (!scene) return; // The editor loads the engine right after opening, so it is almost always there already; a game @@ -191,6 +223,7 @@ export class PlaySession { saves: saveArea(runtime.id, 'play'), recorder: this.recorder, pictures: { missing: (id) => this.host.gameView.missesVideoOf(id) }, + breakpoint: (where) => (this.breakAt ??= where), }; this.run = GameRun.start(runtime, scene.id, this.input, services); videoLibrary.startRun(); @@ -201,7 +234,10 @@ export class PlaySession { this.host.gameView.setWorld(this.run.world); this.host.showGame(); this.waitForVideos(); - this.state.setNotice(this.playNotice(), 'quiet'); + if (paused) { + this.state.setPaused(true); + this.applyPause(false); + } else this.state.setNotice(this.playNotice(), 'quiet'); if (this.run.world.stopRequested) this.stop(); } @@ -248,7 +284,10 @@ export class PlaySession { private playNotice(): string { if (this.videoWait !== null) return 'Loading the videos… · Esc stops'; - if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops'; + if (this.state.paused) { + const why = this.breakAt === null ? 'Paused' : `Break in ${this.breakAt}`; + return `${why} · Ctrl+Enter resumes · Ctrl+Shift+Enter steps · Esc stops`; + } if (this.world?.paused) return 'The game paused itself · Esc stops'; return this.world?.activeCamera() ? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops' @@ -268,6 +307,8 @@ export class PlaySession { soundLibrary.stopAll(); videoLibrary.endRun(); this.videoWait = null; + this.stepAsked = false; + this.breakAt = null; const world = this.world; this.run = null; this.input.detach(); @@ -289,7 +330,7 @@ export class PlaySession { const run = this.run; if (!run) return; const world = run.world; - if (this.state.paused || this.waitsForVideos()) { + if ((this.state.paused && !this.stepping) || this.waitsForVideos()) { // 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(); @@ -305,12 +346,14 @@ export class PlaySession { return; } if (request === 'restart') { + const held = this.state.paused || this.breakAt !== null; this.stop(true); - this.play(); + this.play(held); return; } if (request === 'scene') { this.sceneLoaded(run); + this.pauseAtBreak(); return; } // Mirror what the systems did into the runtime copy every frame, even while the panels wait: an @@ -321,8 +364,22 @@ export class PlaySession { performance.now(), structure, ); - // A game that pauses itself is looked at like one paused from the toolbar. - this.follow(world.paused && !wasPaused ? this.panels.flush() : update); + // A game that pauses itself is looked at like one paused from the toolbar, and so is a step. + this.follow((world.paused && !wasPaused) || this.stepping ? this.panels.flush() : update); + this.pauseAtBreak(); + } + + /** + * A `Break` node ran this frame: the run holds as if the pause button had been pressed. A step + * stays held anyway, and the notice says whether it ran into a break. + */ + private pauseAtBreak(): void { + if (this.state.paused) { + if (this.stepping) this.state.setNotice(this.playNotice(), 'quiet'); + } else if (this.breakAt !== null) { + this.state.setPaused(true); + this.applyPause(true); + } } /** diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 917e9c05..10924424 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -124,9 +124,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E playInfo: () => editor.playInfo(), /** Which scene Play would start, and under which of the two rules (#183: set in the settings). */ playFrom: () => editor.playFromInfo(), - /** The two run buttons of the top bar: the picture each wears, and whether it takes a click. */ + /** The run buttons of the top bar: the picture each wears, and whether it takes a click. */ runButtons: () => editor.runButtons(), - /** Where to click one of the icon buttons: menu, undo, redo, play, pause. */ + /** Where to click one of the icon buttons: menu, undo, redo, play, pause, step. */ toolbarAt: (name: keyof Editor['toolbarControls']) => centreOf(editor.toolbarControls[name]), /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ editorRun: () => editor.editorRunInfo(), diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 7d35f9f7..ba91e50f 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -10,8 +10,10 @@ export interface ToolbarActions { openMainMenu(anchor: Widget): void; /** Play in edit mode, Stop in play mode. */ togglePlay(): void; - /** Hold or resume the running world; only reachable in play mode. */ + /** Hold or resume the running world; before Play, whether Play starts paused. */ togglePause(): void; + /** One frame on from a pause. */ + step(): void; /** The account menu at the far right: who is signed in, changing the password, signing out. */ openAccountMenu(anchor: Widget): void; } @@ -41,7 +43,7 @@ function iconButton(name: IconName, onClick: () => void, tooltip: () => string): } /** - * Top bar (#183): menu, undo and redo on the left, play and pause in the middle, the account on + * Top bar (#183): menu, undo and redo on the left, play, pause and next frame in the middle, the account on * the right – icons, no captions. Everything with words is behind the menu: the project name and * the start-scene switch in Project settings, the project list in Open project. The scene is * picked where the scenes are, in the Assets panel. @@ -55,6 +57,7 @@ export class Toolbar extends HStack { private readonly redoButton: Button; private readonly playButton: Button; private readonly pauseButton: Button; + private readonly stepButton: Button; private readonly status: Label; private readonly notice: Label; private readonly fps: Label; @@ -76,6 +79,7 @@ export class Toolbar extends HStack { redo: state.commands.redoLabel, editing: state.mode === 'edit', paused: state.paused, + startPaused: state.startPaused, }); this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton), tip('menu')); this.add(this.menuButton); @@ -89,8 +93,15 @@ export class Toolbar extends HStack { this.playButton = iconButton('play', () => actions.togglePlay(), tip('play')); this.playButton.variant = 'primary'; this.add(this.playButton); - this.pauseButton = iconButton('pause', () => actions.togglePause(), tip('pause')); + // Before Play there is no `mode` event to say the switch moved, so the button asks for itself. + const togglePause = () => { + actions.togglePause(); + this.refresh(); + }; + this.pauseButton = iconButton('pause', togglePause, tip('pause')); this.add(this.pauseButton); + this.stepButton = iconButton('step', () => actions.step(), tip('step')); + this.add(this.stepButton); this.add(new Spacer(), 1); // Flexible, not a fixed strip: it is the one thing here that may shrink, and with the account @@ -116,24 +127,34 @@ export class Toolbar extends HStack { this.refresh(); } - /** What the two run buttons show right now (test hook) – they carry no text to read. */ - runButtons(): { play: IconName; pause: IconName; playEnabled: boolean; pauseEnabled: boolean } { + /** What the run buttons show right now (test hook) – they carry no text to read. */ + runButtons(): { + play: IconName; + pause: IconName; + playEnabled: boolean; + pauseEnabled: boolean; + pauseToggled: boolean; + stepEnabled: boolean; + } { return { play: this.playIcon, pause: this.pauseIcon, playEnabled: this.playButton.enabled, pauseEnabled: this.pauseButton.enabled, + pauseToggled: this.pauseButton.toggled, + stepEnabled: this.stepButton.enabled, }; } /** The icon buttons, so a check can click what carries no caption. */ - get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause', Button> { + get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause' | 'step', Button> { return { menu: this.menuButton, undo: this.undoButton, redo: this.redoButton, play: this.playButton, pause: this.pauseButton, + step: this.stepButton, }; } @@ -170,28 +191,30 @@ export class Toolbar extends HStack { this.playButton.invalidate(); this.pauseIcon = state.paused ? 'play' : 'pause'; this.pauseButton.setIcon(TOOLBAR_ICON_SVG[this.pauseIcon]); - this.pauseButton.setEnabled(!editing); + this.pauseButton.setToggled(editing && state.startPaused); this.pauseButton.invalidate(); + this.stepButton.setEnabled(!editing && state.paused); + this.stepButton.invalidate(); this.status.setText(SAVE_LABELS[state.saveStatus]); this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small'); } /** - * The row lays itself out first, then the two run buttons are moved onto the middle of the bar. - * The row alone cannot put them there: the two spacers around them share the free space with the - * flexible notice, so each gets a third of it and the pair ends up left of centre. Middle means + * The row lays itself out first, then the run buttons are moved onto the middle of the bar. The + * row alone cannot put them there: the two spacers around them share the free space with the + * flexible notice, so each gets a third of it and the group ends up left of centre. Middle means * the middle of the bar here, not the middle of what the row had left over. It never travels - * past the notice, so a narrow window pushes the pair back rather than one label over another. + * past the notice, so a narrow window pushes the group back rather than one label over another. */ override layout(): void { super.layout(); - const width = this.pauseButton.x + this.pauseButton.w - this.playButton.x; + const run = [this.playButton, this.pauseButton, this.stepButton]; + const width = this.stepButton.x + this.stepButton.w - this.playButton.x; const leftMost = this.redoButton.x + this.redoButton.w + this.gap; const rightMost = this.notice.x - this.gap - width; const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost))); const shift = x - this.playButton.x; - this.playButton.position.set(this.playButton.x + shift, this.playButton.y); - this.pauseButton.position.set(this.pauseButton.x + shift, this.pauseButton.y); + for (const button of run) button.position.set(button.x + shift, button.y); } override redraw(): void { diff --git a/src/editor/panels/icons/step.svg b/src/editor/panels/icons/step.svg new file mode 100644 index 00000000..cb611a78 --- /dev/null +++ b/src/editor/panels/icons/step.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/toolbarIcons.ts b/src/editor/panels/toolbarIcons.ts index 82166b35..c31c70bc 100644 --- a/src/editor/panels/toolbarIcons.ts +++ b/src/editor/panels/toolbarIcons.ts @@ -13,10 +13,11 @@ import shader from './icons/shader.svg?raw'; import showLabel from './icons/show-label.svg?raw'; import showShape from './icons/show-shape.svg?raw'; import showSymbol from './icons/show-symbol.svg?raw'; +import step from './icons/step.svg?raw'; import stop from './icons/stop.svg?raw'; import undo from './icons/undo.svg?raw'; -export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, stop, record }; +export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, step, stop, record }; /** The switches behind the eye: a type's symbol, its name, a collider's outline, a camera's shader. */ export const VISIBILITY_ICON_SVG: Record = { diff --git a/src/editor/panels/toolbarTips.ts b/src/editor/panels/toolbarTips.ts index 7296e4bd..3deeb315 100644 --- a/src/editor/panels/toolbarTips.ts +++ b/src/editor/panels/toolbarTips.ts @@ -7,9 +7,11 @@ export interface ToolbarSituation { redo: string | null; editing: boolean; paused: boolean; + /** Pause switched on before Play, so Play starts paused. */ + startPaused: boolean; } -export type ToolbarControl = 'menu' | 'undo' | 'redo' | 'play' | 'pause'; +export type ToolbarControl = 'menu' | 'undo' | 'redo' | 'play' | 'pause' | 'step'; /** * The sign of a top bar button (#209): what a click does right now, with the keys that do the same. @@ -26,6 +28,10 @@ export function toolbarTip(control: ToolbarControl, now: ToolbarSituation): stri case 'play': return now.editing ? withShortcut('Play', 'Ctrl+Enter') : withShortcut('Stop', 'Esc'); case 'pause': + if (now.editing) + return now.startPaused ? 'Play starts paused – click to start running' : 'Start paused'; return withShortcut(now.paused ? 'Resume' : 'Pause', 'Ctrl+Enter'); + case 'step': + return withShortcut('Next frame', 'Ctrl+Shift+Enter'); } } diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 69ecbfdd..aef609a0 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -7,6 +7,8 @@ export interface ShortcutActions { play(): void; stop(): void; togglePause(): void; + /** One frame on from a pause. */ + step(): void; /** * Copy and paste of what the visible tab edits: keys (#102), objects and graph pieces (#240). * False when there is nothing to do, so the key falls through. @@ -59,8 +61,12 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): } if (event.key === 'Delete' || event.key === 'Backspace') return actions.removePoint() || removeSelection(state); - // 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. + // Ctrl+Enter starts play and then holds/resumes it, with Shift it steps a held run by a frame. + // A plain letter would be taken away from the game, which owns the keyboard while playing. + if (mod && event.shiftKey && event.key === 'Enter') { + actions.step(); + return true; + } if (mod && event.key === 'Enter') { if (state.mode === 'edit') actions.play(); else actions.togglePause(); diff --git a/src/editor/state.ts b/src/editor/state.ts index f93190b1..e89f352f 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -273,6 +273,8 @@ export class EditorState { * still apply. */ paused = false; + /** Pause switched on before Play: every Play then starts paused, until it is switched off again. */ + startPaused = false; saveStatus: SaveStatus = 'saved'; /** Last user-facing message (errors, hints). */ notice = ''; diff --git a/src/runtime/GameRun.ts b/src/runtime/GameRun.ts index 6b42a36b..94201618 100644 --- a/src/runtime/GameRun.ts +++ b/src/runtime/GameRun.ts @@ -9,7 +9,12 @@ import { mixOf, videoBusesOf } from '../core/music/mixer'; import type { RecordingSink } from '../core/recording/recording'; import type { AudioSink } from './systems/Audio'; import type { VideoPictures } from './systems/CompositionSystem'; -import { addGameSystems, type BehaviorSystem, type TimeScaled } from './systems/BehaviorSystem'; +import { + addGameSystems, + type BehaviorSystem, + type Breakpoint, + type TimeScaled, +} from './systems/BehaviorSystem'; import type { Input } from './systems/Input'; import { bringObjects, keptEntities, objectsToBring } from './keptObjects'; import type { Rapier } from './physics/engine'; @@ -24,6 +29,8 @@ export interface RunServices { recorder?: RecordingSink; /** The view the run is shown in, asked whether a video has its first picture yet (#417). */ pictures?: VideoPictures; + /** Where a `Break` node pauses: only the editor's Play has one, a released game runs past it. */ + breakpoint?: Breakpoint; } /** What a scene holds of objects, which is all a run writes into it. */ diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index f5bcbb70..6bcd9fcb 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -37,7 +37,7 @@ import type { Entity } from '../Entity'; import type { World } from '../World'; import type { Rapier } from '../physics/engine'; import { type BodyMotion, PhysicsSystem } from '../physics/PhysicsSystem'; -import { behaviorContext, type TimeScaled } from './behaviorContext'; +import { behaviorContext, type Breakpoint, type TimeScaled } from './behaviorContext'; import { animationFinishedPulses, collisionEnterPulses, @@ -60,7 +60,7 @@ import type { Input } from './Input'; import type { RecordingSink } from '../../core/recording/recording'; import type { System } from './System'; -export type { TimeScaled } from './behaviorContext'; +export type { Breakpoint, TimeScaled } from './behaviorContext'; /** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */ export const TRACE_FADE = 0.35; @@ -124,6 +124,8 @@ export interface GameSystemOptions { playsCompositions?: boolean; /** The view a played game is shown in: a composition waits for its videos there (#417). */ pictures?: VideoPictures | null; + /** Where a `Break` node pauses: only the editor's Play has one. */ + breakpoint?: Breakpoint | null; } /** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */ @@ -142,12 +144,13 @@ export function addGameSystems( recorder = null, playsCompositions = true, pictures = null, + breakpoint = null, }: GameSystemOptions = {}, ): BehaviorSystem { const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem(); const compositions = new CompositionSystem(playsCompositions, pictures); const animations = new AnimationSystem(); - const sources = { collisions, animations, compositions, audio, clock, saves, recorder }; + const sources = { collisions, animations, compositions, audio, clock, saves, recorder, breakpoint }; const behaviors = new BehaviorSystem(project, input, sources, shouldRun); if (carried) behaviors.carryOver(carried); if (kept) { @@ -171,6 +174,7 @@ export interface EventSources { clock?: TimeScaled | null; saves?: SaveArea | null; recorder?: RecordingSink | null; + breakpoint?: Breakpoint | null; } /** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */ @@ -216,6 +220,7 @@ export class BehaviorSystem implements System { private readonly clock: TimeScaled | null; private readonly saves: SaveArea | null; private readonly recorder: RecordingSink | null; + private readonly breakpoint: Breakpoint | null; /** What was last written to `saves`, so a frame that changed nothing writes nothing. */ private lastSaved = ''; @@ -236,6 +241,7 @@ export class BehaviorSystem implements System { this.clock = sources.clock ?? null; this.saves = sources.saves ?? null; this.recorder = sources.recorder ?? null; + this.breakpoint = sources.breakpoint ?? null; } /** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */ @@ -775,6 +781,7 @@ export class BehaviorSystem implements System { audio: this.audio, clock: this.clock, recorder: this.recorder, + breakpoint: this.breakpoint, shared: this.sharedState, stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }), }); diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 87e2306a..a746d507 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -49,6 +49,9 @@ export interface TimeScaled { scale: number; } +/** Hears a `Break` node, with the name of the object whose behavior ran it. */ +export type Breakpoint = (where: string) => void; + /** What the running `BehaviorSystem` lends every context it makes. */ export interface ContextSources { project: Project; @@ -58,6 +61,8 @@ export interface ContextSources { clock: TimeScaled | null; /** Where a recording of the game view goes (#405); only a played game has one. */ recorder: RecordingSink | null; + /** Where `Break` pauses; only the editor's Play has one. */ + breakpoint: Breakpoint | null; shared: SharedState; /** A graph moved one of this instance's machines; its events follow between exec flows. */ stateChanged(machineId: string, from: string, to: string): void; @@ -270,6 +275,7 @@ export function behaviorContext( }, startRecording: (settings) => tell(world, run.recorder?.start(settings) ?? NO_RECORDER), stopRecording: () => tell(world, run.recorder?.stop() ?? NO_RECORDER), + breakpoint: () => run.breakpoint?.(entity.name), }; } diff --git a/tests/core/game-pause.test.ts b/tests/core/game-pause.test.ts index 671bf1fc..088070a3 100644 --- a/tests/core/game-pause.test.ts +++ b/tests/core/game-pause.test.ts @@ -134,6 +134,51 @@ describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { }); }); +describe('Break', () => { + /** `On key down(b) → Break → Log(after)`, and `On update → Log(frame)`. */ + function breaker() { + const f = makeSpriteScene('Box'); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const behavior = makeBehavior(f, 'Breaker'); + logOn(f, behavior, 'OnUpdate', 'frame'); + const edit = graphEditor(f, behavior); + const key = edit.addNode('OnKeyDown', { key: 'b' }); + const halt = edit.addNode('Break'); + const after = edit.addNode('Log', { message: 'after' }); + edit.wire([ + [key, 'exec', halt, 'exec'], + [halt, 'exec', after, 'exec'], + ]); + place(f, behavior.id, { x: 0, y: 0 }, box); + return f; + } + + it("tells the editor's Play which behavior object broke, and the frame and flow run to their end", () => { + const breaks: string[] = []; + const { world, input, frame } = startWorld(breaker(), { + behaviors: true, + breakpoint: (where) => breaks.push(where), + }); + frame(0.1); + expect(breaks).toEqual([]); + input.press('b'); + frame(0.1); + expect(breaks).toEqual(['Breaker']); + expect(world.log).toEqual(['frame', 'after', 'frame']); + // It is the editor's pause, not the game's own. + expect(world.paused).toBe(false); + }); + + it('goes past without a breakpoint, as a released game and the editor run do', () => { + const { world, input, frame } = startWorld(breaker(), { behaviors: true }); + input.press('b'); + frame(0.1); + frame(0.1); + expect(world.log).toEqual(['after', 'frame', 'frame']); + expect(world.paused).toBe(false); + }); +}); + describe('Set time scale', () => { /** `On start → Set time scale(scale)`, and `On update → Log(Time scale)`. */ function scaler(scale: number) { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 73f9f20b..8c6ba930 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2458,6 +2458,14 @@ const CASES: Record = { outputs: { paused: true }, }, ], + Break: [ + { + name: 'asks for a break, not a game pause, and flows on', + next: 'exec', + after: (ctx) => + expect({ breaks: ctx.breaks, paused: ctx.paused }).toEqual({ breaks: 1, paused: false }), + }, + ], SetTimeScale: [ { name: 'sets the scale and flows on', diff --git a/tests/helpers.ts b/tests/helpers.ts index c15ccf80..7e66143d 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -36,7 +36,7 @@ import type { RecordingSettings } from '../src/core/recording/recording'; import type { SceneObject } from '../src/core/model/SceneObject'; import { deserializeProject, serializeProject } from '../src/core/store/serialize'; import type { AudioSink } from '../src/runtime/systems/Audio'; -import { BehaviorSystem, type TimeScaled } from '../src/runtime/systems/BehaviorSystem'; +import { BehaviorSystem, type Breakpoint, type TimeScaled } from '../src/runtime/systems/BehaviorSystem'; import type { SaveArea } from '../src/core/graph/savedValues'; import { readyPhysics } from '../src/runtime/physics/engine'; import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem'; @@ -294,6 +294,8 @@ export type FakeContext = NodeContext & { paused: boolean; /** What the recording nodes asked for (#405), in order: the settings of a start, `'stop'` for a stop. */ recordings: (RecordingSettings | 'stop')[]; + /** How often a `Break` node asked to pause. */ + breaks: number; }; /** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */ @@ -417,6 +419,10 @@ export function fakeContext(overrides: Partial = {}): FakeContext { recordings: [], startRecording: (settings) => ctx.recordings.push(settings), stopRecording: () => ctx.recordings.push('stop'), + breaks: 0, + breakpoint: () => { + ctx.breaks++; + }, ...overrides, }; return ctx; @@ -476,6 +482,8 @@ export interface WorldOptions { clock?: TimeScaled; /** Where persistent Variables assets keep their values; without one nothing outlives the run. */ saves?: SaveArea; + /** Where `Break` pauses; without one the run goes past it, as a released game does. */ + breakpoint?: Breakpoint; } export interface StartedWorld { @@ -512,6 +520,7 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S audio: options.audio, clock: options.clock, saves: options.saves, + breakpoint: options.breakpoint, }) : null; if (behaviors) world.addSystem(behaviors); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 987e4414..f872ec3e 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -108,10 +108,11 @@ describe('icon files', () => { 'play', 'record', 'redo', + 'step', 'stop', 'undo', ]); - expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(8); + expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(9); }); it('has one picture per switch behind the eye', () => { diff --git a/tests/ui/tooltip.test.ts b/tests/ui/tooltip.test.ts index 8364fcea..f747bd24 100644 --- a/tests/ui/tooltip.test.ts +++ b/tests/ui/tooltip.test.ts @@ -335,7 +335,7 @@ describe('the marks of a list row', () => { }); describe('what the buttons without a caption say', () => { - const idle = { undo: null, redo: null, editing: true, paused: false }; + const idle = { undo: null, redo: null, editing: true, paused: false, startPaused: false }; it('writes a shortcut behind the name, and nothing where there is none', () => { expect(withShortcut('Undo', 'Ctrl+Z')).toBe('Undo (Ctrl+Z)'); @@ -355,6 +355,16 @@ describe('what the buttons without a caption say', () => { expect(toolbarTip('play', { ...idle, editing: false })).toBe('Stop (Esc)'); expect(toolbarTip('pause', { ...idle, editing: false })).toBe('Pause (Ctrl+Enter)'); expect(toolbarTip('pause', { ...idle, editing: false, paused: true })).toBe('Resume (Ctrl+Enter)'); + expect(toolbarTip('step', { ...idle, editing: false, paused: true })).toBe( + 'Next frame (Ctrl+Shift+Enter)', + ); + }); + + it('before Play, pause says whether Play will start paused', () => { + expect(toolbarTip('pause', idle)).toBe('Start paused'); + expect(toolbarTip('pause', { ...idle, startPaused: true })).toBe( + 'Play starts paused – click to start running', + ); }); it('takes the names from the command stack, the last step first', () => { diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index de384902..bfc4bc23 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -415,6 +415,109 @@ export async function pause(t) { JSON.stringify(menuLeft), ); + // 18j. Pause before Play: the run starts held, Next frame moves it by exactly one fixed step, and + // a Break node holds a running game the way the pause button does. + const buttons = () => json('__gm.runButtons()'); + const notice = () => json('__gm.state.notice'); + const editButtons = await buttons(); + await clickToolbar('pause'); + const armed = await buttons(); + check( + 'before Play the pause button is a switch, and Next frame waits for a run', + editButtons.pauseEnabled === true && + editButtons.pauseToggled === false && + editButtons.stepEnabled === false && + armed.pauseToggled === true && + (await playInfo()).playing === false, + JSON.stringify({ editButtons, armed }), + ); + await t.play(); + const startX = await boxX(); + const startClock = await clock(); + await sleep(400); + const startHeld = { info: await playInfo(), buttons: await buttons(), notice: await notice() }; + check( + 'Play starts paused: the box and the clock stand still, and Next frame is offered', + startHeld.info.paused === true && + Math.abs((await boxX()) - startX) < 0.001 && + Math.abs((await clock()).now - startClock.now) < 0.001 && + startHeld.buttons.stepEnabled === true && + startHeld.notice.startsWith('Paused · Ctrl+Enter resumes · Ctrl+Shift+Enter steps'), + JSON.stringify(startHeld), + ); + const fixedDrift = DRIFT_PER_SECOND / 60; + await clickToolbar('step'); + await sleep(200); + const stepped = { x: (await boxX()) - startX, t: (await clock()).now - startClock.now }; + await press('Enter', { ctrl: true, shift: true }); + await sleep(200); + const steppedTwice = { x: (await boxX()) - startX, info: await playInfo() }; + check( + 'Next frame and Ctrl+Shift+Enter each move the run by one 1/60 s frame, and it stays paused', + Math.abs(stepped.x - fixedDrift) < 0.01 && + Math.abs(stepped.t - 1 / 60) < 0.0001 && + Math.abs(steppedTwice.x - 2 * fixedDrift) < 0.01 && + steppedTwice.info.paused === true, + JSON.stringify({ stepped, steppedTwice, fixedDrift }), + ); + await stop(); + await t.play(); + const again = await playInfo(); + await stop(); + await clickToolbar('pause'); + const disarmed = await buttons(); + await t.play(); + const free = await playInfo(); + await stop(); + check( + 'the switch holds for every Play until it is switched off again', + again.paused === true && disarmed.pauseToggled === false && free.paused === false, + JSON.stringify({ again, disarmed, free }), + ); + + const breaker = await scaffoldBehavior(t, { + assetId: 'breaker', + name: 'Breaker', + graph: { + nodes: [node('b-key', 'OnKeyDown', 0, 0, { key: 'p' }), node('b-break', 'Break', 300, 0)], + edges: [edge('b-e1', 'b-key', 'exec', 'b-break', 'exec')], + }, + boxId: 'b-box', + boxName: 'Break box', + logicId: 'b-logic', + y: LANES.pause - 75, + }); + await t.play(); + await sleep(300); + const beforeBreak = await playInfo(); + await press('p'); + await sleep(100); + const broke = { info: await playInfo(), notice: await notice(), x: await boxX() }; + await sleep(400); + check( + "a Break node pauses Play like the pause button, not the game's own pause, and names its object", + beforeBreak.paused === false && + broke.info.paused === true && + broke.info.gamePaused === false && + broke.notice.startsWith('Break in Breaker · Ctrl+Enter resumes') && + Math.abs((await boxX()) - broke.x) < 0.001, + JSON.stringify({ beforeBreak, broke }), + ); + await press('Enter', { ctrl: true }); + const afterBreak = { info: await playInfo(), notice: await notice() }; + await stop(); + check( + 'Ctrl+Enter resumes from a break', + afterBreak.info.paused === false && afterBreak.notice.startsWith('Playing'), + JSON.stringify(afterBreak), + ); + const breakerLeft = await breaker.remove(); + check( + 'the break rig leaves nothing of its own behind', + breakerLeft.objects === 0 && breakerLeft.assets === 0, + JSON.stringify(breakerLeft), + ); + // 18h. Play discarded its copy, so the box is back at the position the model holds. const left = await rig.remove(); check( diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index c4179336..75f3f49c 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -27,23 +27,25 @@ export async function topbar(t) { ); const buttons = await json('__gm.runButtons()'); check( - 'in edit mode the run buttons offer play and nothing to pause', + 'in edit mode the run buttons offer play, pause as a switch for the start, and no step', buttons.play === 'play' && buttons.pause === 'pause' && buttons.playEnabled === true && - buttons.pauseEnabled === false, + buttons.pauseEnabled === true && + buttons.pauseToggled === false && + buttons.stepEnabled === false, JSON.stringify(buttons), ); - // The pair is centred on the bar itself. The row cannot do that on its own: the spacers around - // the two buttons share the free space with the flexible notice and so get a third of it each, - // which used to leave the pair well left of the middle. + // The group is centred on the bar itself. The row cannot do that on its own: the spacers around + // the buttons share the free space with the flexible notice and so get a third of it each, which + // used to leave the group well left of the middle. const play = await json('__gm.toolbarAt("play")'); - const pause = await json('__gm.toolbarAt("pause")'); + const step = await json('__gm.toolbarAt("step")'); const barCentre = await json('window.innerWidth / 2'); check( - 'play and pause sit on the middle of the bar, not on the middle of what is left over', - Math.abs((play.x + pause.x) / 2 - barCentre) <= 2, - JSON.stringify({ middle: (play.x + pause.x) / 2, barCentre }), + 'play, pause and next frame sit on the middle of the bar, not on the middle of what is left over', + Math.abs((play.x + step.x) / 2 - barCentre) <= 2, + JSON.stringify({ middle: (play.x + step.x) / 2, barCentre }), ); await shot('46-topbar');