Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus; the port and marker menus were the same list and are now one function. Value specs of the inspector and the list tab share valueSpecs.ts, and the remaining copies (compound commands, type glyphs, typed asset lookup, name rule, play/editor world setup, marquee) are merged. Four copies had already drifted apart; merging them fixes: - "Disconnect N links" in the port menu is one undo step, not N - "+ Add collider" is hidden on a placed container instance, as in the hierarchy menu, instead of always failing - a bool property coerced from the text "false" becomes false - a port command on an unknown node type reports instead of crashing Comment-only edits leave the bundle byte-identical. README corrected in 35 places (step limit, jump-run clock, suite duration, 409 body, ...). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
6.1 KiB
JavaScript
157 lines
6.1 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, clickText, press, entity, world, stop } = t;
|
||
const playInfo = () => json('__gm.playInfo()');
|
||
const boxX = async () => (await entity('p-box')).x;
|
||
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()}`,
|
||
);
|
||
await stop();
|
||
await sleep(200);
|
||
|
||
// 19f. 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),
|
||
);
|
||
}
|