game-maker / suite (push) Successful in 9m49s
Behavior and Container draw an icon because isSymbolType() is true for them, but that predicate does not mean "draws an icon" — it means "is editor decoration". Four places read it that way: collectWorldItems, GameViewport. collectFrame and syncDrawOrder all drop symbols out of the running world and the game picture, and placementOf() gives them an upright unscaled box. Listing 'particles' there would have handed the source an icon and taken away its particles, in the scene view as well as in the game. So the two questions are two functions now. hasSceneIcon() says a type draws an icon; isSymbolType() keeps meaning decoration. The icon itself is a SceneViewport decoration, which is editor-only for free because GameViewport never syncs decorations. A ring (r 10, #c26a1f) with four sparks, upright and unscaled like the other symbols, its middle left open — unlike Behavior and Container a source draws something of its own, and the field is densest exactly there. The name sits beside it and a dashed line runs to the parent, same colour. Found while writing the tests: the source's grab box scaled with its transform while the icon did not, so the two came apart. placementOf() returns only the translation for 'particles', making the box the upright 24 x 24 that covers its icon. The source's own transform still feeds every particle untouched. 824 checks (821 + 3). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
598 lines
26 KiB
JavaScript
598 lines
26 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 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. (#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 }),
|
||
);
|
||
|
||
// 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,
|
||
'',
|
||
);
|
||
}
|