Files
feuerwerk/tests/core/particles.test.ts
T
root 0f9e1611d1
game-maker / suite (push) Failing after 4m22s
A particle is a function of its age, not a thing that is stepped (#73)
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.
2026-09-14 05:44:25 +02:00

336 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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']);
});
});