Start paused, step one frame and break from a behavior
Finding out what a game does in its first frames, or right at one spot of a flow, needed a quick hand on the pause button. Now the editor can start a play paused, step it frame by frame and let a behavior pause it. - The pause button can be pressed in edit mode. Then Play starts paused, and every further Play does too until it is released. A reload of the page clears it. - A third button, Next frame, right of Pause, or Ctrl+Shift+Enter. It works only while paused and runs exactly one frame of 1/60 s, whatever the frame took on screen. Hierarchy and inspector follow. - The Break node pauses the play like the button, at the end of the frame it ran in. The status line names the behavior's object. It is the editor's pause, not Pause game. - Break reaches the editor through a service only the editor hands to a play. In play.html and Run in editor the node does nothing. - Restart game keeps the running play's state. Start paused only counts when Play is pressed. - Wiki Concepts, Behaviors, Keyboard: the buttons, the node and the shortcut. Not tried: sound, free-running videos and a running recording while stepping. Sound and videos stay held during the pause. Tests: - tsc, eslint and Prettier are clean. - Unit pause, node-catalog, tooltip: 465 green. svg-icons now expects the step icon too. - e2e 18 and 46 on stein2 (melektaus-atx was off): ALL PASSED, 63 checks, 7 new in 18j. A step moves the box by 1.667 px at 100 px/s, Break stops the play with "Break in Breaker". - Counter-checks: a session that ignores the step turns 18j red. A Break that does not report its object turns the unit test red. - Gate on stein2: tsc, eslint and Prettier clean, 305 files, 4722 tests, 6 more than before. One was red at first, the icon list of the top bar did not know step yet. Fixed in svg-icons.test.ts and run again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
64d0d472e5
commit
7a952cb20d
23 files changed
+378
-55
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -399,4 +399,6 @@ export interface NodeContext {
|
|||||||
startRecording(settings: RecordingSettings): void;
|
startRecording(settings: RecordingSettings): void;
|
||||||
/** Ends the recording and saves it; without one, only the log hears of it. */
|
/** Ends the recording and saves it; without one, only the log hears of it. */
|
||||||
stopRecording(): void;
|
stopRecording(): void;
|
||||||
|
/** Pauses the editor's Play after this frame, as its pause button does; elsewhere nothing. */
|
||||||
|
breakpoint(): void;
|
||||||
}
|
}
|
||||||
@@ -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 = {
|
export const IsGamePaused: NodeDef = {
|
||||||
type: 'IsGamePaused',
|
type: 'IsGamePaused',
|
||||||
label: 'Is game paused',
|
label: 'Is game paused',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Destroy } from './action/Destroy';
|
|||||||
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
||||||
import { PlayComposition, StopComposition } from './action/compositionNodes';
|
import { PlayComposition, StopComposition } from './action/compositionNodes';
|
||||||
import { Emit } from './action/Emit';
|
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 { LoadScene } from './action/LoadScene';
|
||||||
import { Log } from './action/Log';
|
import { Log } from './action/Log';
|
||||||
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
||||||
@@ -334,6 +334,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
PauseGame,
|
PauseGame,
|
||||||
ResumeGame,
|
ResumeGame,
|
||||||
IsGamePaused,
|
IsGamePaused,
|
||||||
|
Break,
|
||||||
SetTimeScale,
|
SetTimeScale,
|
||||||
TimeScale,
|
TimeScale,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { VideoStore } from '../core/store/VideoStore';
|
|||||||
import { StreamKeeper } from './sound/StreamKeeper';
|
import { StreamKeeper } from './sound/StreamKeeper';
|
||||||
import type { TextureStore } from '../core/store/TextureStore';
|
import type { TextureStore } from '../core/store/TextureStore';
|
||||||
import type { UsersStore } from '../core/store/UsersStore';
|
import type { UsersStore } from '../core/store/UsersStore';
|
||||||
import { gameClock, timeStep } from '../core/time';
|
import { FIXED_STEP, gameClock, timeStep } from '../core/time';
|
||||||
import {
|
import {
|
||||||
clamp,
|
clamp,
|
||||||
HStack,
|
HStack,
|
||||||
@@ -482,6 +482,7 @@ export class Editor implements PlayHost {
|
|||||||
]),
|
]),
|
||||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||||
togglePause: () => this.play.togglePause(),
|
togglePause: () => this.play.togglePause(),
|
||||||
|
step: () => this.play.step(),
|
||||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||||
});
|
});
|
||||||
root.add(this.toolbar);
|
root.add(this.toolbar);
|
||||||
@@ -618,6 +619,7 @@ export class Editor implements PlayHost {
|
|||||||
play: () => this.play.play(),
|
play: () => this.play.play(),
|
||||||
stop: () => this.play.stop(),
|
stop: () => this.play.stop(),
|
||||||
togglePause: () => this.play.togglePause(),
|
togglePause: () => this.play.togglePause(),
|
||||||
|
step: () => this.play.step(),
|
||||||
copy: () =>
|
copy: () =>
|
||||||
(this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) ||
|
(this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) ||
|
||||||
(this.keyTab() === 'pattern' && this.patternEditor.canvas.copy()) ||
|
(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.
|
// 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,
|
// 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).
|
// 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);
|
// A single step out of the pause moves by one fixed step, however long the frame took.
|
||||||
this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused));
|
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.editorRun.tick(step);
|
||||||
this.viewport.animate(sceneDraws);
|
this.viewport.animate(sceneDraws);
|
||||||
this.gameView.animate(gameDraws);
|
this.gameView.animate(gameDraws);
|
||||||
|
|||||||
+71
-14
@@ -81,6 +81,11 @@ export class PlaySession {
|
|||||||
private videoWait: number | null = null;
|
private videoWait: number | null = null;
|
||||||
/** Records the game view for `Start recording` (#405); one for the editor, so it outlives a restart. */
|
/** Records the game view for `Start recording` (#405); one for the editor, so it outlives a restart. */
|
||||||
private readonly recorder: GameRecorder;
|
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(
|
constructor(
|
||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
@@ -126,18 +131,44 @@ export class PlaySession {
|
|||||||
return this.videoWait !== null;
|
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 {
|
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.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.
|
// 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
|
// 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.
|
// on mid-note, rather than playing to their end over a still image.
|
||||||
soundLibrary.setSuspended(this.state.paused);
|
soundLibrary.setSuspended(paused);
|
||||||
videoLibrary.setPaused(this.state.paused);
|
videoLibrary.setPaused(paused);
|
||||||
this.recorder.setPaused(this.state.paused);
|
this.recorder.setPaused(paused);
|
||||||
ui().focus.blur();
|
|
||||||
this.state.setNotice(this.playNotice(), 'quiet');
|
this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +200,8 @@ export class PlaySession {
|
|||||||
return this.behaviors?.liveVariables(assetId) ?? null;
|
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();
|
const scene = this.state.sceneToPlay();
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
// The editor loads the engine right after opening, so it is almost always there already; a game
|
// 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'),
|
saves: saveArea(runtime.id, 'play'),
|
||||||
recorder: this.recorder,
|
recorder: this.recorder,
|
||||||
pictures: { missing: (id) => this.host.gameView.missesVideoOf(id) },
|
pictures: { missing: (id) => this.host.gameView.missesVideoOf(id) },
|
||||||
|
breakpoint: (where) => (this.breakAt ??= where),
|
||||||
};
|
};
|
||||||
this.run = GameRun.start(runtime, scene.id, this.input, services);
|
this.run = GameRun.start(runtime, scene.id, this.input, services);
|
||||||
videoLibrary.startRun();
|
videoLibrary.startRun();
|
||||||
@@ -201,7 +234,10 @@ export class PlaySession {
|
|||||||
this.host.gameView.setWorld(this.run.world);
|
this.host.gameView.setWorld(this.run.world);
|
||||||
this.host.showGame();
|
this.host.showGame();
|
||||||
this.waitForVideos();
|
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();
|
if (this.run.world.stopRequested) this.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +284,10 @@ export class PlaySession {
|
|||||||
|
|
||||||
private playNotice(): string {
|
private playNotice(): string {
|
||||||
if (this.videoWait !== null) return 'Loading the videos… · Esc stops';
|
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';
|
if (this.world?.paused) return 'The game paused itself · Esc stops';
|
||||||
return this.world?.activeCamera()
|
return this.world?.activeCamera()
|
||||||
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
|
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
|
||||||
@@ -268,6 +307,8 @@ export class PlaySession {
|
|||||||
soundLibrary.stopAll();
|
soundLibrary.stopAll();
|
||||||
videoLibrary.endRun();
|
videoLibrary.endRun();
|
||||||
this.videoWait = null;
|
this.videoWait = null;
|
||||||
|
this.stepAsked = false;
|
||||||
|
this.breakAt = null;
|
||||||
const world = this.world;
|
const world = this.world;
|
||||||
this.run = null;
|
this.run = null;
|
||||||
this.input.detach();
|
this.input.detach();
|
||||||
@@ -289,7 +330,7 @@ export class PlaySession {
|
|||||||
const run = this.run;
|
const run = this.run;
|
||||||
if (!run) return;
|
if (!run) return;
|
||||||
const world = run.world;
|
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
|
// 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.
|
// resuming would deliver every one of them in a single frame.
|
||||||
this.input.endFrame();
|
this.input.endFrame();
|
||||||
@@ -305,12 +346,14 @@ export class PlaySession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request === 'restart') {
|
if (request === 'restart') {
|
||||||
|
const held = this.state.paused || this.breakAt !== null;
|
||||||
this.stop(true);
|
this.stop(true);
|
||||||
this.play();
|
this.play(held);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request === 'scene') {
|
if (request === 'scene') {
|
||||||
this.sceneLoaded(run);
|
this.sceneLoaded(run);
|
||||||
|
this.pauseAtBreak();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
|
// 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(),
|
performance.now(),
|
||||||
structure,
|
structure,
|
||||||
);
|
);
|
||||||
// A game that pauses itself is looked at like one paused from the toolbar.
|
// 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.panels.flush() : update);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
playInfo: () => editor.playInfo(),
|
playInfo: () => editor.playInfo(),
|
||||||
/** Which scene Play would start, and under which of the two rules (#183: set in the settings). */
|
/** Which scene Play would start, and under which of the two rules (#183: set in the settings). */
|
||||||
playFrom: () => editor.playFromInfo(),
|
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(),
|
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]),
|
toolbarAt: (name: keyof Editor['toolbarControls']) => centreOf(editor.toolbarControls[name]),
|
||||||
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
||||||
editorRun: () => editor.editorRunInfo(),
|
editorRun: () => editor.editorRunInfo(),
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export interface ToolbarActions {
|
|||||||
openMainMenu(anchor: Widget): void;
|
openMainMenu(anchor: Widget): void;
|
||||||
/** Play in edit mode, Stop in play mode. */
|
/** Play in edit mode, Stop in play mode. */
|
||||||
togglePlay(): void;
|
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;
|
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. */
|
/** The account menu at the far right: who is signed in, changing the password, signing out. */
|
||||||
openAccountMenu(anchor: Widget): void;
|
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 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
|
* 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.
|
* picked where the scenes are, in the Assets panel.
|
||||||
@@ -55,6 +57,7 @@ export class Toolbar extends HStack {
|
|||||||
private readonly redoButton: Button;
|
private readonly redoButton: Button;
|
||||||
private readonly playButton: Button;
|
private readonly playButton: Button;
|
||||||
private readonly pauseButton: Button;
|
private readonly pauseButton: Button;
|
||||||
|
private readonly stepButton: Button;
|
||||||
private readonly status: Label;
|
private readonly status: Label;
|
||||||
private readonly notice: Label;
|
private readonly notice: Label;
|
||||||
private readonly fps: Label;
|
private readonly fps: Label;
|
||||||
@@ -76,6 +79,7 @@ export class Toolbar extends HStack {
|
|||||||
redo: state.commands.redoLabel,
|
redo: state.commands.redoLabel,
|
||||||
editing: state.mode === 'edit',
|
editing: state.mode === 'edit',
|
||||||
paused: state.paused,
|
paused: state.paused,
|
||||||
|
startPaused: state.startPaused,
|
||||||
});
|
});
|
||||||
this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton), tip('menu'));
|
this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton), tip('menu'));
|
||||||
this.add(this.menuButton);
|
this.add(this.menuButton);
|
||||||
@@ -89,8 +93,15 @@ export class Toolbar extends HStack {
|
|||||||
this.playButton = iconButton('play', () => actions.togglePlay(), tip('play'));
|
this.playButton = iconButton('play', () => actions.togglePlay(), tip('play'));
|
||||||
this.playButton.variant = 'primary';
|
this.playButton.variant = 'primary';
|
||||||
this.add(this.playButton);
|
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.add(this.pauseButton);
|
||||||
|
this.stepButton = iconButton('step', () => actions.step(), tip('step'));
|
||||||
|
this.add(this.stepButton);
|
||||||
this.add(new Spacer(), 1);
|
this.add(new Spacer(), 1);
|
||||||
|
|
||||||
// Flexible, not a fixed strip: it is the one thing here that may shrink, and with the account
|
// 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();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the two run buttons show right now (test hook) – they carry no text to read. */
|
/** What the run buttons show right now (test hook) – they carry no text to read. */
|
||||||
runButtons(): { play: IconName; pause: IconName; playEnabled: boolean; pauseEnabled: boolean } {
|
runButtons(): {
|
||||||
|
play: IconName;
|
||||||
|
pause: IconName;
|
||||||
|
playEnabled: boolean;
|
||||||
|
pauseEnabled: boolean;
|
||||||
|
pauseToggled: boolean;
|
||||||
|
stepEnabled: boolean;
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
play: this.playIcon,
|
play: this.playIcon,
|
||||||
pause: this.pauseIcon,
|
pause: this.pauseIcon,
|
||||||
playEnabled: this.playButton.enabled,
|
playEnabled: this.playButton.enabled,
|
||||||
pauseEnabled: this.pauseButton.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. */
|
/** 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 {
|
return {
|
||||||
menu: this.menuButton,
|
menu: this.menuButton,
|
||||||
undo: this.undoButton,
|
undo: this.undoButton,
|
||||||
redo: this.redoButton,
|
redo: this.redoButton,
|
||||||
play: this.playButton,
|
play: this.playButton,
|
||||||
pause: this.pauseButton,
|
pause: this.pauseButton,
|
||||||
|
step: this.stepButton,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,28 +191,30 @@ export class Toolbar extends HStack {
|
|||||||
this.playButton.invalidate();
|
this.playButton.invalidate();
|
||||||
this.pauseIcon = state.paused ? 'play' : 'pause';
|
this.pauseIcon = state.paused ? 'play' : 'pause';
|
||||||
this.pauseButton.setIcon(TOOLBAR_ICON_SVG[this.pauseIcon]);
|
this.pauseButton.setIcon(TOOLBAR_ICON_SVG[this.pauseIcon]);
|
||||||
this.pauseButton.setEnabled(!editing);
|
this.pauseButton.setToggled(editing && state.startPaused);
|
||||||
this.pauseButton.invalidate();
|
this.pauseButton.invalidate();
|
||||||
|
this.stepButton.setEnabled(!editing && state.paused);
|
||||||
|
this.stepButton.invalidate();
|
||||||
this.status.setText(SAVE_LABELS[state.saveStatus]);
|
this.status.setText(SAVE_LABELS[state.saveStatus]);
|
||||||
this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small');
|
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 lays itself out first, then the run buttons are moved onto the middle of the bar. The
|
||||||
* The row alone cannot put them there: the two spacers around them share the free space with 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
|
* 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
|
* 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 {
|
override layout(): void {
|
||||||
super.layout();
|
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 leftMost = this.redoButton.x + this.redoButton.w + this.gap;
|
||||||
const rightMost = this.notice.x - this.gap - width;
|
const rightMost = this.notice.x - this.gap - width;
|
||||||
const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost)));
|
const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost)));
|
||||||
const shift = x - this.playButton.x;
|
const shift = x - this.playButton.x;
|
||||||
this.playButton.position.set(this.playButton.x + shift, this.playButton.y);
|
for (const button of run) button.position.set(button.x + shift, button.y);
|
||||||
this.pauseButton.position.set(this.pauseButton.x + shift, this.pauseButton.y);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="step" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<path d="M-6 -6 L3 0 L-6 6 Z" fill="currentColor" />
|
||||||
|
<rect x="3.5" y="-6" width="2.5" height="12" rx="0.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
@@ -13,10 +13,11 @@ import shader from './icons/shader.svg?raw';
|
|||||||
import showLabel from './icons/show-label.svg?raw';
|
import showLabel from './icons/show-label.svg?raw';
|
||||||
import showShape from './icons/show-shape.svg?raw';
|
import showShape from './icons/show-shape.svg?raw';
|
||||||
import showSymbol from './icons/show-symbol.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 stop from './icons/stop.svg?raw';
|
||||||
import undo from './icons/undo.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. */
|
/** 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<VisibilityPart, string> = {
|
export const VISIBILITY_ICON_SVG: Record<VisibilityPart, string> = {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ export interface ToolbarSituation {
|
|||||||
redo: string | null;
|
redo: string | null;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
paused: 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.
|
* 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':
|
case 'play':
|
||||||
return now.editing ? withShortcut('Play', 'Ctrl+Enter') : withShortcut('Stop', 'Esc');
|
return now.editing ? withShortcut('Play', 'Ctrl+Enter') : withShortcut('Stop', 'Esc');
|
||||||
case 'pause':
|
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');
|
return withShortcut(now.paused ? 'Resume' : 'Pause', 'Ctrl+Enter');
|
||||||
|
case 'step':
|
||||||
|
return withShortcut('Next frame', 'Ctrl+Shift+Enter');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,8 @@ export interface ShortcutActions {
|
|||||||
play(): void;
|
play(): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
togglePause(): 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).
|
* 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.
|
* 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')
|
if (event.key === 'Delete' || event.key === 'Backspace')
|
||||||
return actions.removePoint() || removeSelection(state);
|
return actions.removePoint() || removeSelection(state);
|
||||||
// Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from
|
// Ctrl+Enter starts play and then holds/resumes it, with Shift it steps a held run by a frame.
|
||||||
// the game, which owns the keyboard while playing.
|
// 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 (mod && event.key === 'Enter') {
|
||||||
if (state.mode === 'edit') actions.play();
|
if (state.mode === 'edit') actions.play();
|
||||||
else actions.togglePause();
|
else actions.togglePause();
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ export class EditorState {
|
|||||||
* still apply.
|
* still apply.
|
||||||
*/
|
*/
|
||||||
paused = false;
|
paused = false;
|
||||||
|
/** Pause switched on before Play: every Play then starts paused, until it is switched off again. */
|
||||||
|
startPaused = false;
|
||||||
saveStatus: SaveStatus = 'saved';
|
saveStatus: SaveStatus = 'saved';
|
||||||
/** Last user-facing message (errors, hints). */
|
/** Last user-facing message (errors, hints). */
|
||||||
notice = '';
|
notice = '';
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { mixOf, videoBusesOf } from '../core/music/mixer';
|
|||||||
import type { RecordingSink } from '../core/recording/recording';
|
import type { RecordingSink } from '../core/recording/recording';
|
||||||
import type { AudioSink } from './systems/Audio';
|
import type { AudioSink } from './systems/Audio';
|
||||||
import type { VideoPictures } from './systems/CompositionSystem';
|
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 type { Input } from './systems/Input';
|
||||||
import { bringObjects, keptEntities, objectsToBring } from './keptObjects';
|
import { bringObjects, keptEntities, objectsToBring } from './keptObjects';
|
||||||
import type { Rapier } from './physics/engine';
|
import type { Rapier } from './physics/engine';
|
||||||
@@ -24,6 +29,8 @@ export interface RunServices {
|
|||||||
recorder?: RecordingSink;
|
recorder?: RecordingSink;
|
||||||
/** The view the run is shown in, asked whether a video has its first picture yet (#417). */
|
/** The view the run is shown in, asked whether a video has its first picture yet (#417). */
|
||||||
pictures?: VideoPictures;
|
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. */
|
/** What a scene holds of objects, which is all a run writes into it. */
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { Entity } from '../Entity';
|
|||||||
import type { World } from '../World';
|
import type { World } from '../World';
|
||||||
import type { Rapier } from '../physics/engine';
|
import type { Rapier } from '../physics/engine';
|
||||||
import { type BodyMotion, PhysicsSystem } from '../physics/PhysicsSystem';
|
import { type BodyMotion, PhysicsSystem } from '../physics/PhysicsSystem';
|
||||||
import { behaviorContext, type TimeScaled } from './behaviorContext';
|
import { behaviorContext, type Breakpoint, type TimeScaled } from './behaviorContext';
|
||||||
import {
|
import {
|
||||||
animationFinishedPulses,
|
animationFinishedPulses,
|
||||||
collisionEnterPulses,
|
collisionEnterPulses,
|
||||||
@@ -60,7 +60,7 @@ import type { Input } from './Input';
|
|||||||
import type { RecordingSink } from '../../core/recording/recording';
|
import type { RecordingSink } from '../../core/recording/recording';
|
||||||
import type { System } from './System';
|
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. */
|
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
|
||||||
export const TRACE_FADE = 0.35;
|
export const TRACE_FADE = 0.35;
|
||||||
@@ -124,6 +124,8 @@ export interface GameSystemOptions {
|
|||||||
playsCompositions?: boolean;
|
playsCompositions?: boolean;
|
||||||
/** The view a played game is shown in: a composition waits for its videos there (#417). */
|
/** The view a played game is shown in: a composition waits for its videos there (#417). */
|
||||||
pictures?: VideoPictures | null;
|
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. */
|
/** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */
|
||||||
@@ -142,12 +144,13 @@ export function addGameSystems(
|
|||||||
recorder = null,
|
recorder = null,
|
||||||
playsCompositions = true,
|
playsCompositions = true,
|
||||||
pictures = null,
|
pictures = null,
|
||||||
|
breakpoint = null,
|
||||||
}: GameSystemOptions = {},
|
}: GameSystemOptions = {},
|
||||||
): BehaviorSystem {
|
): BehaviorSystem {
|
||||||
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
||||||
const compositions = new CompositionSystem(playsCompositions, pictures);
|
const compositions = new CompositionSystem(playsCompositions, pictures);
|
||||||
const animations = new AnimationSystem();
|
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);
|
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
|
||||||
if (carried) behaviors.carryOver(carried);
|
if (carried) behaviors.carryOver(carried);
|
||||||
if (kept) {
|
if (kept) {
|
||||||
@@ -171,6 +174,7 @@ export interface EventSources {
|
|||||||
clock?: TimeScaled | null;
|
clock?: TimeScaled | null;
|
||||||
saves?: SaveArea | null;
|
saves?: SaveArea | null;
|
||||||
recorder?: RecordingSink | null;
|
recorder?: RecordingSink | null;
|
||||||
|
breakpoint?: Breakpoint | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
|
/** 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 clock: TimeScaled | null;
|
||||||
private readonly saves: SaveArea | null;
|
private readonly saves: SaveArea | null;
|
||||||
private readonly recorder: RecordingSink | 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. */
|
/** What was last written to `saves`, so a frame that changed nothing writes nothing. */
|
||||||
private lastSaved = '';
|
private lastSaved = '';
|
||||||
|
|
||||||
@@ -236,6 +241,7 @@ export class BehaviorSystem implements System {
|
|||||||
this.clock = sources.clock ?? null;
|
this.clock = sources.clock ?? null;
|
||||||
this.saves = sources.saves ?? null;
|
this.saves = sources.saves ?? null;
|
||||||
this.recorder = sources.recorder ?? 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`. */
|
/** 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,
|
audio: this.audio,
|
||||||
clock: this.clock,
|
clock: this.clock,
|
||||||
recorder: this.recorder,
|
recorder: this.recorder,
|
||||||
|
breakpoint: this.breakpoint,
|
||||||
shared: this.sharedState,
|
shared: this.sharedState,
|
||||||
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export interface TimeScaled {
|
|||||||
scale: number;
|
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. */
|
/** What the running `BehaviorSystem` lends every context it makes. */
|
||||||
export interface ContextSources {
|
export interface ContextSources {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -58,6 +61,8 @@ export interface ContextSources {
|
|||||||
clock: TimeScaled | null;
|
clock: TimeScaled | null;
|
||||||
/** Where a recording of the game view goes (#405); only a played game has one. */
|
/** Where a recording of the game view goes (#405); only a played game has one. */
|
||||||
recorder: RecordingSink | null;
|
recorder: RecordingSink | null;
|
||||||
|
/** Where `Break` pauses; only the editor's Play has one. */
|
||||||
|
breakpoint: Breakpoint | null;
|
||||||
shared: SharedState;
|
shared: SharedState;
|
||||||
/** A graph moved one of this instance's machines; its events follow between exec flows. */
|
/** A graph moved one of this instance's machines; its events follow between exec flows. */
|
||||||
stateChanged(machineId: string, from: string, to: string): void;
|
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),
|
startRecording: (settings) => tell(world, run.recorder?.start(settings) ?? NO_RECORDER),
|
||||||
stopRecording: () => tell(world, run.recorder?.stop() ?? NO_RECORDER),
|
stopRecording: () => tell(world, run.recorder?.stop() ?? NO_RECORDER),
|
||||||
|
breakpoint: () => run.breakpoint?.(entity.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
describe('Set time scale', () => {
|
||||||
/** `On start → Set time scale(scale)`, and `On update → Log(Time scale)`. */
|
/** `On start → Set time scale(scale)`, and `On update → Log(Time scale)`. */
|
||||||
function scaler(scale: number) {
|
function scaler(scale: number) {
|
||||||
|
|||||||
@@ -2458,6 +2458,14 @@ const CASES: Record<string, Case[]> = {
|
|||||||
outputs: { paused: true },
|
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: [
|
SetTimeScale: [
|
||||||
{
|
{
|
||||||
name: 'sets the scale and flows on',
|
name: 'sets the scale and flows on',
|
||||||
|
|||||||
+10
-1
@@ -36,7 +36,7 @@ import type { RecordingSettings } from '../src/core/recording/recording';
|
|||||||
import type { SceneObject } from '../src/core/model/SceneObject';
|
import type { SceneObject } from '../src/core/model/SceneObject';
|
||||||
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
|
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
|
||||||
import type { AudioSink } from '../src/runtime/systems/Audio';
|
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 type { SaveArea } from '../src/core/graph/savedValues';
|
||||||
import { readyPhysics } from '../src/runtime/physics/engine';
|
import { readyPhysics } from '../src/runtime/physics/engine';
|
||||||
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
|
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
|
||||||
@@ -294,6 +294,8 @@ export type FakeContext = NodeContext & {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
/** What the recording nodes asked for (#405), in order: the settings of a start, `'stop'` for a stop. */
|
/** What the recording nodes asked for (#405), in order: the settings of a start, `'stop'` for a stop. */
|
||||||
recordings: (RecordingSettings | '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. */
|
/** 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<NodeContext> = {}): FakeContext {
|
|||||||
recordings: [],
|
recordings: [],
|
||||||
startRecording: (settings) => ctx.recordings.push(settings),
|
startRecording: (settings) => ctx.recordings.push(settings),
|
||||||
stopRecording: () => ctx.recordings.push('stop'),
|
stopRecording: () => ctx.recordings.push('stop'),
|
||||||
|
breaks: 0,
|
||||||
|
breakpoint: () => {
|
||||||
|
ctx.breaks++;
|
||||||
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
return ctx;
|
return ctx;
|
||||||
@@ -476,6 +482,8 @@ export interface WorldOptions {
|
|||||||
clock?: TimeScaled;
|
clock?: TimeScaled;
|
||||||
/** Where persistent Variables assets keep their values; without one nothing outlives the run. */
|
/** Where persistent Variables assets keep their values; without one nothing outlives the run. */
|
||||||
saves?: SaveArea;
|
saves?: SaveArea;
|
||||||
|
/** Where `Break` pauses; without one the run goes past it, as a released game does. */
|
||||||
|
breakpoint?: Breakpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartedWorld {
|
export interface StartedWorld {
|
||||||
@@ -512,6 +520,7 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S
|
|||||||
audio: options.audio,
|
audio: options.audio,
|
||||||
clock: options.clock,
|
clock: options.clock,
|
||||||
saves: options.saves,
|
saves: options.saves,
|
||||||
|
breakpoint: options.breakpoint,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (behaviors) world.addSystem(behaviors);
|
if (behaviors) world.addSystem(behaviors);
|
||||||
|
|||||||
@@ -108,10 +108,11 @@ describe('icon files', () => {
|
|||||||
'play',
|
'play',
|
||||||
'record',
|
'record',
|
||||||
'redo',
|
'redo',
|
||||||
|
'step',
|
||||||
'stop',
|
'stop',
|
||||||
'undo',
|
'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', () => {
|
it('has one picture per switch behind the eye', () => {
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ describe('the marks of a list row', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('what the buttons without a caption say', () => {
|
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', () => {
|
it('writes a shortcut behind the name, and nothing where there is none', () => {
|
||||||
expect(withShortcut('Undo', 'Ctrl+Z')).toBe('Undo (Ctrl+Z)');
|
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('play', { ...idle, editing: false })).toBe('Stop (Esc)');
|
||||||
expect(toolbarTip('pause', { ...idle, editing: false })).toBe('Pause (Ctrl+Enter)');
|
expect(toolbarTip('pause', { ...idle, editing: false })).toBe('Pause (Ctrl+Enter)');
|
||||||
expect(toolbarTip('pause', { ...idle, editing: false, paused: true })).toBe('Resume (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', () => {
|
it('takes the names from the command stack, the last step first', () => {
|
||||||
|
|||||||
@@ -415,6 +415,109 @@ export async function pause(t) {
|
|||||||
JSON.stringify(menuLeft),
|
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.
|
// 18h. Play discarded its copy, so the box is back at the position the model holds.
|
||||||
const left = await rig.remove();
|
const left = await rig.remove();
|
||||||
check(
|
check(
|
||||||
|
|||||||
+11
-9
@@ -27,23 +27,25 @@ export async function topbar(t) {
|
|||||||
);
|
);
|
||||||
const buttons = await json('__gm.runButtons()');
|
const buttons = await json('__gm.runButtons()');
|
||||||
check(
|
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.play === 'play' &&
|
||||||
buttons.pause === 'pause' &&
|
buttons.pause === 'pause' &&
|
||||||
buttons.playEnabled === true &&
|
buttons.playEnabled === true &&
|
||||||
buttons.pauseEnabled === false,
|
buttons.pauseEnabled === true &&
|
||||||
|
buttons.pauseToggled === false &&
|
||||||
|
buttons.stepEnabled === false,
|
||||||
JSON.stringify(buttons),
|
JSON.stringify(buttons),
|
||||||
);
|
);
|
||||||
// The pair is centred on the bar itself. The row cannot do that on its own: the spacers around
|
// The group 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,
|
// the buttons share the free space with the flexible notice and so get a third of it each, which
|
||||||
// which used to leave the pair well left of the middle.
|
// used to leave the group well left of the middle.
|
||||||
const play = await json('__gm.toolbarAt("play")');
|
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');
|
const barCentre = await json('window.innerWidth / 2');
|
||||||
check(
|
check(
|
||||||
'play and pause sit on the middle of the bar, not on the middle of what is left over',
|
'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 + pause.x) / 2 - barCentre) <= 2,
|
Math.abs((play.x + step.x) / 2 - barCentre) <= 2,
|
||||||
JSON.stringify({ middle: (play.x + pause.x) / 2, barCentre }),
|
JSON.stringify({ middle: (play.x + step.x) / 2, barCentre }),
|
||||||
);
|
);
|
||||||
await shot('46-topbar');
|
await shot('46-topbar');
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user