import { colourDistance } from '../lib/harness.mjs'; import { LANES, pickAssetType } 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, showTab } = t; const { 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 selectSource = async () => { await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE }); }; const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); }; /** 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 typeText(label); await clickLast(label); }; 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)} });`); }; 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)} } });`); }; let assetId = ''; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); await showTab('scene'); // 31a. "+ → Particles" makes the asset and opens its tab; with no Output there is nothing to run. await evaluate('__gm.console.clear()'); await clickText('+'); const picked = await pickAssetType(t, 'Particles'); 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'`); // 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'); 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'); // 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); 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'); 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'); 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 }); 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 }); // 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 evaluate('__gm.particleTime(2003)'); await sleep(200); const waited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); await clickText('Run'); 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 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 } }), ); // 31d-vi. (#87) The slider is in the seconds a person waits, not in the source's own: at twice the // speed the same three lifetimes are over in half the time, so the slider reaches half as // far – and the second it is held at is twice as far into the field. await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)}); a.props.timeScale = 2; `); const scaled = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); check( 'a time scale of 2 halves what the slider spans and doubles what a held second shows', scaled.c.span === 3 && scaled.p.time === 1.5 && scaled.p.count === 10, JSON.stringify({ ...scaled.p, span: scaled.c.span, atScaleOne: { count: 8, span: 6 } }), ); await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)}); delete a.props.lifetime; delete a.props.rate; delete a.props.timeScale; `); await evaluate('__gm.particles.hold(null)'); await evaluate('__gm.particleTime(null)'); await sleep(150); // 31d-vii. (#90) A particle's age is measured on the global clock, so a scale of 0 stops the panel // where it stands – without any hold of its own – and 1 lets it run on. await evaluate('__gm.setTimeScale(0)'); await sleep(300); const scaleZero = await json('{ p: __gm.particles.preview(), g: __gm.globalTime() }'); await sleep(500); const stillZero = await json('__gm.particles.preview()'); await evaluate('__gm.setTimeScale(1)'); await sleep(400); const scaleOne = await json('__gm.particles.preview()'); check( 'a global time scale of 0 holds the panel still without holding it, and 1 lets it run on', scaleZero.g.effective === 0 && scaleZero.p.holding === false && stillZero.time === scaleZero.p.time && scaleOne.time > stillZero.time + 0.2, JSON.stringify({ scaleZero, stillZero, scaleOne }), ); // 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 showTab('scene'); 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 showTab('particles'); // 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 showTab('scene'); 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 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 select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE }); 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 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 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 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 }), ); // 31k-ii. (#87) Prewarm means nothing to a burst: one lifetime in, the burst it would start past is // already over, so the offset would hide the effect instead of warming it up. await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); o.overrides.prewarm = true;`); await rewind(); await at(0.5); const warmBurst = await drawn(7); check( 'a prewarmed burst is still a burst: the offset that fills a stream would empty this one', warmBurst?.count === 7, JSON.stringify({ count: warmBurst?.count, ifItApplied: 0 }), ); // 31k-iii. (#87) A continuous source starts one lifetime in instead, which is exactly the second // its field is full at: its first frame already carries a whole lifetime of particles. await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { mode: 'continuous', drift: 0 }); delete o.overrides.count;`); await rewind(); await at(0); const warmStart = await drawn(1); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); delete o.overrides.prewarm;`); await rewind(); await at(0); const coldStart = await drawn(1); check( 'a prewarmed stream is full at its first frame, where a cold one has just the first particle', warmStart?.count === RATE && coldStart?.count === 1, JSON.stringify({ warm: warmStart?.count, cold: coldStart?.count }), ); // 31k-iv. (#87) Time scale is a factor on that same clock argument and nothing else: at twice the // speed, half a second in is the very picture the same source shows a whole second in. await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); o.overrides.timeScale = 2;`); await rewind(); await at(0.5); const fast = await drawn(3); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); delete o.overrides.timeScale;`); await rewind(); await at(1); const plain = await drawn(3); check( 'twice the time scale is the same field half the time in, particle for particle', fast?.count === RATE && JSON.stringify(fast?.drawn) === JSON.stringify(plain?.drawn), JSON.stringify({ fast: fast?.count, plain: plain?.count, oldest: fast?.drawn[0] }), ); // 31k-v. (#87) Both rows stand with the ones about time, under the lifetime they are measured in. const timeRows = await json(`{ lifetime: __gm.inspectorField('Lifetime'), scale: __gm.inspectorField('Time scale'), prewarm: __gm.inspectorField('Prewarm'), }`); check( 'the source shows a Time scale and a Prewarm row, both below Lifetime', Boolean(timeRows.scale && timeRows.prewarm && timeRows.lifetime) && timeRows.scale.y > timeRows.lifetime.y && timeRows.prewarm.y > timeRows.scale.y, JSON.stringify(timeRows), ); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { mode: 'burst', count: 7 });`); // 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 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'); // 31l-ii. (#90) A field keeps its origin as an absolute second of the global clock, so anything // that moves that clock backwards leaves that origin in the future: releasing the freeze // above, and the reset at either end of a run. Ageing backwards from there draws nothing // at all — for as long as the editor has been up — so the field has to start over instead. await evaluate('__gm.particleTime(null)'); await sleep(400); const thawed = await drawn(1); await t.play(); await t.stop(); const afterRun = await drawn(1); check( 'the source draws on after the clock is pulled out from under it: a freeze released, a run ended', (thawed?.count ?? 0) > 0 && (afterRun?.count ?? 0) > 0, JSON.stringify({ thawed: thawed?.count ?? null, afterRun: afterRun?.count ?? null }), ); // Back to the frozen second the icon checks below were written against. await rewind(); await at(0.1); // 31m. (#89) A source draws only its particles, so an empty one would be invisible: it wears an // icon of its own, in the overlay above the objects, with its name beside it. const icon = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); check( 'the source wears an icon in the overlay, named beside it', icon?.layer === 'overlay' && icon.visible === true && icon.label === 'Fountain', JSON.stringify(icon), ); // 31n. (#89) The ring is hollow on purpose – the mark may not hide the effect it marks. Scanned // outward from the source's own point, because a 2-unit stroke at an exact radius would be a // test of rounding rather than of drawing. The ring is white like every symbol's icon, with // a radius of 3.94 world units: its thin stroke starts at about 3.1, so the scan takes the // first white from 2.5 units out, and the dark outline leaves the middle two units free. const ICON_COLOUR = '#ffffff'; const unit = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24; const row = []; for (let dx = 0; dx <= Math.ceil(19 * unit); dx++) row.push(await json(`__gm.pixelAt(${Math.round(spotOn.x + dx)}, ${Math.round(spotOn.y)})`)); const ringAt = row.findIndex((c, dx) => dx >= 2.5 * unit && colourDistance(c, ICON_COLOUR) < 12); check( 'the icon rings the source and leaves its middle to the particles', ringAt > 0 && colourDistance(row[0], ICON_COLOUR) > 60 && row[0] !== '#22252c', JSON.stringify({ centre: row[0], unit, ringAt, ring: row[ringAt] }), ); // 31o. (#89) Parented, it hangs off its owner by a dashed line like a behavior does – otherwise a // source that moves with something else is a mark in mid-air. await mutate(` const scene = __gm.state.activeScene; const anchor = structuredClone(scene.objects[0]); Object.assign(anchor, { id: 'pt-anchor', name: 'Anchor', parentId: '', position: { x: 0, y: ${LANE}, z: 0 }, overrides: {}, instanceEdits: {} }); scene.objects.push(anchor); const source = scene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); source.parentId = 'pt-anchor'; source.position = { x: 200, y: 0, z: 0 };`); const linked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); await mutate(` const scene = __gm.state.activeScene; const source = scene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); source.parentId = ''; source.position = { x: 0, y: ${LANE}, z: 0 }; scene.objects = scene.objects.filter((o) => o.id !== 'pt-anchor');`); const unlinked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); check( 'a parented source hangs off its owner by a line, a free one by none', linked?.link === true && unlinked?.link === false, JSON.stringify({ linked, unlinked }), ); // 31r. (#88) A source wears a shader like a sprite does: the picker is a row of its own, and the // shader's Property nodes become rows under it. The graph is planted rather than clicked – // step 28 owns the shader tab; what is new here is a *source* wearing one. const SHADER = 'pt-shader'; await mutate(` __gm.state.project.assets.push({ id: ${JSON.stringify(SHADER)}, type: 'shader', name: 'Level', props: {}, graph: { nodes: [ { id: 'lv-prop', type: 'ShaderProperty', x: 0, y: 0, params: { name: 'level', default: 1, min: 0, max: 1 } }, { id: 'lv-out', type: 'ShaderOutput', x: 200, y: 0, params: {} }, ], edges: [ { from: { node: 'lv-prop', port: 'value' }, to: { node: 'lv-out', port: 'color' } }, ] }, }); const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); Object.assign(o.overrides, { shader: ${JSON.stringify(SHADER)}, level: 1 });`); await selectSource(); // The tab bar has a button reading "Shader" too, and it is drawn first: the row wanted here is the // *last* text of that name, and it has to stand in the inspector's own column to be one. const shaderRow = await json('__gm.inspectorField("Shader", true)'); const levelRow = await json('__gm.inspectorField("level")'); check( 'a source has the shader picker, and the shader’s own rows below it', Boolean(shaderRow) && Boolean(levelRow) && shaderRow.left === levelRow.left && levelRow.y > shaderRow.y, JSON.stringify({ shaderRow, levelRow }), ); // 31s. (#88) And the particles really wear it: the fragment replaces the container's own, so the // green the source asks for gives way to what the graph computes – white at level 1. await rewind(); await at(0.1); const shaded = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`); check( 'the shader reaches every particle, over the colour the source itself sets', colourDistance(shaded, '#ffffff') < 12 && colourDistance(shaded, '#3cc85a') > 60, JSON.stringify({ shaded }), ); await shot('31-particles-shaded'); // 31t. (#88) The row is the uniform: a quarter turns the same particles a quarter grey. Read at // the same frozen second, so nothing but the number has changed. await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); o.overrides.level = 0.25;`); await at(0.1); const dimmed = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`); check( 'the shader’s row feeds its uniform, particle by particle', colourDistance(dimmed, '#404040') < 16, JSON.stringify({ dimmed }), ); // 31u. (#88) A source draws rows from two graphs, so one name can arrive twice. The source's own // row wins and the uniform keeps its default – two rows called Rate would be one row with two // meanings, which is #63 in another coat. await mutate(` const shader = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SHADER)}); shader.graph.nodes.find((n) => n.id === 'lv-prop').params.name = 'rate'; shader.graph.edges = [];`); await select(null); await selectSource(); const own = await json('__gm.locateAll("Rate")'); const uniform = await json('__gm.locateAll("rate")'); check( 'a uniform named like a row of the source adds no second row of that name', own.length === 1 && uniform.length === 0, JSON.stringify({ own: own.length, uniform: uniform.length }), ); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); delete o.overrides.shader; delete o.overrides.level; __gm.state.project.assets = __gm.state.project.assets.filter( (a) => a.id !== ${JSON.stringify(SHADER)});`); // 31p. 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)), ); // 31q. 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 showTab('scene'); 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, '', ); }