Files
feuerwerk/tools/e2e/31-particles.mjs
T
root 0f9e1611d1
game-maker / suite (push) Failing after 4m22s
A particle is a function of its age, not a thing that is stepped (#73)
The asset type `particles` is placeable and carries a graph at the same time —
`behavior` was the blueprint. Because a continuous emitter gives birth to particle n
at time n/rate, the whole field is a function of the clock rather than of the frames:
no ring-buffer bookkeeping, exact time jumps, reproducibility as a measurable property.

compile.ts follows compileShader's frame but emits JS closures into a Float64Array
instead of GLSL, and without eval; a node with two consumers is evaluated once.
Drawing is one Pixi ParticleContainer per source, so one draw call.

Collision, wind and braking in flight are out for good — that is the price of the
formula model, not an omission.

The tab count goes from nine to ten, which is exactly the case #84 was built for one
day earlier: at 1280 px the row no longer holds every tab, so guard 14b now asks for
hidden.length > 0 instead of === 0.
2026-09-14 05:44:25 +02:00

333 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LANES } from './lib/scaffold.mjs';
/**
* Particle assets (#73): a graph that says what one particle *is* as a function of its age and of a
* random number drawn once for it. Because nothing is simulated, the whole field is a function of
* the clock – which is what lets every check here freeze particle time and read an exact second
* instead of sleeping and hoping.
*
* The source lives in its own lane (`__gm.lookAt`): the origin is crowded by what the earlier steps
* leave behind, and symbols draw above objects.
*/
const LANE = LANES.particles;
const SOURCE = 'pt-source';
/** The type's defaults: 20 a second, each living one second, 500 at most. */
const RATE = 20;
export async function particles(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, locate, typeText, press, shot } = t;
const { wheel } = t;
const { select } = t;
/** Freeze, restart every source's clock, then step the clock to `seconds` and let it redraw. */
const at = async (seconds) => {
await evaluate(`__gm.particleTime(${1000 + seconds})`);
await sleep(150);
};
const rewind = async () => {
await evaluate('__gm.particleTime(1000)');
await sleep(120);
await evaluate('__gm.particlesRestart()');
await sleep(120);
};
const drawn = (limit = 8) => json(`__gm.particlesOf(${JSON.stringify(SOURCE)}, 'scene', ${limit})`);
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
await sleep(200);
};
/**
* The `+` menu has one entry per asset type and scrolls past 320 px, so the last of them sits
* under the mask: `locate` still finds it, but a click there lands on nothing. Scroll it in first.
*/
const pickFromMenu = async (label) => {
const box = await json('__gm.overlayRect()');
let p = await locate(label, true);
let scrolled = false;
if (box && (!p || p.y > box.y + box.h - 4)) {
await wheel(box.x + box.w / 2, box.y + box.h / 2, 300);
await sleep(250);
p = await locate(label, true);
scrolled = true;
}
await click(p.x, p.y);
await sleep(250);
return { box, p, scrolled };
};
/** Places a node from the palette. The list scrolls, so the name is searched into view first. */
const placeNode = async (label, dx, dy) => {
const canvas = await json('__gm.particles.canvasRect()');
await click(canvas.x + dx, canvas.y + dy, 'right');
await sleep(300);
await typeText(label);
await sleep(200);
await clickLast(label);
await sleep(300);
};
const nodeIdOf = (type) =>
json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)})
.graph.nodes.find((n) => n.type === ${JSON.stringify(type)})?.id ?? ''`);
/**
* Adds a node straight to the graph. The palette is proved by the two nodes placed through it
* above; the tab's canvas is short (the preview strip takes the lower part), so dropping five
* more through it would be a test of where they happen to land rather than of what they do.
*/
const addNode = async (id, type, params = {}) => {
await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.nodes.push({
id: ${JSON.stringify(id)}, type: ${JSON.stringify(type)}, x: 0, y: 0,
params: ${JSON.stringify(params)} });`);
await sleep(150);
};
const link = async (id, from, fromPort, to, toPort) => {
await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.edges.push({
id: ${JSON.stringify(id)}, from: { node: ${JSON.stringify(from)}, port: ${JSON.stringify(fromPort)} },
to: { node: ${JSON.stringify(to)}, port: ${JSON.stringify(toPort)} } });`);
await sleep(150);
};
let assetId = '';
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
await evaluate("__gm.showTab('scene')");
await sleep(200);
// 31a. "+ → Particles" makes the asset and opens its tab; with no Output there is nothing to run.
await evaluate('__gm.console.clear()');
await clickText('+');
await sleep(200);
const picked = await pickFromMenu('Particles');
await sleep(400);
const created = await json(`{
tab: __gm.tab(),
current: __gm.particles.current(),
info: __gm.particles.info(),
assets: __gm.state.project.assets.filter((a) => a.type === 'particles').map((a) => a.id),
}`);
check(
'the + menu creates a particle asset and opens the Particles tab on it, with nothing to compile',
created.tab === 'particles' &&
created.assets.length === 1 &&
created.current === created.assets[0] &&
created.info === 'No Output node',
JSON.stringify({ ...created, picked }),
);
if (created.assets.length !== 1) return;
assetId = created.assets[0];
// Renamed away from "Particles": the tab button says that too, and a lookup by text would find it.
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).name = 'Sparks'`);
await sleep(200);
// 31b. The palette of this tab holds particle nodes and nothing else.
const canvas = await json('__gm.particles.canvasRect()');
await click(canvas.x + 80, canvas.y + 60, 'right');
await sleep(300);
const palette = await json('__gm.overlayTexts()');
check(
'the palette offers particle nodes, not behavior ones',
palette.includes('Output') && palette.includes('Gravity') && !palette.includes('Branch'),
JSON.stringify(palette.slice(0, 12)),
);
await press('Escape');
await sleep(200);
// 31c. One Output node is a whole graph: a particle without rules is one at the source, full size.
await placeNode('Output', 460, 60);
const withOutput = await json('{ info: __gm.particles.info(), preview: __gm.particles.preview() }');
check(
'an Output node alone compiles, and the strip below the graph emits',
withOutput.info === '0 parameters' && withOutput.preview.count > 0,
JSON.stringify(withOutput),
);
// 31d. The slider jumps to a second exactly: 20 a second for one second means 0..10 are alive
// at 0.5 s – eleven of them, whatever the frame rate was on the way there.
await evaluate('__gm.particles.hold(0.5)');
await sleep(200);
const held = await json('__gm.particles.preview()');
check(
'holding the preview at 0.5 s shows exactly the particles due by then, not a frame-rate count',
held.holding === true && held.time === 0.5 && held.count === RATE / 2 + 1,
JSON.stringify(held),
);
await shot('31-particles-tab');
await evaluate('__gm.particles.hold(null)');
// 31e. Placed in the scene, the same asset is a source with its own rows.
await mutate(`
__gm.state.activeScene.objects.push({ id: ${JSON.stringify(SOURCE)}, assetId: ${JSON.stringify(assetId)},
name: 'Fountain', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
overrides: {}, parentId: '', instanceEdits: {} });
__gm.lookAt(0, ${LANE});`);
await evaluate("__gm.showTab('scene')");
await sleep(400);
await rewind();
await at(0.5);
const inScene = await drawn();
check(
'the placed source draws the same field in the scene, on the same clock',
Boolean(inScene) && inScene.count === RATE / 2 + 1,
JSON.stringify(inScene && { count: inScene.count, first: inScene.drawn[0] }),
);
if (!inScene) return;
// 31f. Gravity is ½gt², not a force stepped frame by frame: the oldest particle, half a second
// old, is exactly 100 px below the source at strength 800.
await evaluate("__gm.showTab('particles')");
await sleep(250);
await placeNode('Gravity', 80, 60);
const gravityId = await nodeIdOf('ParticleGravity');
const outputId = await nodeIdOf('ParticleOutput');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph;
graph.edges.push({ id: 'pt-e1', from: { node: ${JSON.stringify(gravityId)}, port: 'y' },
to: { node: ${JSON.stringify(outputId)}, port: 'y' } });`);
await evaluate("__gm.showTab('scene')");
await sleep(300);
await rewind();
await at(0.5);
const falling = await drawn(2);
const oldest = falling?.drawn[0];
check(
'a Gravity node puts the oldest particle exactly ½·800·t² below the source',
Boolean(oldest) && Math.abs(oldest.y - (LANE + 100)) < 0.5 && Math.abs(oldest.x) < 0.5,
JSON.stringify({ lane: LANE, oldest }),
);
// 31g. The same second twice is the same picture – the promise a formula makes and a simulation
// cannot: stepping the clock away and back lands on the very same numbers.
await at(1.4);
await at(0.5);
const again = await drawn(2);
check(
'stepping the clock away and back reproduces the field exactly',
JSON.stringify(again?.drawn) === JSON.stringify(falling?.drawn),
JSON.stringify({ first: falling?.drawn[0], again: again?.drawn[0] }),
);
// 31h. Age → alpha: the old ones are the faint ones. Age is 0..1 over the lifetime, so
// Subtract(1, age) is a fade-out.
await addNode('pt-sub', 'ParticleSubtract', { a: 1 });
await addNode('pt-age', 'ParticleAge');
await link('pt-e2', 'pt-age', 'age', 'pt-sub', 'b');
await link('pt-e3', 'pt-sub', 'result', outputId, 'alpha');
await sleep(300);
await rewind();
await at(0.5);
const fading = await drawn(11);
const first = fading?.drawn[0];
const last = fading?.drawn.at(-1);
check(
'Age into alpha fades the old ones out: the oldest is at 0.5, the newest at 1',
Boolean(first && last) && Math.abs(first.alpha - 0.5) < 0.02 && Math.abs(last.alpha - 1) < 0.02,
JSON.stringify({ oldest: first?.alpha, newest: last?.alpha }),
);
// 31i. A Property node is a row on the placed source, like a shader parameter on a sprite.
await addNode('pt-prop', 'ParticleProperty', { name: 'drift', default: 0, min: -200, max: 200 });
await link('pt-e4', 'pt-prop', 'value', outputId, 'x');
await sleep(300);
await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE });
await sleep(350);
const driftRow = await json("__gm.inspectorField('drift')");
const rateRow = await json("__gm.inspectorField('Rate')");
check(
'the graph’s Property becomes a row on the source, below its own rows',
Boolean(driftRow) && Boolean(rateRow) && driftRow.y > rateRow.y,
JSON.stringify({ drift: driftRow, rate: rateRow }),
);
await mutate(`
__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.drift = 60;`);
await sleep(250);
await rewind();
await at(0.5);
const drifted = await drawn(2);
check(
'setting that row on the source moves its particles',
Math.abs((drifted?.drawn[0]?.x ?? 0) - 60) < 0.5,
JSON.stringify(drifted?.drawn[0]),
);
// 31j. Emitting off keeps the ones in flight and takes in no new ones; a lifetime later the
// field is empty, and switching it back on fills it again.
await mutate(`
__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.emitting = false;`);
await at(0.6);
const stopping = await drawn(1);
await at(1.6);
const stopped = await drawn(1);
check(
'switching Emitting off lets the ones in flight live out their lives, then leaves nothing',
(stopping?.count ?? 0) > 0 && stopped?.count === 0,
JSON.stringify({ stopping: stopping?.count, stopped: stopped?.count }),
);
await mutate(`
__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.emitting = true;`);
await sleep(200);
await rewind();
await at(0.5);
check('switching it back on fills the field again', (await drawn(1))?.count === RATE / 2 + 1, '');
// 31k. Burst: every particle at once, so they all share one age – and they end together.
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { mode: 'burst', count: 7 });`);
await sleep(200);
await rewind();
await at(0.5);
const burst = await drawn(7);
const alphas = new Set(burst?.drawn.map((p) => p.alpha));
await at(1.2);
const afterBurst = await drawn(1);
check(
'a burst throws its whole count at once, all of one age, and ends one lifetime later',
burst?.count === 7 && alphas.size === 1 && afterBurst?.count === 0,
JSON.stringify({ count: burst?.count, alphas: [...alphas], after: afterBurst?.count }),
);
// 31l. It really draws: the pixel where a burst particle sits is not the scene background.
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { size: 40, color: 0x3cc85a, drift: 0 });`);
await sleep(200);
await rewind();
await at(0.1);
const spotOn = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`);
const drawnAt = await drawn(1);
// ½·800·0.1² = 4 px down, so the particle still covers the source's own point.
const colour = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`);
check(
'a particle actually paints where the numbers say it is',
colour !== '#22252c' && (drawnAt?.count ?? 0) > 0,
JSON.stringify({ colour, at: spotOn, drawn: drawnAt?.drawn[0] }),
);
await shot('31-particles-scene');
// 31m. The graph tab draws no Pixi complaint of its own.
const logged = await json('__gm.console.lines()');
check(
'nothing about the particle tab draws a complaint from Pixi',
!logged.some((line) => /Deprecation/i.test(JSON.stringify(line))),
JSON.stringify(logged.slice(0, 3)),
);
// 31n. The step takes back everything it made, so the steps after it see the fixture again.
await evaluate('__gm.particleTime(null)');
await evaluate('__gm.state.select(null)');
await mutate(`
const project = __gm.state.project;
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(assetId)});
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
(o) => o.id !== ${JSON.stringify(SOURCE)});
__gm.lookAt(0, 0);`);
await evaluate("__gm.showTab('scene')");
await sleep(300);
check(
'the step leaves nothing of its own behind',
(await json("__gm.state.project.assets.filter((a) => a.type === 'particles').length")) === 0 &&
(await drawn(1)) === null,
'',
);
}