Until now only the editor toolbar could pause a running game. A pause menu inside the game had no way to stop the world while its own graph kept running, and there was no slow motion. - Five new nodes: Pause game, Resume game, Is game paused, Set time scale and Time scale. Pause and resume take effect on the next frame. - A game pause stops world time, physics, animations, particles and shader time. Only behaviors with the new "Run while paused" switch keep running. - Set time scale 0 freezes the world but graphs keep running, as agreed in the issue. - Play and Stop reset the time scale to 1, Load scene keeps it. The pause belongs to the scene, so Load scene and Restart unpause. - The toolbar pause stays separate and also freezes the menu. When the game pauses itself the status line reads "The game paused itself". - While paused, collision events are not reported, sounds keep playing, and the editor run ignores both nodes with a log line. Findings: - The first status line was longer than any before and got cut with an ellipsis. It is shorter now. - The e2e harness did not know the key "p". It does now. Tests: - ./check.sh: 133 files, 1968 tests, exit 0 (before 132 / 1952) - npm run build: exit 0 - GM_STEPS=18 fresh: ALL PASSED, 33 checks (before 26) - 16 new unit tests: 8 catalog cases, game-pause.test.ts, editor run - 7 new e2e checks in block 18i, driven by real keys in the game - Six counter-checks all bite: behavior filter, collision lock, world time, editor run, clock reset and the game clock in the browser - Check total 1217 in README is computed (1210 + 7), CI measures it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
424 lines
17 KiB
JavaScript
424 lines
17 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);
|
||
check(
|
||
'an exec edge firing every frame carries an evenly spaced chain of beads',
|
||
chain.length >= 1 && chain.length <= 3 && gaps.every((g) => Math.abs(g - 0.5) < 0.1),
|
||
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),
|
||
);
|
||
|
||
// 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),
|
||
);
|
||
}
|