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>
529 lines
21 KiB
JavaScript
529 lines
21 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Pausing freezes world and clock and drops input instead of replaying it on resume. Brings its own
|
||
* self-drifting box, since the fixture's Mover only moves on arrow keys and a pause would look like idling.
|
||
* 18i is the game pausing and slowing itself from a graph (#167), through a pause menu that runs while paused.
|
||
*/
|
||
|
||
const DRIFT_PER_SECOND = 100;
|
||
const KEY_LOG = 'arrow down';
|
||
|
||
/**
|
||
* Two independent things in one graph: `On update → Move` drifts the owner right without any
|
||
* input, and `On key down → Log` leaves a mark for every *edge* the world saw – which is what a
|
||
* pause has to swallow.
|
||
*/
|
||
const pauseGraph = {
|
||
nodes: [
|
||
node('p-update', 'OnUpdate', 0, 0),
|
||
node('p-owner', 'Owner', 0, 120),
|
||
node('p-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }),
|
||
node('p-key', 'OnKeyDown', 0, 260, { key: 'ArrowRight' }),
|
||
node('p-log', 'Log', 340, 260, { message: KEY_LOG }),
|
||
],
|
||
edges: [
|
||
edge('p-e1', 'p-update', 'exec', 'p-move', 'exec'),
|
||
edge('p-e2', 'p-update', 'dt', 'p-move', 'dt'),
|
||
edge('p-e3', 'p-owner', 'object', 'p-move', 'object'),
|
||
edge('p-e4', 'p-key', 'exec', 'p-log', 'exec'),
|
||
],
|
||
};
|
||
|
||
/**
|
||
* The pause menu of 18i (#167), running while paused: P pauses or resumes, whichever Is game
|
||
* paused says is due, and S slows the game to half speed.
|
||
*/
|
||
const pauseMenuGraph = {
|
||
nodes: [
|
||
node('pm-p', 'OnKeyDown', 0, 0, { key: 'p' }),
|
||
node('pm-paused', 'IsGamePaused', 0, 120),
|
||
node('pm-branch', 'Branch', 300, 0),
|
||
node('pm-resume', 'ResumeGame', 560, -60),
|
||
node('pm-pause', 'PauseGame', 560, 60),
|
||
node('pm-s', 'OnKeyDown', 0, 260, { key: 's' }),
|
||
node('pm-slow', 'SetTimeScale', 300, 260, { scale: 0.5 }),
|
||
],
|
||
edges: [
|
||
edge('pm-e1', 'pm-p', 'exec', 'pm-branch', 'exec'),
|
||
edge('pm-e2', 'pm-paused', 'paused', 'pm-branch', 'condition'),
|
||
edge('pm-e3', 'pm-branch', 'then', 'pm-resume', 'exec'),
|
||
edge('pm-e4', 'pm-branch', 'else', 'pm-pause', 'exec'),
|
||
edge('pm-e5', 'pm-s', 'exec', 'pm-slow', 'exec'),
|
||
],
|
||
};
|
||
|
||
export async function pause(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
has,
|
||
shot,
|
||
click,
|
||
clickToolbar,
|
||
press,
|
||
entity,
|
||
world,
|
||
stop,
|
||
select,
|
||
evaluate,
|
||
asset,
|
||
showTab,
|
||
} = t;
|
||
const playInfo = () => json('__gm.playInfo()');
|
||
const boxX = async () => (await entity('p-box'))?.x ?? NaN;
|
||
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
|
||
|
||
// 18a. A box with a drifting behavior below it, written into the model directly: what is under
|
||
// test is the pause, not another way of building a graph.
|
||
const rig = await scaffoldBehavior(t, {
|
||
assetId: 'pauser',
|
||
name: 'Pauser',
|
||
graph: pauseGraph,
|
||
boxId: 'p-box',
|
||
boxName: 'Pause box',
|
||
logicId: 'p-logic',
|
||
y: LANES.pause,
|
||
});
|
||
|
||
await t.play();
|
||
const running = await playInfo();
|
||
const beforeDrift = await boxX();
|
||
await sleep(500);
|
||
const drifted = await boxX();
|
||
// The precondition of everything below: without movement, "it stood still" is trivially true.
|
||
check(
|
||
'the box drifts on its own while playing, with nothing held down',
|
||
running.playing === true && running.paused === false && drifted > beforeDrift + 20,
|
||
`x ${beforeDrift} → ${drifted}, ${JSON.stringify(running)}`,
|
||
);
|
||
|
||
// 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
|
||
// ticking while the picture happens to look the same would pass a position check alone.
|
||
await clickToolbar('pause');
|
||
const held = await playInfo();
|
||
const heldX = await boxX();
|
||
await sleep(1000);
|
||
const stillHeld = await playInfo();
|
||
check(
|
||
'pausing stops the world where it is: the box and the clock both stand still',
|
||
held.paused === true &&
|
||
stillHeld.playing === true &&
|
||
Math.abs((await boxX()) - heldX) < 0.001 &&
|
||
Math.abs(stillHeld.time - held.time) < 0.05,
|
||
`x ${heldX} → ${await boxX()}, clock ${held.time.toFixed(2)} → ${stillHeld.time.toFixed(2)} over 1 s`,
|
||
);
|
||
// The buttons carry no caption (#183), so what they offer is read as the picture each one
|
||
// wears: while the run is held, the pause button shows a second play sign, and the play button
|
||
// has turned into the stop square for as long as the run is on.
|
||
const heldButtons = await json('__gm.runButtons()');
|
||
check(
|
||
'the button offers to resume while it is held, and no longer to pause',
|
||
heldButtons.pause === 'play' && heldButtons.play === 'stop' && heldButtons.pauseEnabled === true,
|
||
JSON.stringify(heldButtons),
|
||
);
|
||
await shot('36-paused');
|
||
|
||
// 18c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
|
||
// each of them. The count before proves the graph reacts at all.
|
||
const hitsBefore = await keyHits();
|
||
await press('ArrowRight');
|
||
await sleep(1000);
|
||
const hitsWhilePaused = await keyHits();
|
||
await press('ArrowRight');
|
||
await press('ArrowRight');
|
||
|
||
// 18d. Ctrl+Enter resumes, the same shortcut that started play.
|
||
await press('Enter', { ctrl: true });
|
||
const resumed = await playInfo();
|
||
const resumedX = await boxX();
|
||
check(
|
||
'resuming lets it move on from where it stopped, without catching up the held time',
|
||
resumed.paused === false &&
|
||
resumedX > heldX &&
|
||
// A second of pause plus the pressing above: catching up would show as a jump far beyond
|
||
// what the resumed run itself had time for.
|
||
resumedX < heldX + DRIFT_PER_SECOND * 1.2,
|
||
`x ${heldX} → ${resumedX}, clock ${stillHeld.time.toFixed(2)} → ${resumed.time.toFixed(2)}`,
|
||
);
|
||
const hitsAfter = await keyHits();
|
||
check(
|
||
'a key pressed while paused is swallowed instead of firing on resume',
|
||
hitsWhilePaused === hitsBefore && hitsAfter === hitsBefore,
|
||
`${hitsBefore} hits before, ${hitsWhilePaused} while held, ${hitsAfter} after resuming`,
|
||
);
|
||
// Without this the check above would also pass on a graph that never logs anything at all.
|
||
await press('ArrowRight');
|
||
check(
|
||
'the same key does fire once the game is running again',
|
||
(await keyHits()) === hitsBefore + 1,
|
||
`${hitsBefore} → ${await keyHits()}`,
|
||
);
|
||
|
||
// 18e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
|
||
// moving. A pause left standing would leave the editor unable to run anything.
|
||
await press('Enter', { ctrl: true });
|
||
check('Ctrl+Enter holds it again', (await playInfo()).paused === true);
|
||
await stop();
|
||
const afterStop = await playInfo();
|
||
await t.play();
|
||
await sleep(500);
|
||
const restartedX = await boxX();
|
||
await sleep(400);
|
||
check(
|
||
'stopping clears the hold, so the next play runs',
|
||
afterStop.playing === false &&
|
||
afterStop.paused === false &&
|
||
(await playInfo()).paused === false &&
|
||
(await boxX()) > restartedX,
|
||
`after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`,
|
||
);
|
||
|
||
// 18f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
|
||
// selects the Move node, Delete removes it, and the running world stops drifting the box.
|
||
await select({ kind: 'asset', assetId: 'pauser' });
|
||
await showTab('graph');
|
||
// The live values are not drawn, they are carried: the running graph has to execute once while
|
||
// this tab is open before a node output holds anything. That is game time, not frames.
|
||
await sleep(150);
|
||
const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`);
|
||
const live = {
|
||
owner: await outputs('p-owner'),
|
||
update: await outputs('p-update'),
|
||
move: await outputs('p-move'),
|
||
};
|
||
check(
|
||
// Not a fixed number of decimals: the live value is formatted with trailing zeros trimmed, and
|
||
// a slow frame of exactly 50 ms reads as "0.05", not "0.050". CI failed on exactly that
|
||
// (task 175), while the frame time here always lands on three digits.
|
||
'while playing, each output shows the value it last carried in front of its name',
|
||
live.owner[0] === 'Pause box object' &&
|
||
/^0\.\d+ {2}dt$/.test(live.update[1]) &&
|
||
live.move[1] === 'false blocked x',
|
||
JSON.stringify(live),
|
||
);
|
||
await shot('36b-graph-live-values');
|
||
const beadsOn = async (edgeId) =>
|
||
(await json('__gm.graph.beads()')).filter((b) => b.edgeId === edgeId).map((b) => b.progress);
|
||
const chain = await beadsOn('p-e1');
|
||
const gaps = chain.slice(1).map((p, i) => chain[i] - p);
|
||
// A bead leaves on the first frame at least 0.15 s of game time after the one before (0.5 of a
|
||
// 0.3 s trip), so a gap is that plus up to one frame – CI task 261 had a 33 ms frame: 0.611.
|
||
check(
|
||
'an exec edge firing every frame carries an evenly spaced chain of beads',
|
||
chain.length >= 1 && chain.length <= 3 && gaps.every((g) => g > 0.49 && g < 0.5 + 0.05 / 0.3),
|
||
JSON.stringify(chain),
|
||
);
|
||
check(
|
||
'data edges carry no beads',
|
||
(await beadsOn('p-e2')).length === 0 && (await beadsOn('p-e3')).length === 0,
|
||
);
|
||
const beforeKey = await beadsOn('p-e4');
|
||
await press('ArrowRight');
|
||
const onKey = await beadsOn('p-e4');
|
||
await sleep(500);
|
||
const afterKey = await beadsOn('p-e4');
|
||
check(
|
||
'a single key press sends exactly one bead, and it arrives',
|
||
beforeKey.length === 0 && onKey.length === 1 && afterKey.length === 0,
|
||
JSON.stringify({ beforeKey, onKey, afterKey }),
|
||
);
|
||
const movePos = await json(`__gm.graph.nodeAt('p-move')`);
|
||
await click(movePos.x, movePos.y);
|
||
check(
|
||
'a click selects a node while playing',
|
||
JSON.stringify(await json('__gm.graph.selected()')) === '["p-move"]',
|
||
JSON.stringify(await json('__gm.graph.selected()')),
|
||
);
|
||
const area = await json('__gm.graph.canvasRect()');
|
||
await click(area.x + area.w - 15, area.y + area.h - 15, 'right');
|
||
check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…'));
|
||
await press('Escape');
|
||
check(
|
||
'Escape closes the palette and leaves the game running',
|
||
!(await has('Search nodes…')) && (await playInfo()).playing === true,
|
||
);
|
||
await click(movePos.x, movePos.y);
|
||
await press('Delete');
|
||
const stoppedAt = await boxX();
|
||
await sleep(500);
|
||
check(
|
||
'deleting the Move node while playing stops the drift on the next frames',
|
||
!(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move') &&
|
||
Math.abs((await boxX()) - stoppedAt) < 0.001,
|
||
`x ${stoppedAt} → ${await boxX()}`,
|
||
);
|
||
await stop();
|
||
check(
|
||
'stopping takes the values off the nodes',
|
||
JSON.stringify(await outputs('p-owner')) === '["object"]',
|
||
JSON.stringify(await outputs('p-owner')),
|
||
);
|
||
check(
|
||
'stopping discards the graph edit with the rest of the run',
|
||
(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move'),
|
||
JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)),
|
||
);
|
||
|
||
// 18g. (#90) One clock under everything that moves by itself – the world's delta, particle age and
|
||
// shader time. It starts over with a run and ends with it, and a pause is a scale of 0.
|
||
const clock = () => json('__gm.globalTime()');
|
||
// The bounds are a multiple apart rather than an epsilon: reading the clock costs a round trip,
|
||
// and without the reset the run would start at everything the editor counted, not a little of it.
|
||
await sleep(2500);
|
||
const beforePlay = await clock();
|
||
check(
|
||
'the clock runs in edit mode too, so the previews move without a game',
|
||
beforePlay.now > 2 && beforePlay.effective === 1,
|
||
JSON.stringify(beforePlay),
|
||
);
|
||
await t.play();
|
||
const atStart = await clock();
|
||
check(
|
||
'a run starts the clock over, however long the editor has been counting',
|
||
atStart.now < 1 && atStart.now < beforePlay.now / 2,
|
||
`editor at ${beforePlay.now.toFixed(2)} s → run at ${atStart.now.toFixed(2)} s`,
|
||
);
|
||
|
||
await sleep(400);
|
||
const runningClock = await clock();
|
||
await clickToolbar('pause');
|
||
const heldClock = await clock();
|
||
await sleep(800);
|
||
const stillHeldClock = await clock();
|
||
check(
|
||
'a pause is an effective scale of 0, and it gates the scale instead of overwriting it',
|
||
runningClock.effective === 1 &&
|
||
heldClock.effective === 0 &&
|
||
heldClock.scale === 1 &&
|
||
Math.abs(stillHeldClock.now - heldClock.now) < 0.02,
|
||
`effective ${runningClock.effective} → ${heldClock.effective} (scale ${heldClock.scale}), clock ${heldClock.now.toFixed(2)} → ${stillHeldClock.now.toFixed(2)} over 0.8 s`,
|
||
);
|
||
|
||
// Twice the scale has to move the clock and the world by the same factor: the delta a behavior
|
||
// reads is the step the clock took, not a second measurement of the wall clock.
|
||
await press('Enter', { ctrl: true });
|
||
const slowFrom = { t: await clock(), x: await boxX() };
|
||
await sleep(600);
|
||
const slowTo = { t: await clock(), x: await boxX() };
|
||
await evaluate('__gm.setTimeScale(2)');
|
||
await sleep(150);
|
||
const fastFrom = { t: await clock(), x: await boxX() };
|
||
await sleep(600);
|
||
const fastTo = { t: await clock(), x: await boxX() };
|
||
await evaluate('__gm.setTimeScale(1)');
|
||
const slow = { time: slowTo.t.now - slowFrom.t.now, drift: slowTo.x - slowFrom.x };
|
||
const fast = { time: fastTo.t.now - fastFrom.t.now, drift: fastTo.x - fastFrom.x };
|
||
const twice = (a, b) => b > a * 1.7 && b < a * 2.3;
|
||
check(
|
||
'at scale 2 the clock and the drifting box both move twice as far in the same wall second',
|
||
slow.drift > 20 && twice(slow.time, fast.time) && twice(slow.drift, fast.drift),
|
||
JSON.stringify({ slow, fast }),
|
||
);
|
||
|
||
await stop();
|
||
const afterStopClock = await clock();
|
||
check(
|
||
'stopping puts the clock back to zero, so the editor shows a freshly started scene again',
|
||
afterStopClock.now < 1 &&
|
||
afterStopClock.now < fastTo.t.now / 2 &&
|
||
afterStopClock.scale === 1 &&
|
||
afterStopClock.effective === 1,
|
||
`run ended at ${fastTo.t.now.toFixed(2)} s → editor at ${afterStopClock.now.toFixed(2)} s`,
|
||
);
|
||
|
||
// 18i. (#167) The game pauses itself. A pause menu is a behavior that runs while paused: P asks
|
||
// Is game paused and pauses or resumes, S sets the time scale to 0.5. Everything is driven by
|
||
// keys in the running game, and the drifting box from 18a is what has to hold or slow down.
|
||
const menu = await scaffoldBehavior(t, {
|
||
assetId: 'pause-menu',
|
||
name: 'Pause menu',
|
||
graph: pauseMenuGraph,
|
||
boxId: 'pm-box',
|
||
boxName: 'Pause menu box',
|
||
logicId: 'pm-logic',
|
||
y: LANES.pause - 150,
|
||
props: { enabled: true, runInEditor: false, runWhilePaused: true },
|
||
});
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: 'pm-logic' });
|
||
check('a behavior object offers "Run while paused" in the inspector', await has('Run while paused'));
|
||
|
||
await t.play();
|
||
await sleep(300);
|
||
const drift = async (ms) => {
|
||
const from = { x: await boxX(), t: (await clock()).now };
|
||
await sleep(ms);
|
||
return { x: (await boxX()) - from.x, t: (await clock()).now - from.t };
|
||
};
|
||
const normal = await drift(600);
|
||
await press('p');
|
||
await sleep(100);
|
||
const gameHeld = { info: await playInfo(), clock: await clock() };
|
||
const heldDrift = await drift(600);
|
||
check(
|
||
'Pause game from a key holds the box and the clock, and it is the game pausing, not the toolbar',
|
||
normal.x > 20 &&
|
||
gameHeld.info.gamePaused === true &&
|
||
gameHeld.info.paused === false &&
|
||
gameHeld.clock.effective === 0 &&
|
||
gameHeld.clock.scale === 1 &&
|
||
Math.abs(heldDrift.x) < 0.001 &&
|
||
Math.abs(heldDrift.t) < 0.001,
|
||
JSON.stringify({ normal, gameHeld, heldDrift }),
|
||
);
|
||
check('the status line says the game paused itself', await has('The game paused itself · Esc stops'));
|
||
await shot('36c-game-paused');
|
||
// Only a behavior that runs while paused can have heard this key: the drifting one stands still.
|
||
await press('p');
|
||
await sleep(100);
|
||
const resumedDrift = await drift(600);
|
||
check(
|
||
'the same key resumes it – the menu behavior kept running while everything else stood',
|
||
(await playInfo()).gamePaused === false && resumedDrift.x > 20,
|
||
JSON.stringify(resumedDrift),
|
||
);
|
||
|
||
await press('s');
|
||
await sleep(100);
|
||
const slowed = { clock: await clock(), drift: await drift(800) };
|
||
const rate = (d) => d.x / d.t;
|
||
check(
|
||
'Set time scale 0.5 halves the clock, and the box drifts as far per clock second as before',
|
||
slowed.clock.scale === 0.5 &&
|
||
slowed.clock.effective === 0.5 &&
|
||
slowed.drift.t > 0.2 &&
|
||
slowed.drift.t < 0.55 &&
|
||
Math.abs(rate(slowed.drift) - rate(resumedDrift)) < DRIFT_PER_SECOND * 0.1,
|
||
JSON.stringify({ slowed, resumedDrift }),
|
||
);
|
||
await stop();
|
||
const afterGameStop = await clock();
|
||
await t.play();
|
||
const replay = await clock();
|
||
await stop();
|
||
check(
|
||
'stopping ends the slow motion, and the next Play starts at real time',
|
||
afterGameStop.scale === 1 && replay.scale === 1 && replay.effective === 1,
|
||
JSON.stringify({ afterGameStop, replay }),
|
||
);
|
||
const menuLeft = await menu.remove();
|
||
check(
|
||
'the pause menu leaves nothing of its own behind',
|
||
menuLeft.objects === 0 && menuLeft.assets === 0,
|
||
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(
|
||
'the step leaves nothing of its own behind',
|
||
left.objects === 0 && left.assets === 0,
|
||
JSON.stringify(left),
|
||
);
|
||
}
|