A particle is a function of its age, not a thing that is stepped (#73)
game-maker / suite (push) Failing after 4m22s

The asset type `particles` is placeable and carries a graph at the same time —
`behavior` was the blueprint. Because a continuous emitter gives birth to particle n
at time n/rate, the whole field is a function of the clock rather than of the frames:
no ring-buffer bookkeeping, exact time jumps, reproducibility as a measurable property.

compile.ts follows compileShader's frame but emits JS closures into a Float64Array
instead of GLSL, and without eval; a node with two consumers is evaluated once.
Drawing is one Pixi ParticleContainer per source, so one draw call.

Collision, wind and braking in flight are out for good — that is the price of the
formula model, not an omission.

The tab count goes from nine to ten, which is exactly the case #84 was built for one
day earlier: at 1280 px the row no longer holds every tab, so guard 14b now asks for
hidden.length > 0 instead of === 0.
This commit is contained in:
root committed 2026-09-14 05:44:25 +02:00
1 parent f83c568999
commit 0f9e1611d1
39 files changed
+2386 -32

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
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 { defaultsFor } from '../../core/properties/PropertySchema';
import { type Size, Theme, Widget } from '../../ui';
import { ParticleField } from '../../viewport/particles';
import { particleLibrary } from '../../viewport/ParticleLibrary';
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.
*/
export class ParticlePreview extends Widget {
private readonly bg = new Graphics();
private readonly content = new Container();
private readonly clip = new Graphics();
private readonly field = new ParticleField();
private asset: ParticlesAsset | null = null;
/** Null: the preview runs on the library clock. A number: it stands at that second. */
private held: number | null = null;
constructor() {
super();
this.minHeight = MIN_HEIGHT;
this.content.addChild(this.field.container);
this.content.mask = this.clip;
this.addChild(this.bg, this.content, this.clip);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) };
}
show(asset: ParticlesAsset | null): void {
this.asset = asset;
this.invalidate();
}
/** Seconds the shown source has been emitting, whether held or running. */
get time(): number {
return this.held ?? Math.max(0, this.field.elapsed(particleLibrary.now()));
}
get holding(): boolean {
return this.held !== null;
}
hold(at: number | null): void {
this.held = at;
this.invalidate();
}
restart(): void {
this.field.restart(particleLibrary.now());
this.invalidate();
}
/** How many particles the preview is drawing – what the checks read. */
get count(): number {
return this.field.count;
}
override redraw(): void {
const c = Theme.colors;
// A ground of its own and a rule along the top: on the graph's own background the strip was
// invisible as a thing, and a particle standing still read as a node that had lost its box.
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel);
this.bg.rect(0, 0, this.w, 1).fill(c.border);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.field.container.visible = this.asset !== null;
if (!this.asset) return;
const build = particleLibrary.buildFor(this.asset);
const rows = build.compiled?.params ?? [];
const props = previewProps(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 m = translation(this.w / 2, this.h / 2);
this.field.sync(build.compiled, props, params, m, Texture.WHITE, this.clock());
}
/** Holding at second t means handing the field a clock that reads exactly t since it started. */
private clock(): number {
const now = particleLibrary.now();
if (this.held === null) return now;
return now - this.field.elapsed(now) + this.held;
}
override destroy(): void {
this.field.destroy();
super.destroy();
}
}
/** 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 {
const props = defaultsFor('particles');
for (const row of params) props[row.key] = row.default;
return props;
}
+107
View File
@@ -0,0 +1,107 @@
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 { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle';
import type { EditorState } from '../state';
import { ParticlePreview } from './ParticlePreview';
/** Room kept for "N parameters", or for the reason the graph does not compile. */
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 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.
*/
export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas> {
readonly preview = new 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() });
private readonly timeLabel = new Label('0.00 s', { style: 'small' });
readonly splitter = new Splitter('y');
private span = MIN_SPAN;
private previewHeight = 180;
constructor(state: EditorState) {
super(state, { noun: 'Particles', infoWidth: INFO_WIDTH });
this.slider.onChange = (value) => {
this.preview.hold(value);
this.syncTime();
};
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. */
tick(): void {
this.preview.invalidate();
if (!this.preview.holding) this.syncTime();
}
private toggleHold(): void {
this.preview.hold(this.preview.holding ? null : this.preview.time);
this.holdButton.setText(this.preview.holding ? 'Run' : 'Hold');
this.syncTime();
}
private syncTime(): void {
const time = this.preview.time;
this.timeLabel.setText(`${time.toFixed(2)} s`);
if (!this.preview.holding) this.slider.setValue(time % this.span);
}
protected override selectedId(): string {
return this.state.selectedParticlesId ?? '';
}
protected override isMine(asset: Asset): asset is ParticlesAsset {
return asset.type === 'particles';
}
protected override infoText(asset: ParticlesAsset): string {
const build = particleLibrary.buildFor(asset);
if (!build.compiled) return build.reason;
const count = build.compiled.params.length;
return `${count} ${count === 1 ? 'parameter' : 'parameters'}`;
}
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);
}
}