A particle is a function of its age, not a thing that is stepped (#73)
game-maker / suite (push) Failing after 4m22s
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.
This commit is contained in:
1 parent
f83c568999
commit
0f9e1611d1
39 files changed
+2386
-32
No files matched your search
@@ -17,6 +17,7 @@ const LABELS = {
|
||||
animation: 'Animation',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
particles: 'Particles',
|
||||
};
|
||||
|
||||
export async function overflow(t) {
|
||||
@@ -63,9 +64,16 @@ export async function overflow(t) {
|
||||
const wide = await json('__gm.tabRow()');
|
||||
check(
|
||||
'every tab button ends inside the centre column',
|
||||
spillOf(wide, await columnRight()).length === 0 && wide.hidden.length === 0,
|
||||
spillOf(wide, await columnRight()).length === 0,
|
||||
JSON.stringify({ spilled: spillOf(wide, await columnRight()), hidden: wide.hidden }),
|
||||
);
|
||||
// The tenth tab (Particles, #73) is the case #84 was built for: 631 px of nine tabs already left
|
||||
// 35 px in a 1280 px window, so at ten the row has to put something away even undisturbed.
|
||||
check(
|
||||
'at the test window’s width the row already needs its overflow, and still shows what is edited',
|
||||
wide.hidden.length > 0 && wide.shown.some((b) => b.tab === 'scene'),
|
||||
JSON.stringify({ hidden: wide.hidden, shown: wide.shown.map((b) => b.tab) }),
|
||||
);
|
||||
|
||||
// 14b-ii. Narrowed until the row cannot hold nine tabs, the leftovers go behind the overflow
|
||||
// button instead of under the inspector – the guard against the tenth tab (#84).
|
||||
@@ -113,13 +121,13 @@ export async function overflow(t) {
|
||||
await sleep(300);
|
||||
const back = await json('__gm.tabRow()');
|
||||
check(
|
||||
'giving the room back brings every tab into the row',
|
||||
back.hidden.length === 0 && spillOf(back, await columnRight()).length === 0,
|
||||
JSON.stringify({ hidden: back.hidden }),
|
||||
'giving the room back brings tabs into the row again, and none past the edge',
|
||||
back.hidden.length < narrow.hidden.length && spillOf(back, await columnRight()).length === 0,
|
||||
JSON.stringify({ hidden: back.hidden, narrow: narrow.hidden }),
|
||||
);
|
||||
|
||||
// 14c. Every tab, because each one owns a different set of controls.
|
||||
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list']) {
|
||||
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list', 'particles']) {
|
||||
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
|
||||
await sleep(400);
|
||||
const spills = await json('__gm.textOverflows()');
|
||||
@@ -152,7 +160,7 @@ export async function overflow(t) {
|
||||
|
||||
// 14f. The other direction: a control narrow enough to swallow its whole label draws nothing
|
||||
// at all, which the overflow walk cannot see – an empty text spills over no edge.
|
||||
for (const tab of ['scene', 'graph', 'texture', 'container', 'list']) {
|
||||
for (const tab of ['scene', 'graph', 'texture', 'container', 'list', 'particles']) {
|
||||
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
|
||||
await sleep(400);
|
||||
const dropped = await json('__gm.labelsDropped()');
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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,
|
||||
'',
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { consolePanel } from './27-console.mjs';
|
||||
import { shader } from './28-shader.mjs';
|
||||
import { transform } from './29-transform.mjs';
|
||||
import { font } from './30-font.mjs';
|
||||
import { particles } from './31-particles.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -65,4 +66,5 @@ export const STEPS = [
|
||||
shader,
|
||||
transform,
|
||||
font,
|
||||
particles,
|
||||
];
|
||||
@@ -18,6 +18,7 @@ export const LANES = {
|
||||
console: -1100,
|
||||
transform: -1500,
|
||||
font: -1900,
|
||||
particles: -2300,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user