Files
feuerwerk/tests/core/particles.test.ts
T
rootandClaude Opus 5 8ae20d044f
game-maker / suite (push) Successful in 9m48s
Time is an argument, so speed scales it and a prewarm offsets it (#87)
Speed and Prewarm are not state and not a second loop: both are arithmetic
on the clock argument the field already passes to every particle formula.
fieldTime = elapsed * timeScale + prewarmOffset, a pure rule next to
liveRange, called at the one place ParticleField.sync forms its time, so
preview and world inherit it together.

Prewarm offsets by one lifetime because that is the smallest offset which
fills a continuous emitter; a burst is dead at time >= lifetime, so the
same offset would skip it entirely and it is suppressed there.

The tab's slider counts wall-clock seconds, so its span divides by the
scale. A missing row falls back to 1 / off, so old project files need no
migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:31:07 +02:00

382 lines
16 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, fieldTime, liveCount, liveRange, prewarmOffset } 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,
isValidParticlePropertyName,
LIFETIME_KEY,
MAX_PARTICLES_KEY,
PARTICLE_PROPERTY,
particleParamDefs,
PREWARM_KEY,
RATE_KEY,
TIME_SCALE_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,
timeScale: 1,
prewarm: false,
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('runs the source clock at its own speed, which is all a time scale is', () => {
const fast = { ...settings, timeScale: 2 };
expect(fieldTime(fast, 1)).toBe(2);
// Twice the speed is the same picture half the time earlier, particle for particle.
expect(liveRange(fast, fieldTime(fast, 1))).toEqual(liveRange(settings, fieldTime(settings, 2)));
// A scale of zero holds the second it starts on rather than emptying the field.
expect(fieldTime({ ...settings, timeScale: 0 }, 99)).toBe(0);
});
it('starts a prewarmed source one lifetime in, where the field is already full', () => {
const warm = { ...settings, prewarm: true };
expect(fieldTime(warm, 0)).toBe(settings.lifetime);
// Its very first frame carries a whole lifetime of particles; a cold one carries the first.
expect(liveCount(liveRange(warm, fieldTime(warm, 0)))).toBe(10);
expect(liveCount(liveRange(settings, fieldTime(settings, 0)))).toBe(1);
// And it is the steady state, not a spike: a second later there are as many.
expect(liveCount(liveRange(warm, fieldTime(warm, 1)))).toBe(10);
});
it('leaves a burst alone, which one lifetime in would be over', () => {
const burst = { ...settings, mode: 'burst' as const, prewarm: true };
expect(prewarmOffset(burst)).toBe(0);
expect(liveCount(liveRange(burst, fieldTime(burst, 0)))).toBe(20);
});
it('counts the prewarm in the seconds a lifetime is in, not in the world clock', () => {
// Scaled first, then offset: at twice the speed one second in is the third second of the field.
expect(fieldTime({ ...settings, prewarm: true, timeScale: 2 }, 1)).toBe(3);
});
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,
TIME_SCALE_KEY,
PREWARM_KEY,
])
expect(keys).toContain(key);
// A graph parameter may not take one of those names, or the inspector would show it twice.
expect(isValidParticlePropertyName(TIME_SCALE_KEY)).toBe(false);
expect(isValidParticlePropertyName(PREWARM_KEY)).toBe(false);
});
it('has one Output input per drawn value', () => {
expect([...PARTICLE_OUTPUTS]).toEqual(['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha']);
});
});