game-maker / suite (push) Successful in 9m48s
Speed and Prewarm are not state and not a second loop: both are arithmetic on the clock argument the field already passes to every particle formula. fieldTime = elapsed * timeScale + prewarmOffset, a pure rule next to liveRange, called at the one place ParticleField.sync forms its time, so preview and world inherit it together. Prewarm offsets by one lifetime because that is the smallest offset which fills a continuous emitter; a burst is dead at time >= lifetime, so the same offset would skip it entirely and it is suppressed there. The tab's slider counts wall-clock seconds, so its span divides by the scale. A missing row falls back to 1 / off, so old project files need no migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
546 lines
24 KiB
JavaScript
546 lines
24 KiB
JavaScript
import { LANES } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Particle assets (#73): a graph that says what one particle *is* as a function of its age and of a
|
||
* random number drawn once for it. Because nothing is simulated, the whole field is a function of
|
||
* the clock – which is what lets every check here freeze particle time and read an exact second
|
||
* instead of sleeping and hoping.
|
||
*
|
||
* The source lives in its own lane (`__gm.lookAt`): the origin is crowded by what the earlier steps
|
||
* leave behind, and symbols draw above objects.
|
||
*/
|
||
const LANE = LANES.particles;
|
||
const SOURCE = 'pt-source';
|
||
/** The type's defaults: 20 a second, each living one second, 500 at most. */
|
||
const RATE = 20;
|
||
|
||
export async function particles(t) {
|
||
const { check, sleep, json, evaluate, mutate, click, clickText, locate, typeText, press, shot } = t;
|
||
const { wheel, drag } = t;
|
||
const { select } = t;
|
||
/** Freeze, restart every source's clock, then step the clock to `seconds` and let it redraw. */
|
||
const at = async (seconds) => {
|
||
await evaluate(`__gm.particleTime(${1000 + seconds})`);
|
||
await sleep(150);
|
||
};
|
||
const rewind = async () => {
|
||
await evaluate('__gm.particleTime(1000)');
|
||
await sleep(120);
|
||
await evaluate('__gm.particlesRestart()');
|
||
await sleep(120);
|
||
};
|
||
const drawn = (limit = 8) => json(`__gm.particlesOf(${JSON.stringify(SOURCE)}, 'scene', ${limit})`);
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
await sleep(200);
|
||
};
|
||
/**
|
||
* The `+` menu has one entry per asset type and scrolls past 320 px, so the last of them sits
|
||
* under the mask: `locate` still finds it, but a click there lands on nothing. Scroll it in first.
|
||
*/
|
||
const pickFromMenu = async (label) => {
|
||
const box = await json('__gm.overlayRect()');
|
||
let p = await locate(label, true);
|
||
let scrolled = false;
|
||
if (box && (!p || p.y > box.y + box.h - 4)) {
|
||
await wheel(box.x + box.w / 2, box.y + box.h / 2, 300);
|
||
await sleep(250);
|
||
p = await locate(label, true);
|
||
scrolled = true;
|
||
}
|
||
await click(p.x, p.y);
|
||
await sleep(250);
|
||
return { box, p, scrolled };
|
||
};
|
||
/** Places a node from the palette. The list scrolls, so the name is searched into view first. */
|
||
const placeNodeAt = async (label, point) => {
|
||
await click(point.x, point.y, 'right');
|
||
await sleep(300);
|
||
await typeText(label);
|
||
await sleep(200);
|
||
await clickLast(label);
|
||
await sleep(300);
|
||
};
|
||
const placeNode = async (label, dx, dy) => {
|
||
const canvas = await json('__gm.particles.canvasRect()');
|
||
await placeNodeAt(label, { x: canvas.x + dx, y: canvas.y + dy });
|
||
};
|
||
const nodeIdOf = (type) =>
|
||
json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)})
|
||
.graph.nodes.find((n) => n.type === ${JSON.stringify(type)})?.id ?? ''`);
|
||
/**
|
||
* Adds a node straight to the graph. The palette is proved by the two nodes placed through it
|
||
* above; the tab's canvas is narrow (the preview panel takes the right of it), so dropping five
|
||
* more through it would be a test of where they happen to land rather than of what they do.
|
||
*/
|
||
const addNode = async (id, type, params = {}) => {
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.nodes.push({
|
||
id: ${JSON.stringify(id)}, type: ${JSON.stringify(type)}, x: 0, y: 0,
|
||
params: ${JSON.stringify(params)} });`);
|
||
await sleep(150);
|
||
};
|
||
const link = async (id, from, fromPort, to, toPort) => {
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.edges.push({
|
||
id: ${JSON.stringify(id)}, from: { node: ${JSON.stringify(from)}, port: ${JSON.stringify(fromPort)} },
|
||
to: { node: ${JSON.stringify(to)}, port: ${JSON.stringify(toPort)} } });`);
|
||
await sleep(150);
|
||
};
|
||
let assetId = '';
|
||
|
||
await press('Escape');
|
||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||
await evaluate('__gm.state.select(null)');
|
||
await evaluate("__gm.showTab('scene')");
|
||
await sleep(200);
|
||
|
||
// 31a. "+ → Particles" makes the asset and opens its tab; with no Output there is nothing to run.
|
||
await evaluate('__gm.console.clear()');
|
||
await clickText('+');
|
||
await sleep(200);
|
||
const picked = await pickFromMenu('Particles');
|
||
await sleep(400);
|
||
const created = await json(`{
|
||
tab: __gm.tab(),
|
||
current: __gm.particles.current(),
|
||
info: __gm.particles.info(),
|
||
assets: __gm.state.project.assets.filter((a) => a.type === 'particles').map((a) => a.id),
|
||
}`);
|
||
check(
|
||
'the + menu creates a particle asset and opens the Particles tab on it, with nothing to compile',
|
||
created.tab === 'particles' &&
|
||
created.assets.length === 1 &&
|
||
created.current === created.assets[0] &&
|
||
created.info === 'No Output node',
|
||
JSON.stringify({ ...created, picked }),
|
||
);
|
||
if (created.assets.length !== 1) return;
|
||
assetId = created.assets[0];
|
||
// Renamed away from "Particles": the tab button says that too, and a lookup by text would find it.
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).name = 'Sparks'`);
|
||
await sleep(200);
|
||
|
||
// 31b. The palette of this tab holds particle nodes and nothing else.
|
||
const canvas = await json('__gm.particles.canvasRect()');
|
||
await click(canvas.x + 80, canvas.y + 60, 'right');
|
||
await sleep(300);
|
||
const palette = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the palette offers particle nodes, not behavior ones',
|
||
palette.includes('Output') && palette.includes('Gravity') && !palette.includes('Branch'),
|
||
JSON.stringify(palette.slice(0, 12)),
|
||
);
|
||
await press('Escape');
|
||
await sleep(200);
|
||
|
||
// 31c. One Output node is a whole graph: a particle without rules is one at the source, full size.
|
||
await placeNode('Output', 80, 60);
|
||
const withOutput = await json('{ info: __gm.particles.info(), preview: __gm.particles.preview() }');
|
||
check(
|
||
'an Output node alone compiles, and the strip below the graph emits',
|
||
withOutput.info === '0 parameters' && withOutput.preview.count > 0,
|
||
JSON.stringify(withOutput),
|
||
);
|
||
|
||
// 31d. The slider jumps to a second exactly: 20 a second for one second means 0..10 are alive
|
||
// at 0.5 s – eleven of them, whatever the frame rate was on the way there.
|
||
await evaluate('__gm.particles.hold(0.5)');
|
||
await sleep(200);
|
||
const held = await json('__gm.particles.preview()');
|
||
check(
|
||
'holding the preview at 0.5 s shows exactly the particles due by then, not a frame-rate count',
|
||
held.holding === true && held.time === 0.5 && held.count === RATE / 2 + 1,
|
||
JSON.stringify(held),
|
||
);
|
||
await shot('31-particles-tab');
|
||
await evaluate('__gm.particles.hold(null)');
|
||
|
||
// 31d-ii. (#86) The controls read the panel rather than their own history: the slider holds it, so
|
||
// the button has to offer the way back, and Restart on a held panel stands at second 0.
|
||
const track = await json('__gm.particles.sliderAt()');
|
||
await click(track.x, track.y);
|
||
await sleep(200);
|
||
const dragged = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
|
||
check(
|
||
'dragging the time slider holds the panel, and the button then offers to run it again',
|
||
dragged.p.holding === true &&
|
||
dragged.p.time > 0 &&
|
||
dragged.c.hold === 'Run' &&
|
||
dragged.c.time === `${dragged.p.time.toFixed(2)} s` &&
|
||
Math.abs(dragged.c.slider - dragged.p.time) < 0.01,
|
||
JSON.stringify(dragged),
|
||
);
|
||
await clickText('Restart');
|
||
await sleep(200);
|
||
const restarted = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
|
||
check(
|
||
'Restart puts a held panel back to second 0, and the readout and slider with it',
|
||
restarted.p.holding === true &&
|
||
restarted.p.time === 0 &&
|
||
restarted.c.time === '0.00 s' &&
|
||
restarted.c.slider === 0 &&
|
||
restarted.c.hold === 'Run',
|
||
JSON.stringify(restarted),
|
||
);
|
||
await clickText('Run');
|
||
await sleep(250);
|
||
const running = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
|
||
check(
|
||
'the same button lets it run again and says "Hold" once it does',
|
||
running.p.holding === false && running.c.hold === 'Hold',
|
||
JSON.stringify(running),
|
||
);
|
||
|
||
// 31d-iii. (#86) The panel stands beside the graph, not under it, and the divider between them is
|
||
// the one that moves – a graph is read across, so the width is what it cannot spare.
|
||
const beside = await json(`{
|
||
canvas: __gm.particles.canvasRect(),
|
||
preview: __gm.particles.previewRect(),
|
||
split: __gm.particles.splitterAt(),
|
||
}`);
|
||
check(
|
||
'the preview stands right of the graph, with the divider between the two',
|
||
beside.preview.x >= beside.canvas.x + beside.canvas.w - 1 &&
|
||
beside.split.x > beside.canvas.x + beside.canvas.w - 1 &&
|
||
beside.split.x < beside.preview.x + 1 &&
|
||
beside.preview.y + beside.preview.h > beside.canvas.y + beside.canvas.h / 2,
|
||
JSON.stringify(beside),
|
||
);
|
||
await drag(beside.split.x, beside.split.y, beside.split.x - 80, beside.split.y, { steps: 8 });
|
||
await sleep(200);
|
||
const widened = await json(`{
|
||
canvas: __gm.particles.canvasRect(),
|
||
preview: __gm.particles.previewRect(),
|
||
}`);
|
||
check(
|
||
'dragging that divider left widens the preview and narrows the graph by the same 80 px',
|
||
widened.preview.w === beside.preview.w + 80 && widened.canvas.w === beside.canvas.w - 80,
|
||
JSON.stringify({ before: beside.preview.w, after: widened.preview.w, canvas: widened.canvas.w }),
|
||
);
|
||
await drag(beside.split.x - 80, beside.split.y, beside.split.x, beside.split.y, { steps: 8 });
|
||
await sleep(200);
|
||
|
||
// 31d-iv. (#86) A hold has to stop the clock, not just the picture: the library's second goes on
|
||
// under the freeze, so letting go must move the panel's origin rather than resume at the
|
||
// second it would have reached. Measured with the clock nailed down, so 0 means 0.
|
||
await evaluate('__gm.particleTime(2000)');
|
||
await sleep(150);
|
||
await evaluate('__gm.particles.hold(0.5)');
|
||
await sleep(150);
|
||
await clickText('Restart');
|
||
await sleep(200);
|
||
await evaluate('__gm.particleTime(2003)');
|
||
await sleep(200);
|
||
const waited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
|
||
await clickText('Run');
|
||
await sleep(200);
|
||
const letGo = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
|
||
check(
|
||
'three seconds spent on hold are not owed: letting it run again carries on from second 0',
|
||
waited.p.time === 0 && letGo.p.holding === false && letGo.p.time < 0.1 && letGo.c.hold === 'Hold',
|
||
JSON.stringify({ waited: waited.p, letGo: letGo.p, label: letGo.c.hold }),
|
||
);
|
||
|
||
// 31d-v. (#86) The panel shows the asset's own rows, not the type's defaults: a lifetime of 2 s at
|
||
// 5 a second is a different field from 1 s at 20, and the slider reaches three lifetimes.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)});
|
||
a.props.lifetime = 2;
|
||
a.props.rate = 5;
|
||
`);
|
||
await sleep(200);
|
||
await evaluate('__gm.particles.hold(1.5)');
|
||
await sleep(200);
|
||
const edited = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }');
|
||
check(
|
||
"the preview follows the asset's own rate and lifetime, and the slider spans three of them",
|
||
edited.p.count === 8 && edited.c.span === 6,
|
||
JSON.stringify({ ...edited.p, span: edited.c.span, defaultsWouldGive: { count: 21, span: 3 } }),
|
||
);
|
||
|
||
// 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);
|
||
|
||
// 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');
|
||
|
||
// 31m. The graph tab draws no Pixi complaint of its own.
|
||
const logged = await json('__gm.console.lines()');
|
||
check(
|
||
'nothing about the particle tab draws a complaint from Pixi',
|
||
!logged.some((line) => /Deprecation/i.test(JSON.stringify(line))),
|
||
JSON.stringify(logged.slice(0, 3)),
|
||
);
|
||
|
||
// 31n. The step takes back everything it made, so the steps after it see the fixture again.
|
||
await evaluate('__gm.particleTime(null)');
|
||
await evaluate('__gm.state.select(null)');
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(assetId)});
|
||
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
|
||
(o) => o.id !== ${JSON.stringify(SOURCE)});
|
||
__gm.lookAt(0, 0);`);
|
||
await evaluate("__gm.showTab('scene')");
|
||
await sleep(300);
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
(await json("__gm.state.project.assets.filter((a) => a.type === 'particles').length")) === 0 &&
|
||
(await drawn(1)) === null,
|
||
'',
|
||
);
|
||
}
|