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.
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
/**
|
||
* 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']);
|
||
});
|
||
});
|