The graph was only locked by five edit-mode guards in GraphCanvas. The running game reads the graph by reference each frame, so a new node, edge, changed value, disconnect or undo takes effect from the next frame. Stop discards these edits like everything else changed in play. A new On start node does not fire for a behavior that has already started. During play every data output shows the value it last produced, in the highlight colour and refreshed ten times a second. Values are kept per graph, because node ids are only unique within one; with several instances the most recent value wins. The Variables node reads the live variable bag, so only its own outputs are kept, as a snapshot. Each time an exec edge fires, a bead runs along it in 0.3 s, at most one every 0.15 s per edge, following the same curve the edge is drawn with. Beads run on game time and freeze while paused; the game logic is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
9.4 KiB
JavaScript
255 lines
9.4 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,
|
||
} = 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;
|
||
|
||
// 19a. 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)}`,
|
||
);
|
||
|
||
// 19b. 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');
|
||
await sleep(200);
|
||
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');
|
||
|
||
// 19c. 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');
|
||
await sleep(200);
|
||
|
||
// 19d. Ctrl+Enter resumes, the same shortcut that started play.
|
||
await press('Enter', { ctrl: true });
|
||
await sleep(400);
|
||
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');
|
||
await sleep(300);
|
||
check(
|
||
'the same key does fire once the game is running again',
|
||
(await keyHits()) === hitsBefore + 1,
|
||
`${hitsBefore} → ${await keyHits()}`,
|
||
);
|
||
|
||
// 19e. 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 });
|
||
await sleep(200);
|
||
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()}`,
|
||
);
|
||
|
||
// 19f. 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 evaluate(`__gm.showTab('graph')`);
|
||
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(
|
||
'while playing, each output shows the value it last carried in front of its name',
|
||
live.owner[0] === 'Pause box object' &&
|
||
/^0\.\d{3} {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');
|
||
await sleep(80);
|
||
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);
|
||
await sleep(100);
|
||
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');
|
||
await sleep(100);
|
||
check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…'));
|
||
await press('Escape');
|
||
await sleep(100);
|
||
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');
|
||
await sleep(150);
|
||
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();
|
||
await sleep(200);
|
||
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)),
|
||
);
|
||
|
||
// 19g. 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),
|
||
);
|
||
}
|