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

+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 = [];