A held preview is rebased, not paused, and it wears the asset's own rows (#86)
game-maker / suite (push) Successful in 9m42s

Three reports plus one found on the way.

Hold only froze the *picture*: the preview computed a fake second while drawing,
the library clock kept counting underneath, so letting go jumped to "how much
would have passed by now". That is the flip side of the formula model — a thing
that is a function of the clock is not stopped, its origin is moved.
ParticleField.rebase() does that; hold(null) calls it with the second stood at.
The Hold label now mirrors the preview's state instead of its own click history,
so dragging the slider (which holds too) reads `Run`, and Restart puts a held
preview back to 0.00 s rather than leaving it on the second it stopped at.

previewProps only read defaultsFor('particles'), so the panel showed the type's
defaults while the inspector showed the asset's rows. AssetBase.props is a full
bag on a graph asset as well — asset.props now lie over the defaults, the
asset's texture is resolved instead of always drawing a white square, and the
slider's span follows its lifetime. The span is re-read in tick(), because the
rows are edited in the inspector and the tab hears nothing about it.

The preview moves from under the graph to beside it, past a draggable splitter:
a graph is read across, so width is what it can spare and height is not.
AssetTabEditor grows a body() hook for that — the base class still stacks header,
strip and canvas, a subclass may put something next to the canvas.

Found by the full run, not reported: the graph canvas's empty-state hint ran
38 px past its frame now that the preview takes 280 px away. It is cut with
truncate() like any other label.

