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');