diff --git a/README.md b/README.md index fac327fc..9d9ecb05 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/AssetTabEditor.ts b/src/editor/AssetTabEditor.ts index 48744234..89789a4c 100644 --- a/src/editor/AssetTabEditor.ts +++ b/src/editor/AssetTabEditor.ts @@ -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 {} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 74323ee3..aab61008 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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), }, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 8064673a..bb6bab59 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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(); diff --git a/src/editor/particles/ParticlePreview.ts b/src/editor/particles/ParticlePreview.ts index 7b3451ee..59719f5f 100644 --- a/src/editor/particles/ParticlePreview.ts +++ b/src/editor/particles/ParticlePreview.ts @@ -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 }; } diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index 7e4bafdc..12602fda 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -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 { - 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 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(); } } diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 48cb2c8b..6ac371ab 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -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 = []; diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index db24f404..b54c6abd 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -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(`