game-maker / suite (push) Failing after 4m22s
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.
333 lines
14 KiB
JavaScript
333 lines
14 KiB
JavaScript
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,
|
||
'',
|
||
);
|
||
}
|