game-maker / suite (push) Successful in 10m9s
A particle source can wear a shader asset the way a sprite does, and it is the
cheapest place in the editor to wear one: a shaded sprite gives up its batch and
costs a draw call of its own, while `ParticleContainerPipe` breaks the batch for
every field regardless. Five hundred shaded particles are the one call five
hundred plain ones were.
The fragment the compiler emits needed no change, only a second vertex stage:
Pixi's particle vertex is GLSL ES 1.00 and ours is ES 3.00, so it is translated
rather than invented. What it sees is a particle's picture, not its life — the
buffer carries five fixed attributes and takes no sixth, so age, index and seed
stay on the graph, which is what the graph is for.
Two things the issue did not ask about:
`Shader.resources` is an accessor over the names its constructor declared, so a
slot that is not declared becomes a loose JS property and is never bound. The
adaptor writes `uTranslationMatrix` into whichever shader the container names —
without an empty block for it the vertex gets no matrix and the particles land
elsewhere.
`schemaOf` in serialize.ts hung on `schema !== schemaFor('sprite')`, an identity
check a source never satisfies, so its shader values would have been dropped on
save.
A source is the one asset drawing rows from two graphs, so a name can arrive
twice. Its own row wins and the uniform keeps its default; nothing is rejected
on the shader's side, because the collision exists only at this one wearer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
722 lines
32 KiB
JavaScript
722 lines
32 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
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 selectSource = async () => {
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE });
|
||
await sleep(350);
|
||
};
|
||
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 } }),
|
||
);
|
||
|
||
// 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;
|
||
`);
|
||
await sleep(300);
|
||
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 sleep(200);
|
||
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 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 }),
|
||
);
|
||
|
||
// 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 sleep(200);
|
||
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 sleep(200);
|
||
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 sleep(200);
|
||
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 sleep(200);
|
||
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 sleep(200);
|
||
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 });`);
|
||
await sleep(200);
|
||
|
||
// 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');
|
||
|
||
// 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 sleep(300);
|
||
await t.stop();
|
||
await sleep(400);
|
||
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.
|
||
const ICON_COLOUR = '#c26a1f';
|
||
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 > 4 * 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 };`);
|
||
await sleep(300);
|
||
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');`);
|
||
await sleep(300);
|
||
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 sleep(300);
|
||
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 sleep(300);
|
||
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 sleep(300);
|
||
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)});`);
|
||
await sleep(300);
|
||
|
||
// 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 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,
|
||
'',
|
||
);
|
||
}
|