@flighthq/particleemitter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/emitParticleBurst.d.ts +15 -0
- package/dist/emitParticleBurst.d.ts.map +1 -0
- package/dist/emitParticleBurst.js +113 -0
- package/dist/emitParticleBurst.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/particleEmitter.d.ts +72 -0
- package/dist/particleEmitter.d.ts.map +1 -0
- package/dist/particleEmitter.js +328 -0
- package/dist/particleEmitter.js.map +1 -0
- package/dist/prewarmParticleEmitter.d.ts +5 -0
- package/dist/prewarmParticleEmitter.d.ts.map +1 -0
- package/dist/prewarmParticleEmitter.js +13 -0
- package/dist/prewarmParticleEmitter.js.map +1 -0
- package/dist/stepParticleEmitter.d.ts +21 -0
- package/dist/stepParticleEmitter.d.ts.map +1 -0
- package/dist/stepParticleEmitter.js +30 -0
- package/dist/stepParticleEmitter.js.map +1 -0
- package/dist/updateParticleEmitter.d.ts +8 -0
- package/dist/updateParticleEmitter.d.ts.map +1 -0
- package/dist/updateParticleEmitter.js +313 -0
- package/dist/updateParticleEmitter.js.map +1 -0
- package/package.json +42 -0
- package/src/deterministic.test.ts +53 -0
- package/src/emitParticleBurst.test.ts +102 -0
- package/src/particleEmitter.test.ts +516 -0
- package/src/prewarmParticleEmitter.test.ts +67 -0
- package/src/stepParticleEmitter.test.ts +86 -0
- package/src/updateParticleEmitter.test.ts +630 -0
- package/src/updateParticleEmitterIntegration.test.ts +270 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createRandomSource } from '@flighthq/math';
|
|
2
|
+
import {
|
|
3
|
+
applyParticleCollisions,
|
|
4
|
+
applyParticleForces,
|
|
5
|
+
createParticleEmitterConfig,
|
|
6
|
+
createParticleEmitterState,
|
|
7
|
+
enableParticleEmitterSignals,
|
|
8
|
+
normalizeParticleEmitterConfig,
|
|
9
|
+
} from '@flighthq/particles';
|
|
10
|
+
import type { TextureAtlas } from '@flighthq/types';
|
|
11
|
+
|
|
12
|
+
import { createParticleEmitter } from './particleEmitter';
|
|
13
|
+
import { updateParticleEmitter } from './updateParticleEmitter';
|
|
14
|
+
|
|
15
|
+
// Integration coverage for the node-driven sim: the pure primitives (forces, collisions, curves,
|
|
16
|
+
// config validation, state, signals) live in @flighthq/particles and are unit-tested there against
|
|
17
|
+
// node-free fixtures; these cases exercise them through the real ParticleEmitter node driven by
|
|
18
|
+
// updateParticleEmitter, which is only possible from this package.
|
|
19
|
+
|
|
20
|
+
function makeAtlas(): TextureAtlas {
|
|
21
|
+
return {
|
|
22
|
+
image: null,
|
|
23
|
+
regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
|
|
24
|
+
} as unknown as TextureAtlas;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('attractor forces via updateParticleEmitter', () => {
|
|
28
|
+
it('integrates with the core update so attractors curve trajectories', () => {
|
|
29
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
30
|
+
const state = createParticleEmitterState();
|
|
31
|
+
const spawnConfig = createParticleEmitterConfig({
|
|
32
|
+
spawnRate: 1,
|
|
33
|
+
lifetimeMin: 100,
|
|
34
|
+
lifetimeMax: 100,
|
|
35
|
+
speedMin: 0,
|
|
36
|
+
speedMax: 0,
|
|
37
|
+
});
|
|
38
|
+
updateParticleEmitter(emitter, state, spawnConfig, 1); // spawn one stationary particle at origin
|
|
39
|
+
|
|
40
|
+
const config = createParticleEmitterConfig({
|
|
41
|
+
spawnRate: 0,
|
|
42
|
+
speedMin: 0,
|
|
43
|
+
speedMax: 0,
|
|
44
|
+
lifetimeMin: 100,
|
|
45
|
+
lifetimeMax: 100,
|
|
46
|
+
});
|
|
47
|
+
const forces = [{ kind: 'AttractorForce' as const, x: 100, y: 0, strength: 200 }];
|
|
48
|
+
const startX = emitter.data.transforms[0];
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
applyParticleForces(emitter, state, forces, 1 / 60);
|
|
51
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
52
|
+
}
|
|
53
|
+
expect(emitter.data.transforms[0]).toBeGreaterThan(startX); // drifted toward the attractor
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('collision settling via updateParticleEmitter', () => {
|
|
58
|
+
it('settles a falling particle onto a floor over multiple frames', () => {
|
|
59
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
60
|
+
const state = createParticleEmitterState();
|
|
61
|
+
const spawnConfig = createParticleEmitterConfig({
|
|
62
|
+
spawnRate: 1,
|
|
63
|
+
lifetimeMin: 100,
|
|
64
|
+
lifetimeMax: 100,
|
|
65
|
+
speedMin: 0,
|
|
66
|
+
speedMax: 0,
|
|
67
|
+
});
|
|
68
|
+
updateParticleEmitter(emitter, state, spawnConfig, 1); // spawn one particle
|
|
69
|
+
emitter.data.transforms[0] = 0;
|
|
70
|
+
emitter.data.transforms[1] = 480;
|
|
71
|
+
state.velocities[0] = 0;
|
|
72
|
+
state.velocities[1] = 0;
|
|
73
|
+
|
|
74
|
+
const config = createParticleEmitterConfig({ spawnRate: 0, lifetimeMin: 100, lifetimeMax: 100, gravityY: 2000 });
|
|
75
|
+
const floor = [{ kind: 'PlaneCollider' as const, nx: 0, ny: -1, distance: -500 }];
|
|
76
|
+
for (let i = 0; i < 60; i++) {
|
|
77
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
78
|
+
applyParticleCollisions(emitter, state, floor);
|
|
79
|
+
}
|
|
80
|
+
expect(emitter.data.transforms[1]).toBeLessThanOrEqual(500 + 1e-3); // never falls through
|
|
81
|
+
expect(emitter.data.transforms[1]).toBeCloseTo(500, 0); // resting on the floor
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('lifetime curves in updateParticleEmitter', () => {
|
|
86
|
+
function spawnOne(configOverrides: Parameters<typeof createParticleEmitterConfig>[0]) {
|
|
87
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
88
|
+
const state = createParticleEmitterState();
|
|
89
|
+
const config = createParticleEmitterConfig({
|
|
90
|
+
spawnRate: 1,
|
|
91
|
+
lifetimeMin: 1,
|
|
92
|
+
lifetimeMax: 1,
|
|
93
|
+
speedMin: 0,
|
|
94
|
+
speedMax: 0,
|
|
95
|
+
...configOverrides,
|
|
96
|
+
});
|
|
97
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn one particle
|
|
98
|
+
return { emitter, state, config };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('alphaCurve drives alpha non-linearly over lifetime', () => {
|
|
102
|
+
// A curve that is 1 at birth, dips to 0 at mid-life, back to 1 at death.
|
|
103
|
+
const { emitter, state, config } = spawnOne({ alphaCurve: [1, 0, 1] });
|
|
104
|
+
updateParticleEmitter(emitter, state, config, 0.5); // → mid-life
|
|
105
|
+
expect(emitter.data.alphas[0]).toBeCloseTo(0, 2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('colorCurve overrides the start/end gradient', () => {
|
|
109
|
+
// green at birth, red at death (interleaved RGB).
|
|
110
|
+
const { emitter, state, config } = spawnOne({
|
|
111
|
+
colorCurve: [0, 1, 0, 1, 0, 0],
|
|
112
|
+
colorStartR: 0.123, // would be used by the linear path; curve must win
|
|
113
|
+
});
|
|
114
|
+
updateParticleEmitter(emitter, state, config, 0.5);
|
|
115
|
+
expect(emitter.data.colors[0]).toBeCloseTo(0.5); // R halfway
|
|
116
|
+
expect(emitter.data.colors[1]).toBeCloseTo(0.5); // G halfway
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('scaleCurve multiplies the spawn scale', () => {
|
|
120
|
+
const { emitter, state, config } = spawnOne({
|
|
121
|
+
scaleMin: 2,
|
|
122
|
+
scaleMax: 2,
|
|
123
|
+
scaleCurve: [1, 0.5, 0], // shrink to nothing
|
|
124
|
+
});
|
|
125
|
+
updateParticleEmitter(emitter, state, config, 0.5); // mid-life → factor 0.5
|
|
126
|
+
expect(emitter.data.transforms[3]).toBeCloseTo(1, 2); // 2 * 0.5
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('sets curve-driven values at spawn time (t=0)', () => {
|
|
130
|
+
const { emitter } = spawnOne({ alphaCurve: [0.25, 1] });
|
|
131
|
+
expect(emitter.data.alphas[0]).toBeCloseTo(0.25, 2);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('normalized config simulates via updateParticleEmitter', () => {
|
|
136
|
+
it('produces a config that simulates without NaN poisoning', () => {
|
|
137
|
+
// A pathologically corrupt config that would otherwise inject NaN everywhere.
|
|
138
|
+
const corrupt = createParticleEmitterConfig({
|
|
139
|
+
spawnRate: NaN,
|
|
140
|
+
lifetimeMin: NaN,
|
|
141
|
+
lifetimeMax: NaN,
|
|
142
|
+
gravityY: Infinity,
|
|
143
|
+
speedMin: NaN,
|
|
144
|
+
speedMax: NaN,
|
|
145
|
+
});
|
|
146
|
+
const safe = normalizeParticleEmitterConfig(corrupt);
|
|
147
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
148
|
+
const state = createParticleEmitterState();
|
|
149
|
+
for (let i = 0; i < 30; i++) updateParticleEmitter(emitter, state, safe, 1 / 60);
|
|
150
|
+
expect(emitter.data.particleCount).toBeGreaterThan(0);
|
|
151
|
+
for (let i = 0; i < emitter.data.particleCount * 4; i++) {
|
|
152
|
+
expect(Number.isFinite(emitter.data.transforms[i])).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('onEmitterComplete signal', () => {
|
|
158
|
+
it('fires once when a finite non-looping emitter completes', () => {
|
|
159
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
160
|
+
const state = createParticleEmitterState();
|
|
161
|
+
const config = createParticleEmitterConfig({
|
|
162
|
+
duration: 0.5,
|
|
163
|
+
loop: false,
|
|
164
|
+
spawnRate: 1,
|
|
165
|
+
lifetimeMin: 0.1,
|
|
166
|
+
lifetimeMax: 0.1,
|
|
167
|
+
speedMin: 0,
|
|
168
|
+
speedMax: 0,
|
|
169
|
+
});
|
|
170
|
+
const signals = enableParticleEmitterSignals(state);
|
|
171
|
+
const completes: number[] = [];
|
|
172
|
+
signals.onEmitterComplete.emit = () => completes.push(1);
|
|
173
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn + age past duration
|
|
174
|
+
updateParticleEmitter(emitter, state, config, 1); // particles die → complete
|
|
175
|
+
expect(completes.length).toBeGreaterThan(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('does not fire for infinite emitters', () => {
|
|
179
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
180
|
+
const state = createParticleEmitterState();
|
|
181
|
+
const config = createParticleEmitterConfig({
|
|
182
|
+
duration: 0,
|
|
183
|
+
loop: true,
|
|
184
|
+
spawnRate: 1,
|
|
185
|
+
lifetimeMin: 1,
|
|
186
|
+
lifetimeMax: 1,
|
|
187
|
+
speedMin: 0,
|
|
188
|
+
speedMax: 0,
|
|
189
|
+
});
|
|
190
|
+
const signals = enableParticleEmitterSignals(state);
|
|
191
|
+
const completes: number[] = [];
|
|
192
|
+
signals.onEmitterComplete.emit = () => completes.push(1);
|
|
193
|
+
for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 1);
|
|
194
|
+
expect(completes.length).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('onParticleDeath signal', () => {
|
|
199
|
+
it('fires with the particle position when a particle dies', () => {
|
|
200
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
201
|
+
const state = createParticleEmitterState();
|
|
202
|
+
const config = createParticleEmitterConfig({
|
|
203
|
+
spawnRate: 1,
|
|
204
|
+
lifetimeMin: 1,
|
|
205
|
+
lifetimeMax: 1,
|
|
206
|
+
speedMin: 0,
|
|
207
|
+
speedMax: 0,
|
|
208
|
+
});
|
|
209
|
+
const signals = enableParticleEmitterSignals(state);
|
|
210
|
+
const deaths: Array<[number, number]> = [];
|
|
211
|
+
signals.onParticleDeath.emit = (x: number, y: number) => deaths.push([x, y]);
|
|
212
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn
|
|
213
|
+
updateParticleEmitter(emitter, state, config, 1.1); // age past lifetime → death
|
|
214
|
+
expect(deaths.length).toBe(1);
|
|
215
|
+
expect(Number.isFinite(deaths[0][0])).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('onParticleSpawn signal', () => {
|
|
220
|
+
it('fires with spawn position and velocity when a particle is spawned', () => {
|
|
221
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
222
|
+
const state = createParticleEmitterState();
|
|
223
|
+
const config = createParticleEmitterConfig({
|
|
224
|
+
spawnRate: 1,
|
|
225
|
+
lifetimeMin: 1,
|
|
226
|
+
lifetimeMax: 1,
|
|
227
|
+
speedMin: 50,
|
|
228
|
+
speedMax: 50,
|
|
229
|
+
});
|
|
230
|
+
const signals = enableParticleEmitterSignals(state);
|
|
231
|
+
const spawns: Array<[number, number, number, number]> = [];
|
|
232
|
+
signals.onParticleSpawn.emit = (x: number, y: number, vx: number, vy: number) => spawns.push([x, y, vx, vy]);
|
|
233
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
234
|
+
expect(spawns.length).toBe(1);
|
|
235
|
+
const [, , vx, vy] = spawns[0];
|
|
236
|
+
// Speed must match config (allow sign variations by checking magnitude).
|
|
237
|
+
expect(Math.sqrt(vx * vx + vy * vy)).toBeCloseTo(50, 0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('seeded state determinism via updateParticleEmitter', () => {
|
|
242
|
+
// Runs a short emitter simulation seeded from `seed` and returns the live particle transforms, so
|
|
243
|
+
// a seeded state can be shown to simulate reproducibly (the seeded RNG lives in @flighthq/math).
|
|
244
|
+
function simulate(seed: number): number[] {
|
|
245
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
246
|
+
const state = createParticleEmitterState(createRandomSource(seed));
|
|
247
|
+
const config = createParticleEmitterConfig({
|
|
248
|
+
spawnRate: 20,
|
|
249
|
+
maxParticles: 100,
|
|
250
|
+
lifetimeMin: 0.5,
|
|
251
|
+
lifetimeMax: 1.5,
|
|
252
|
+
speedMin: 10,
|
|
253
|
+
speedMax: 200,
|
|
254
|
+
spread: Math.PI,
|
|
255
|
+
emitterShape: 'circle',
|
|
256
|
+
emitterRadius: 25,
|
|
257
|
+
colorStartVarianceR: 0.5,
|
|
258
|
+
});
|
|
259
|
+
for (let f = 0; f < 30; f++) updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
260
|
+
return Array.from(emitter.data.transforms.slice(0, emitter.data.particleCount * 4));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
it('seeded identically, two states simulate bit-for-bit identically', () => {
|
|
264
|
+
expect(simulate(42)).toEqual(simulate(42));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('diverges under different seeds', () => {
|
|
268
|
+
expect(simulate(42)).not.toEqual(simulate(43));
|
|
269
|
+
});
|
|
270
|
+
});
|