A particle is a function of its age, not a thing that is stepped (#73)
game-maker / suite (push) Failing after 4m22s
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:
1 parent
f83c568999
commit
0f9e1611d1
39 files changed
+2386
-32
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user