317 lines
13 KiB
JavaScript
317 lines
13 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.
|
||
*/
|
||
|
||
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'),
|
||
],
|
||
};
|
||
|
||
export async function pause(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
has,
|
||
shot,
|
||
click,
|
||
clickText,
|
||
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 clickText('❚❚ 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`,
|
||
);
|
||
// `locate` throws on a missing label, so the button that must be *gone* is asked for through
|
||
// the hook, which answers with null.
|
||
check(
|
||
'the button offers to resume while it is held, and no longer to pause',
|
||
(await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false,
|
||
`resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`,
|
||
);
|
||
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 clickText('❚❚ 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`,
|
||
);
|
||
|
||
// 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),
|
||
);
|
||
}
|