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, drag } = 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 placeNodeAt = async (label, point) => { await click(point.x, point.y, 'right'); await sleep(300); await typeText(label); await sleep(200); await clickLast(label); await sleep(300); }; const placeNode = async (label, dx, dy) => { const canvas = await json('__gm.particles.canvasRect()'); await placeNodeAt(label, { x: canvas.x + dx, y: canvas.y + dy }); }; 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 narrow (the preview panel takes the right of it), 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', 80, 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)'); // 31d-ii. (#86) The controls read the panel rather than their own history: the slider holds it, so // the button has to offer the way back, and Restart on a held panel stands at second 0. const track = await json('__gm.particles.sliderAt()'); await click(track.x, track.y); await sleep(200); const dragged = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'dragging the time slider holds the panel, and the button then offers to run it again', dragged.p.holding === true && dragged.p.time > 0 && dragged.c.hold === 'Run' && dragged.c.time === `${dragged.p.time.toFixed(2)} s` && Math.abs(dragged.c.slider - dragged.p.time) < 0.01, JSON.stringify(dragged), ); await clickText('Restart'); await sleep(200); const restarted = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'Restart puts a held panel back to second 0, and the readout and slider with it', restarted.p.holding === true && restarted.p.time === 0 && restarted.c.time === '0.00 s' && restarted.c.slider === 0 && restarted.c.hold === 'Run', JSON.stringify(restarted), ); await clickText('Run'); await sleep(250); const running = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'the same button lets it run again and says "Hold" once it does', running.p.holding === false && running.c.hold === 'Hold', JSON.stringify(running), ); // 31d-iii. (#86) The panel stands beside the graph, not under it, and the divider between them is // the one that moves – a graph is read across, so the width is what it cannot spare. const beside = await json(`{ canvas: __gm.particles.canvasRect(), preview: __gm.particles.previewRect(), split: __gm.particles.splitterAt(), }`); check( 'the preview stands right of the graph, with the divider between the two', beside.preview.x >= beside.canvas.x + beside.canvas.w - 1 && beside.split.x > beside.canvas.x + beside.canvas.w - 1 && beside.split.x < beside.preview.x + 1 && beside.preview.y + beside.preview.h > beside.canvas.y + beside.canvas.h / 2, JSON.stringify(beside), ); await drag(beside.split.x, beside.split.y, beside.split.x - 80, beside.split.y, { steps: 8 }); await sleep(200); const widened = await json(`{ canvas: __gm.particles.canvasRect(), preview: __gm.particles.previewRect(), }`); check( 'dragging that divider left widens the preview and narrows the graph by the same 80 px', widened.preview.w === beside.preview.w + 80 && widened.canvas.w === beside.canvas.w - 80, JSON.stringify({ before: beside.preview.w, after: widened.preview.w, canvas: widened.canvas.w }), ); await drag(beside.split.x - 80, beside.split.y, beside.split.x, beside.split.y, { steps: 8 }); await sleep(200); // 31d-iv. (#86) A hold has to stop the clock, not just the picture: the library's second goes on // under the freeze, so letting go must move the panel's origin rather than resume at the // second it would have reached. Measured with the clock nailed down, so 0 means 0. await evaluate('__gm.particleTime(2000)'); await sleep(150); await evaluate('__gm.particles.hold(0.5)'); await sleep(150); await clickText('Restart'); await sleep(200); await evaluate('__gm.particleTime(2003)'); await sleep(200); const waited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); await clickText('Run'); await sleep(200); const letGo = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'three seconds spent on hold are not owed: letting it run again carries on from second 0', waited.p.time === 0 && letGo.p.holding === false && letGo.p.time < 0.1 && letGo.c.hold === 'Hold', JSON.stringify({ waited: waited.p, letGo: letGo.p, label: letGo.c.hold }), ); // 31d-v. (#86) The panel shows the asset's own rows, not the type's defaults: a lifetime of 2 s at // 5 a second is a different field from 1 s at 20, and the slider reaches three lifetimes. await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)}); a.props.lifetime = 2; a.props.rate = 5; `); await sleep(200); await evaluate('__gm.particles.hold(1.5)'); await sleep(200); const edited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( "the preview follows the asset's own rate and lifetime, and the slider spans three of them", edited.p.count === 8 && edited.c.span === 6, JSON.stringify({ ...edited.p, span: edited.c.span, defaultsWouldGive: { count: 21, span: 3 } }), ); await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)}); delete a.props.lifetime; delete a.props.rate; `); await sleep(200); await evaluate('__gm.particles.hold(null)'); await evaluate('__gm.particleTime(null)'); await sleep(150); // 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); // Not a fixed point: the Output node is already on the canvas, and since the preview took the // right of the tab (#86) there is no corner left that is free by construction. await placeNodeAt('Gravity', await json('__gm.particles.emptySpot()')); 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, '', ); }