game-maker / suite (push) Successful in 10m31s
`Variables` pushed `Font` under the menu's mask edge: `locate` still finds the entry, a click there lands on nothing, `__gm.fontPicker()` is null and reading `.captions` throws. Only step 31 carried the scroll workaround, so step 30 fell. The helper moves to `lib/scaffold.mjs` as `pickAssetType()` and both steps use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
704 lines
31 KiB
JavaScript
704 lines
31 KiB
JavaScript
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 } = 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 });
|
||
await sleep(350);
|
||
};
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
await sleep(200);
|
||
};
|
||
/** 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 pickAssetType(t, '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,
|
||
'',
|
||
);
|
||
}
|