816 checks (809 + 7).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-14 07:03:51 +02:00
1 parent 4feb4c2cf6
commit fbb2d157f0
8 files changed
+284 -66

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 809 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 816 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+6 -1
View File
@@ -69,7 +69,7 @@ export abstract class AssetTabEditor<
this.add(header);
if (strip) this.add(strip);
this.add(canvas, 1);
this.add(this.body(canvas), 1);
this.state.on('selection', () => this.followSelection());
this.state.on('project', () => this.refresh());
@@ -98,6 +98,11 @@ export abstract class AssetTabEditor<
/** The right-hand readout; `null` is the empty state. */
protected abstract infoText(asset: A | null): string;
/** What fills the tab below the header. Override to put something beside the canvas. */
protected body(canvas: C): Widget {
return canvas;
}
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
protected syncExtras(_asset: A | null): void {}
+10 -1
View File
@@ -22,6 +22,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
};
const rectOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
};
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
@@ -292,12 +296,17 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
canvasRect: () => editor.particlesEditor.canvas.canvasRect(),
emptySpot: () => editor.particlesEditor.canvas.emptySpotGlobal(),
center: () => centreOf(editor.particlesEditor.canvas),
/** The strip below the graph: how many particles it draws and which second it stands at. */
/** The panel beside the graph: how many particles it draws and which second it stands at. */
preview: () => ({
count: editor.particlesEditor.preview.count,
time: Math.round(editor.particlesEditor.preview.time * 100) / 100,
holding: editor.particlesEditor.preview.holding,
}),
previewRect: () => rectOf(editor.particlesEditor.preview),
/** Hold button, time readout and slider as they are shown, not as the preview holds them. */
controls: () => editor.particlesEditor.controls,
splitterAt: () => centreOf(editor.particlesEditor.splitter),
sliderAt: () => centreOf(editor.particlesEditor.slider),
/** Jumps the preview to a second – exact, because a particle is a function of its age. */
hold: (at: number | null) => editor.particlesEditor.preview.hold(at),
},
+8 -2
View File
@@ -45,6 +45,7 @@ import {
type Size,
Theme,
textStyle,
truncate,
Widget,
zoomFactor,
} from '../../ui';
@@ -658,6 +659,11 @@ export class GraphCanvas extends Widget {
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
}
/** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */
private setHint(text: string): void {
this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2);
}
override layout(): void {
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
if (this.needsFit) this.fitView();
@@ -671,11 +677,11 @@ export class GraphCanvas extends Widget {
const asset = this.asset();
this.canvas.visible = asset !== null;
if (!asset) {
this.hint.text = `No ${this.noun} selected – pick one above, or add a ${this.noun} asset.`;
this.setHint(`No ${this.noun} selected – pick one above, or add one.`);
return;
}
const playing = this.state.mode === 'play';
this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '';
this.setHint(asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '');
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
+28 -11
View File
@@ -2,18 +2,22 @@ import { Container, Graphics, Texture } from 'pixi.js';
import type { ParticlesAsset } from '../../core/model/Asset';
import { translation } from '../../core/model/matrix';
import type { PropertyBag } from '../../core/model/Properties';
import { resolveTexture } from '../../core/model/texture';
import { defaultsFor } from '../../core/properties/PropertySchema';
import { SHEET_EDGE } from '../../core/shader/nodes';
import { type Size, Theme, Widget } from '../../ui';
import { ParticleField } from '../../viewport/particles';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
const MIN_HEIGHT = 40;
/**
* The asset's own particles, emitted from the middle of the strip by a source with the type's
* default rows. It is the one place a formula graph can be judged: the numbers say nothing. The
* picture is deliberately a plain square – which texture a source throws belongs to the placed
* object, not to the asset the tab is showing.
* The asset's own particles, emitted from the middle of the panel by a source that wears the asset's
* own rows. It is the one place a formula graph can be judged: the numbers say nothing. What it is
* not is an instance – a source placed in a scene may override every one of those rows, and the tab
* has no way to know which of them is meant.
*/
export class ParticlePreview extends Widget {
private readonly bg = new Graphics();
@@ -24,7 +28,7 @@ export class ParticlePreview extends Widget {
/** Null: the preview runs on the library clock. A number: it stands at that second. */
private held: number | null = null;
constructor() {
constructor(private readonly state: EditorState) {
super();
this.minHeight = MIN_HEIGHT;
this.content.addChild(this.field.container);
@@ -51,12 +55,17 @@ export class ParticlePreview extends Widget {
}
hold(at: number | null): void {
// Letting go has to move the origin, not just stop faking one: the library clock keeps counting
// under a freeze, so the panel would otherwise resume at the second it would have reached.
if (at === null && this.held !== null) this.field.rebase(particleLibrary.now(), this.held);
this.held = at;
this.invalidate();
}
/** Back to second 0 – a held panel stands there too, rather than keeping the second it was on. */
restart(): void {
this.field.restart(particleLibrary.now());
if (this.held !== null) this.held = 0;
this.invalidate();
}
@@ -76,11 +85,13 @@ export class ParticlePreview extends Widget {
if (!this.asset) return;
const build = particleLibrary.buildFor(this.asset);
const rows = build.compiled?.params ?? [];
const props = previewProps(rows);
const props = previewProps(this.asset, rows);
const params = rows.map((row) => Number(props[row.key] ?? row.default));
// The source sits in the middle of the strip; a fountain then has room above and below it.
const ref = resolveTexture(this.state.project, props);
const texture = ref ? textureLibrary.get(ref, SHEET_EDGE) : null;
// The source sits in the middle of the panel; a fountain then has room above and below it.
const m = translation(this.w / 2, this.h / 2);
this.field.sync(build.compiled, props, params, m, Texture.WHITE, this.clock());
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, this.clock());
}
/** Holding at second t means handing the field a clock that reads exactly t since it started. */
@@ -96,9 +107,15 @@ export class ParticlePreview extends Widget {
}
}
/** A source as the asset alone describes it: the type's defaults, the graph's parameters at theirs. */
function previewProps(params: readonly { key: string; default: number }[]): PropertyBag {
/**
* A source as the asset alone describes it: the rows the asset carries, over the type's defaults for
* the ones it does not, with the graph's own parameters at their declared defaults underneath.
*/
function previewProps(
asset: ParticlesAsset,
params: readonly { key: string; default: number }[],
): PropertyBag {
const props = defaultsFor('particles');
for (const row of params) props[row.key] = row.default;
return props;
return { ...props, ...asset.props };
}
+99 -43
View File
@@ -2,7 +2,7 @@ import type { Asset, ParticlesAsset } from '../../core/model/Asset';
import { LIFETIME_KEY } from '../../core/properties/particles';
import { defaultsFor } from '../../core/properties/PropertySchema';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { Button, clamp, HStack, Label, Slider, Spacer, Splitter, Theme } from '../../ui';
import { Button, clamp, HStack, Label, Slider, Spacer, Splitter, Theme, VStack, type Widget } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle';
@@ -14,72 +14,120 @@ const INFO_WIDTH = 220;
/** How far the time slider reaches: three lifetimes, so a particle's whole arc fits with room after. */
const SPAN_LIFETIMES = 3;
const MIN_SPAN = 2;
/** The strip can be dragged shut to its bar and open to half a tall window. */
const MIN_PREVIEW = 60;
const MAX_PREVIEW = 500;
/** The panel can be dragged in to the width of its own controls, and open to most of a wide window. */
const MIN_PREVIEW = 200;
const MAX_PREVIEW = 640;
/** The button reads "Hold" or "Run"; a floor keeps the row from moving when it changes. */
const HOLD_WIDTH = 52;
/**
* The nodes of one particle asset, above a strip that actually emits them. A formula graph cannot be
* read as a picture, so the tab shows one – and because every particle is a function of its age, the
* slider can jump to any second exactly, without simulating the way there.
* The nodes of one particle asset, beside a panel that actually emits them. A formula graph cannot
* be read as a picture, so the tab shows one – and because every particle is a function of its age,
* the slider can jump to any second exactly, without simulating the way there.
*/
export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas> {
readonly preview = new ParticlePreview();
readonly preview: ParticlePreview;
private readonly snap = new SnapToggle(() => this.canvas);
private readonly slider = new Slider(0, 0, MIN_SPAN, 140);
private readonly holdButton = new Button('Hold', { onClick: () => this.toggleHold() });
/** Public for the check scripts: the one control they have to hit rather than read. */
readonly slider = new Slider(0, 0, MIN_SPAN, 120);
private readonly holdButton = new Button('Hold', {
minWidth: HOLD_WIDTH,
onClick: () => this.toggleHold(),
});
private readonly timeLabel = new Label('0.00 s', { style: 'small' });
readonly splitter = new Splitter('y');
readonly splitter = new Splitter('x');
private readonly side = new VStack();
private row = new HStack();
private span = MIN_SPAN;
private previewHeight = 180;
private previewWidth = 280;
constructor(state: EditorState) {
super(state, { noun: 'Particles', infoWidth: INFO_WIDTH });
this.preview = new ParticlePreview(state);
this.slider.onChange = (value) => {
this.preview.hold(value);
this.syncTime();
this.syncControls();
};
this.mount(new GraphCanvas(state, 'particles'), this.snap.widgets);
// The clock belongs to the strip, not to the asset, so its controls sit above the strip: the
// header is already carrying the picker, Snap, Fit and the readout.
const bar = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
bar.minHeight = Theme.controlHeight + Theme.padding * 2;
bar.add(new Label('Preview', { style: 'muted' }));
bar.add(this.holdButton);
bar.add(this.slider);
bar.add(this.timeLabel);
bar.add(new Button('Restart', { onClick: () => this.preview.restart() }));
bar.add(new Spacer(), 1);
// Draggable like the console: how much of the tab is graph and how much is picture is a matter
// of what is being worked on, and on a short window a fixed strip eats the canvas.
this.splitter.onDrag = (delta) => {
this.previewHeight = clamp(this.previewHeight - delta, MIN_PREVIEW, MAX_PREVIEW);
this.preview.minHeight = this.previewHeight;
this.relayout();
};
this.add(this.splitter);
this.add(bar);
this.preview.minHeight = this.previewHeight;
this.add(this.preview);
state.on('mode', () => this.refresh());
}
/** Called every frame while the tab is showing: the strip is a moving picture. */
/** The graph, and right of it the panel that runs the asset – the two are dragged apart. */
protected override body(canvas: GraphCanvas): Widget {
// The clock belongs to the panel, not to the asset, so its controls sit on top of the panel:
// the tab header is already carrying the picker, Snap, Fit and the readout.
const bar = new VStack({ gap: Theme.gap, padding: Theme.padding });
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.minHeight = Theme.controlHeight;
buttons.add(new Label('Preview', { style: 'muted' }));
buttons.add(new Spacer(), 1);
buttons.add(this.holdButton);
buttons.add(new Button('Restart', { onClick: () => this.restart() }));
const time = new HStack({ gap: Theme.gap, align: 'center' });
time.minHeight = Theme.controlHeight;
time.add(this.slider, 1);
this.timeLabel.minWidth = 48;
time.add(this.timeLabel);
bar.add(buttons);
bar.add(time);
this.side.add(bar);
this.side.add(this.preview, 1);
this.side.minWidth = this.previewWidth;
// Draggable like the console: how much of the tab is graph and how much is picture is a matter
// of what is being worked on, and on a narrow window a fixed panel eats the canvas.
this.splitter.onDrag = (delta) => {
this.previewWidth = clamp(this.previewWidth - delta, MIN_PREVIEW, MAX_PREVIEW);
this.side.minWidth = this.previewWidth;
// The row keeps its own size, so resizing it would not lay out again: ask it directly.
this.row.relayout();
};
this.row = new HStack();
this.row.add(canvas, 1);
this.row.add(this.splitter);
this.row.add(this.side);
return this.row;
}
/** Called every frame while the tab is showing: the panel is a moving picture. */
tick(): void {
this.preview.invalidate();
if (!this.preview.holding) this.syncTime();
// The rows are edited in the inspector, which the tab does not hear about: read the one that
// decides how far the slider reaches rather than wait to be told.
this.syncSpan(this.asset());
if (!this.preview.holding) this.syncControls();
}
/** What the preview controls say – the check scripts read this. */
get controls(): { hold: string; time: string; slider: number; span: number } {
return {
hold: this.holdButton.text,
time: this.timeLabel.text,
slider: this.slider.currentValue,
span: this.span,
};
}
private toggleHold(): void {
this.preview.hold(this.preview.holding ? null : this.preview.time);
this.holdButton.setText(this.preview.holding ? 'Run' : 'Hold');
this.syncTime();
this.syncControls();
}
private syncTime(): void {
private restart(): void {
this.preview.restart();
this.syncControls();
}
/**
* The controls read the preview, never the other way round: dragging the slider also holds, so a
* button that only changed on its own click said "Hold" while the panel already stood still.
*/
private syncControls(): void {
const holding = this.preview.holding;
const time = this.preview.time;
this.holdButton.setText(holding ? 'Run' : 'Hold');
this.timeLabel.setText(`${time.toFixed(2)} s`);
if (!this.preview.holding) this.slider.setValue(time % this.span);
this.slider.setValue(holding ? time : time % this.span);
}
protected override selectedId(): string {
@@ -100,8 +148,16 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
protected override syncExtras(asset: ParticlesAsset | null): void {
this.snap.setEnabled(Boolean(asset));
this.preview.show(asset);
const lifetime = Number(defaultsFor('particles')[LIFETIME_KEY] ?? 1);
this.span = Math.max(MIN_SPAN, lifetime * SPAN_LIFETIMES);
this.slider.setRange(0, this.span);
this.syncSpan(asset);
}
/** The slider covers three of the asset's own lifetimes, so a particle's whole arc fits. */
private syncSpan(asset: ParticlesAsset | null): void {
const lifetime = Number(asset?.props[LIFETIME_KEY] ?? defaultsFor('particles')[LIFETIME_KEY] ?? 1);
const span = Math.max(MIN_SPAN, lifetime * SPAN_LIFETIMES);
if (span === this.span) return;
this.span = span;
this.slider.setRange(0, span);
this.syncControls();
}
}
+8
View File
@@ -71,6 +71,14 @@ export class ParticleField {
this.reset();
}
/**
* Moves the origin so the field is `to` seconds old at `now`, keeping every particle where it is.
* What a held preview needs when it is let go: without it the clock ran on underneath the freeze.
*/
rebase(now: number, to: number): void {
this.startedAt = now - to;
}
private reset(): void {
this.highest = -1;
this.born = [];
+124 -7
View File
@@ -16,7 +16,7 @@ const RATE = 20;
export async function particles(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, locate, typeText, press, shot } = t;
const { wheel } = 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) => {
@@ -54,21 +54,24 @@ export async function particles(t) {
return { box, p, scrolled };
};
/** Places a node from the palette. The list scrolls, so the name is searched into view first. */
const placeNode = async (label, dx, dy) => {
const canvas = await json('__gm.particles.canvasRect()');
await click(canvas.x + dx, canvas.y + dy, 'right');
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 short (the preview strip takes the lower part), so dropping five
* 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 = {}) => {
@@ -133,7 +136,7 @@ export async function particles(t) {
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', 460, 60);
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',
@@ -154,6 +157,118 @@ export async function particles(t) {
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 } }),
);
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)});
delete a.props.lifetime;
delete a.props.rate;
`);
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)},
@@ -176,7 +291,9 @@ export async function particles(t) {
// old, is exactly 100 px below the source at strength 800.
await evaluate("__gm.showTab('particles')");
await sleep(250);
await placeNode('Gravity', 80, 60);
// 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(`