Files
feuerwerk/tests/core/particles.test.ts
T
adminandClaude Fable 5.1 cc6db5f350
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s
Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:38:54 +02:00

449 lines
18 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, SetAssetPropertyCommand } 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 { graphFingerprint } from '../../src/core/graph/Graph';
import { compileParticles, emptyParticleOut, type ParticleOut } from '../../src/core/particles/compile';
import {
birthTime,
fieldTime,
type LiveRange,
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 { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
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 = graphFingerprint(p.asset().graph);
f.stack.execute(new MoveNodeCommand(p.id, n, 120, 80));
expect(graphFingerprint(p.asset().graph)).toBe(before);
f.stack.execute(new AddNodeCommand(p.id, 'ParticleNumber', 0, 0, { value: 5 }));
expect(graphFingerprint(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');
});
});
const liveCount = (range: LiveRange): number => Math.max(0, range.to - range.from + 1);
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']);
});
});
/**
* A source wears a shader the way a sprite does (#88) – but it is the only asset drawing its rows
* from two graphs at once, so what a name means when both declare it is pinned here.
*/
describe('a source wearing a shader', () => {
function wearing(uniform = 'glow') {
const f = makeProject();
const p = particlesEditor(f);
const create = new CreateAssetCommand('shader', 'Glow');
f.stack.execute(create);
const shaderId = create.asset.id;
const declared = { name: uniform, default: 3, min: 0, max: 10 };
f.stack.execute(new AddNodeCommand(shaderId, SHADER_PROPERTY, 0, 0, declared));
const keys = () => schemaForAsset(f.project, p.asset()).map((d) => d.key);
const wear = () => f.stack.execute(new SetAssetPropertyCommand(p.id, SHADER_KEY, shaderId));
return { ...f, p, shaderId, keys, wear };
}
it('has the picker, and the shader’s rows once one is picked', () => {
const f = wearing();
expect(schemaFor('particles').find((d) => d.key === SHADER_KEY)).toMatchObject({
kind: 'asset',
assetTypes: ['shader'],
default: '',
});
expect(f.keys()).not.toContain('glow');
f.wear();
expect(f.keys().at(-1)).toBe('glow');
expect(schemaForAsset(f.project, f.p.asset()).at(-1)).toMatchObject({ kind: 'number', default: 3 });
});
it('keeps a name it already has: the uniform gets no second row', () => {
const f = wearing(RATE_KEY);
f.wear();
// One row named `rate`, and it is the source's own – the uniform stays on its declared default.
expect(f.keys().filter((k) => k === RATE_KEY)).toEqual([RATE_KEY]);
expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === RATE_KEY)).toMatchObject({
label: 'Rate',
default: 20,
});
});
it('takes a name the particle graph declares, too', () => {
const f = wearing('spin');
f.p.add(PARTICLE_PROPERTY, { name: 'spin', default: 7 });
f.wear();
expect(f.keys().filter((k) => k === 'spin')).toEqual(['spin']);
expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === 'spin')).toMatchObject({
default: 7,
});
});
it('carries the shader and its values through a save', () => {
const f = wearing();
f.wear();
f.stack.execute(new SetAssetPropertyCommand(f.p.id, 'glow', 5));
const loaded = findAssetOf(roundTrip(f.project), f.p.id, 'particles')!;
expect(loaded.props[SHADER_KEY]).toBe(f.shaderId);
expect(loaded.props.glow).toBe(5);
});
});