Files
feuerwerk/tools/e2e/31-particles.mjs
T
adminandClaude Opus 5.5 cb32b4bc23
game-maker / gate (push) Successful in 2m54s
game-maker / suite (push) Successful in 9m5s
Draw scene symbols as white panel icons that make room for each other
The scene view drew its symbols from its own SVGs, each in a type
colour and some on a plate. They looked heavier than the panel icons
and were hard to read on busy ground. Symbols of different objects
also sat on top of each other whenever their positions met.

- Every symbol is now the panel icon of its kind, one file for both
  places. The scene-only SVGs in src/viewport/icons are gone.
- Symbols are white in a thin dark outline with no plate behind them.
  haloSvg widens each shape of the file by its own stroke and draws
  that silhouette dark under the icon, so no second file is needed.
- A butt stroke end would leave its end without outline. The halo caps
  such strokes square, and the rigidbody arrow shaft is now a filled
  rect so its top edge is outlined exactly.
- Symbols are 18 world units across, stay upright and are clickable.
  A click selects the object, a drag moves it.
- symbolLayout places every symbol on its object's position. Symbols
  that overlap stand on a ring round the middle of their positions,
  evenly spaced one icon apart. A ring that reaches into another
  symbol takes it in until nothing overlaps. Ownership plays no part.
- Names sit beside their symbol, on the outside of a ring. Selection
  frames and parent links follow the symbol.
- A collider icon no longer shows trigger or invalid state. The
  outline colour of the shape still does.
- A lone collider sits on its sprite's middle again. A press there on
  an unselected sprite drags the collider, so e2e 02 and 16 now grab
  the sprite beside its middle. Hook __gm.container.objectRect helps
  16 find that spot.

Tests:
- check.sh: 2245 tests, CHECK EXIT 0 (+8 for layout and halo)
- e2e 1-16,31,39-43: 670 PASS, 2 FAIL. Both were sampling too
  strictly for the thinner, smaller icons. 5c looked for the exact
  outline colour, which the thin outline only reaches blended at this
  zoom. 31n started the ring scan one pixel past the smaller ring.
  Both are fixed, and e2e 5,31 then passed with 48 checks.
- 5c checks white with an outline and none of the old colours, 41b
  checks the ring positions and that a click on a symbol selects its
  object, 31n checks the now white particle ring.
- Counter-checks: without the halo only 5c fails, also with the looser
  outline match. Without the layout the ring and click checks in 41b
  fail. Originals restored by cmp.
- The e2e check total stays 1351.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:07:47 +02:00

659 lines
31 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 { 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,
'',
);
}