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

+2 -2
View File
@@ -74,8 +74,8 @@ describe('node registry', () => {
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
// An event is seeded by the runtime, a state is a name in a machine: neither runs.
if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0);
// A shader node is compiled to GLSL, never run.
else if (def.category === 'shader') expect('emit' in def).toBe(true);
// A shader node is compiled to GLSL and a particle node to a function: neither is ever run.
else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true);
else expect(def.run).toBeTypeOf('function');
expect(keys.every((k) => k.length > 0)).toBe(true);
}
+15
View File
@@ -56,6 +56,8 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } =>
};
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
/** Its own entity, because Emit's whole effect is the count it leaves on the one it is handed. */
const emitTarget = fakeEntity('Sparks');
const painted = fakeHandle({ width: 100, text: '' });
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
@@ -647,6 +649,19 @@ const CASES: Record<string, Case[]> = {
next: 'exec',
},
],
Emit: [
{
name: 'no source is a no-op that still continues the flow',
inputs: { source: null },
next: 'exec',
},
{
name: 'counts a pulse on the source it is given',
inputs: { source: emitTarget },
next: 'exec',
after: () => expect(emitTarget.emits).toBe(1),
},
],
Log: [
{
name: 'logs its message and continues',
+335
View File
@@ -0,0 +1,335 @@
/**
* A particle asset is a graph that says what one particle *is*, as a function of its age and of a
* random number drawn once for it – not what forces act on it. That choice is what these tests
* pin: the same second always looks the same, and the whole field is a function of the clock.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { findAssetOf } from '../../src/core/model/Project';
import {
compileParticles,
emptyParticleOut,
type ParticleOut,
particlesFingerprint,
} from '../../src/core/particles/compile';
import { birthTime, liveCount, liveRange } from '../../src/core/particles/emission';
import {
hashId,
mixColor,
PARTICLE_NODES,
PARTICLE_OUTPUT,
PARTICLE_OUTPUTS,
PARTICLE_RANDOM,
type ParticleEnv,
} from '../../src/core/particles/nodes';
import {
BURST_COUNT_KEY,
EMITTING_KEY,
LIFETIME_KEY,
MAX_PARTICLES_KEY,
PARTICLE_PROPERTY,
particleParamDefs,
RATE_KEY,
} from '../../src/core/properties/particles';
import { schemaFor, schemaForAsset } from '../../src/core/properties/PropertySchema';
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
import { makeProject, type ProjectFixture, roundTrip } from '../helpers';
/** Command-driven editing of a particle asset's graph, the way the tab does it. */
function particlesEditor(f: ProjectFixture, name = 'Sparks') {
const create = new CreateAssetCommand('particles', name);
f.stack.execute(create);
const id = create.asset.id;
const asset = () => findAssetOf(f.project, id, 'particles')!;
const add = (type: string, params = {}) => {
const command = new AddNodeCommand(id, type, 0, 0, params);
f.stack.execute(command);
return command.node.id;
};
const link = (from: string, fromPort: string, to: string, toPort: string) =>
f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort }));
return { id, asset, add, link };
}
const env = (over: Partial<ParticleEnv> = {}): ParticleEnv => ({
age: 0,
ageSeconds: 0,
time: 0,
index: 0,
seed: 0,
color: 0xffffff,
params: [],
...over,
});
function run(p: ReturnType<typeof particlesEditor>, at: Partial<ParticleEnv>): ParticleOut {
const build = compileParticles(p.asset().graph);
expect(build.reason).toBe('');
const out = emptyParticleOut();
build.compiled!.evaluate(env(at), out);
return out;
}
describe('a particle asset', () => {
it('is placeable, carries a graph, and starts out with nothing to compile', () => {
const f = makeProject();
const p = particlesEditor(f);
expect(isPlaceableType('particles')).toBe(true);
expect(p.asset().graph).toEqual({ nodes: [], edges: [] });
expect(compileParticles(p.asset().graph).reason).toBe('No Output node');
// The source's own rows are there from the start; the graph adds to them.
expect(schemaFor('particles').map((d) => d.key)).toContain(RATE_KEY);
expect(schemaFor('particles').map((d) => d.key)).toContain(EMITTING_KEY);
});
it('leaves every unconnected output at what a particle without a graph would be', () => {
const f = makeProject();
const p = particlesEditor(f);
p.add(PARTICLE_OUTPUT);
const out = run(p, { age: 0.5, color: 0x336699 });
expect(out).toEqual({
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
// Unconnected, the colour is the source's own row – not a hard-coded white.
color: 0x336699,
alpha: 1,
});
});
it('fades a particle out over its life when Age feeds alpha through Subtract', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
const age = p.add('ParticleAge');
const fade = p.add('ParticleSubtract', { a: 1 });
p.link(age, 'age', fade, 'b');
p.link(fade, 'result', output, 'alpha');
expect(run(p, { age: 0 }).alpha).toBe(1);
expect(run(p, { age: 0.25 }).alpha).toBe(0.75);
expect(run(p, { age: 1 }).alpha).toBe(0);
});
it('falls by ½gt², which is the whole point of a formula instead of a force', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
const gravity = p.add('ParticleGravity', { strength: 800 });
p.link(gravity, 'y', output, 'y');
expect(run(p, { ageSeconds: 0 }).y).toBe(0);
expect(run(p, { ageSeconds: 0.5 }).y).toBe(100);
expect(run(p, { ageSeconds: 1 }).y).toBe(400);
});
it('aims Start velocity by degrees, 0 to the right and 90 down', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
const v = p.add('ParticleStartVelocity', { angle: 90, speed: 200 });
p.link(v, 'x', output, 'x');
p.link(v, 'y', output, 'y');
const out = run(p, { ageSeconds: 0.5 });
expect(out.x).toBeCloseTo(0, 10);
expect(out.y).toBeCloseTo(100, 10);
});
it('draws one random number per particle and a different one per Random node', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
const a = p.add(PARTICLE_RANDOM, { min: 0, max: 100 });
const b = p.add(PARTICLE_RANDOM, { min: 0, max: 100 });
p.link(a, 'value', output, 'x');
p.link(b, 'value', output, 'y');
const first = run(p, { seed: 7, ageSeconds: 0 });
// The same particle at a later moment: its draw has not moved.
const later = run(p, { seed: 7, ageSeconds: 9, age: 0.9 });
expect(later.x).toBe(first.x);
// Two nodes, two draws – otherwise direction and speed of one particle would be the same number.
expect(first.y).not.toBe(first.x);
// A different particle draws differently.
expect(run(p, { seed: 8 }).x).not.toBe(first.x);
// And so does the same index at another source: the drawing party's salt goes into the seed,
// which is what keeps two fountains of one asset from being the same fountain twice.
expect(run(p, { seed: 7 ^ hashId('source-b') }).x).not.toBe(first.x);
});
it('feeds one node into two without evaluating it twice differently', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
const r = p.add(PARTICLE_RANDOM, { min: 0, max: 100 });
p.link(r, 'value', output, 'x');
p.link(r, 'value', output, 'y');
const out = run(p, { seed: 3 });
expect(out.x).toBe(out.y);
});
it('declares a row per Property node and reads its value from the source', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 });
const second = p.add(PARTICLE_PROPERTY, { name: 'spread', default: 1, min: 0, max: 2 });
p.link(second, 'value', output, 'x');
const rows = particleParamDefs(p.asset().graph);
expect(rows.map((r) => r.key)).toEqual(['speed', 'spread']);
expect(schemaForAsset(f.project, p.asset()).map((d) => d.key)).toEqual([
...schemaFor('particles').map((d) => d.key),
'speed',
'spread',
]);
// `spread` is the second row, so it reads slot 1 – not slot 0, and not by name per particle.
expect(run(p, { params: [99, 42] }).x).toBe(42);
});
it('refuses a parameter named after one of the source’s own rows', () => {
const f = makeProject();
const p = particlesEditor(f);
p.add(PARTICLE_OUTPUT);
const bad = p.add(PARTICLE_PROPERTY, { name: RATE_KEY });
const build = compileParticles(p.asset().graph);
expect(build.compiled).toBeNull();
expect(build.problems[bad]).toContain('Name must be one word');
expect(particleParamDefs(p.asset().graph)).toEqual([]);
});
it('names the node that feeds itself instead of hanging', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
const a = p.add('ParticleAdd');
const b = p.add('ParticleAdd');
p.link(a, 'result', b, 'a');
p.link(b, 'result', a, 'a');
p.link(a, 'result', output, 'x');
const build = compileParticles(p.asset().graph);
expect(build.compiled).toBeNull();
expect(Object.values(build.problems)).toContain('Feeds itself');
});
it('keeps the fingerprint when a node only moves, and changes it on a parameter', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
const n = p.add('ParticleNumber', { value: 2 });
p.link(n, 'value', output, 'x');
const before = particlesFingerprint(p.asset().graph);
f.stack.execute(new MoveNodeCommand(p.id, n, 120, 80));
expect(particlesFingerprint(p.asset().graph)).toBe(before);
f.stack.execute(new AddNodeCommand(p.id, 'ParticleNumber', 0, 0, { value: 5 }));
expect(particlesFingerprint(p.asset().graph)).not.toBe(before);
});
it('survives a round trip through the project file, graph and rows intact', () => {
const f = makeProject();
const p = particlesEditor(f);
const output = p.add(PARTICLE_OUTPUT);
p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 });
const g = p.add('ParticleGravity', { strength: 400 });
p.link(g, 'y', output, 'y');
const back = roundTrip(f.project);
const asset = findAssetOf(back, p.id, 'particles')!;
expect(asset.graph.nodes).toHaveLength(3);
expect(asset.graph.edges).toHaveLength(1);
expect(particleParamDefs(asset.graph).map((r) => r.key)).toEqual(['speed']);
const out = emptyParticleOut();
compileParticles(asset.graph).compiled!.evaluate(env({ ageSeconds: 1 }), out);
expect(out.y).toBe(200);
});
it('offers only particle nodes in its palette, and the Output only while there is none', () => {
const f = makeProject();
const p = particlesEditor(f);
const before = paletteEntries(f.project, p.asset());
expect(before.every((e) => e.def.category === 'particles')).toBe(true);
expect(before.map((e) => e.id)).toContain(PARTICLE_OUTPUT);
expect(before).toHaveLength(PARTICLE_NODES.length);
// A behavior node has no business here, and neither has a shader one.
expect(before.map((e) => e.id)).not.toContain('Branch');
p.add(PARTICLE_OUTPUT);
expect(paletteEntries(f.project, p.asset()).map((e) => e.id)).not.toContain(PARTICLE_OUTPUT);
});
it('numbers the Property nodes it places from the palette', () => {
const f = makeProject();
const p = particlesEditor(f);
const first = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!;
expect(first.params.name).toBe('value');
p.add(PARTICLE_PROPERTY, first.params);
const second = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!;
expect(second.params.name).toBe('value2');
});
});
describe('when particles are alive', () => {
const settings = {
mode: 'continuous' as const,
rate: 10,
count: 20,
lifetime: 1,
max: 500,
};
it('puts a continuous source on a fixed grid, so the field is a function of the clock', () => {
// Particle n is born at n/rate: at 2 s exactly, particles 11..20 are the ones within a lifetime.
expect(birthTime(settings, 15)).toBe(1.5);
expect(liveRange(settings, 2)).toEqual({ from: 11, to: 20 });
expect(liveCount(liveRange(settings, 2))).toBe(10);
// Before the first one is due there is nothing, and one frame in there is exactly one.
expect(liveCount(liveRange(settings, 0))).toBe(1);
expect(liveCount(liveRange(settings, 0.05))).toBe(1);
});
it('drops the oldest when the cap is reached, never the newest', () => {
expect(liveRange({ ...settings, max: 4 }, 2)).toEqual({ from: 17, to: 20 });
});
it('lets the ones in flight live out their lives after emission stops', () => {
// Emission stopped at 1 s; at 1.5 s only what was born after 0.5 s is still alive.
expect(liveRange(settings, 1.5, 1)).toEqual({ from: 6, to: 10 });
// A whole lifetime after the stop there is nothing left.
expect(liveCount(liveRange(settings, 2.01, 1))).toBe(0);
});
it('sends a burst out at once and ends it one lifetime later', () => {
const burst = { ...settings, mode: 'burst' as const, count: 30 };
expect(birthTime(burst, 29)).toBe(0);
expect(liveRange(burst, 0.5)).toEqual({ from: 0, to: 29 });
expect(liveCount(liveRange(burst, 1))).toBe(0);
// The cap holds for a burst too.
expect(liveRange({ ...burst, max: 8 }, 0.5)).toEqual({ from: 0, to: 7 });
});
it('emits nothing without a rate, a lifetime or a future', () => {
expect(liveCount(liveRange({ ...settings, rate: 0 }, 2))).toBe(0);
expect(liveCount(liveRange({ ...settings, lifetime: 0 }, 2))).toBe(0);
expect(liveCount(liveRange(settings, -1))).toBe(0);
});
});
describe('a particle colour', () => {
it('blends channel by channel, which a plain number mix cannot', () => {
expect(mixColor(0xff0000, 0x0000ff, 0)).toBe(0xff0000);
expect(mixColor(0xff0000, 0x0000ff, 1)).toBe(0x0000ff);
// Half way is dark magenta – mixing the packed numbers would have run through green.
expect(mixColor(0xff0000, 0x0000ff, 0.5)).toBe(0x800080);
expect(mixColor(0x000000, 0xffffff, 2)).toBe(0xffffff);
});
});
describe('the source rows', () => {
it('name every setting the emission rule reads', () => {
const keys = schemaFor('particles').map((d) => d.key);
for (const key of [RATE_KEY, BURST_COUNT_KEY, LIFETIME_KEY, MAX_PARTICLES_KEY, EMITTING_KEY])
expect(keys).toContain(key);
});
it('has one Output input per drawn value', () => {
expect([...PARTICLE_OUTPUTS]).toEqual(['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha']);
});
});