@flighthq/particles 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.
Files changed (71) hide show
  1. package/dist/applyParticleCollisions.d.ts +12 -0
  2. package/dist/applyParticleCollisions.d.ts.map +1 -0
  3. package/dist/applyParticleCollisions.js +175 -0
  4. package/dist/applyParticleCollisions.js.map +1 -0
  5. package/dist/applyParticleForces.d.ts +13 -0
  6. package/dist/applyParticleForces.d.ts.map +1 -0
  7. package/dist/applyParticleForces.js +129 -0
  8. package/dist/applyParticleForces.js.map +1 -0
  9. package/dist/curve.d.ts +35 -0
  10. package/dist/curve.d.ts.map +1 -0
  11. package/dist/curve.js +227 -0
  12. package/dist/curve.js.map +1 -0
  13. package/dist/emitParticleBurst.d.ts +15 -0
  14. package/dist/emitParticleBurst.d.ts.map +1 -0
  15. package/dist/emitParticleBurst.js +114 -0
  16. package/dist/emitParticleBurst.js.map +1 -0
  17. package/dist/index.d.ts +14 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +14 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/particleEmitterConfig.d.ts +4 -0
  22. package/dist/particleEmitterConfig.d.ts.map +1 -0
  23. package/dist/particleEmitterConfig.js +53 -0
  24. package/dist/particleEmitterConfig.js.map +1 -0
  25. package/dist/particleEmitterSignals.d.ts +20 -0
  26. package/dist/particleEmitterSignals.d.ts.map +1 -0
  27. package/dist/particleEmitterSignals.js +33 -0
  28. package/dist/particleEmitterSignals.js.map +1 -0
  29. package/dist/particleEmitterState.d.ts +7 -0
  30. package/dist/particleEmitterState.d.ts.map +1 -0
  31. package/dist/particleEmitterState.js +38 -0
  32. package/dist/particleEmitterState.js.map +1 -0
  33. package/dist/particleObjectsState.d.ts +4 -0
  34. package/dist/particleObjectsState.d.ts.map +1 -0
  35. package/dist/particleObjectsState.js +24 -0
  36. package/dist/particleObjectsState.js.map +1 -0
  37. package/dist/prewarmParticleEmitter.d.ts +5 -0
  38. package/dist/prewarmParticleEmitter.d.ts.map +1 -0
  39. package/dist/prewarmParticleEmitter.js +13 -0
  40. package/dist/prewarmParticleEmitter.js.map +1 -0
  41. package/dist/stepParticleEmitter.d.ts +38 -0
  42. package/dist/stepParticleEmitter.d.ts.map +1 -0
  43. package/dist/stepParticleEmitter.js +57 -0
  44. package/dist/stepParticleEmitter.js.map +1 -0
  45. package/dist/updateParticleEmitter.d.ts +8 -0
  46. package/dist/updateParticleEmitter.d.ts.map +1 -0
  47. package/dist/updateParticleEmitter.js +315 -0
  48. package/dist/updateParticleEmitter.js.map +1 -0
  49. package/dist/updateParticleObjects.d.ts +8 -0
  50. package/dist/updateParticleObjects.d.ts.map +1 -0
  51. package/dist/updateParticleObjects.js +151 -0
  52. package/dist/updateParticleObjects.js.map +1 -0
  53. package/dist/validateParticleEmitterConfig.d.ts +15 -0
  54. package/dist/validateParticleEmitterConfig.d.ts.map +1 -0
  55. package/dist/validateParticleEmitterConfig.js +206 -0
  56. package/dist/validateParticleEmitterConfig.js.map +1 -0
  57. package/package.json +42 -0
  58. package/src/applyParticleCollisions.test.ts +191 -0
  59. package/src/applyParticleForces.test.ts +174 -0
  60. package/src/curve.test.ts +327 -0
  61. package/src/deterministic.test.ts +54 -0
  62. package/src/emitParticleBurst.test.ts +103 -0
  63. package/src/particleEmitterConfig.test.ts +65 -0
  64. package/src/particleEmitterSignals.test.ts +125 -0
  65. package/src/particleEmitterState.test.ts +113 -0
  66. package/src/particleObjectsState.test.ts +34 -0
  67. package/src/prewarmParticleEmitter.test.ts +68 -0
  68. package/src/stepParticleEmitter.test.ts +113 -0
  69. package/src/updateParticleEmitter.test.ts +631 -0
  70. package/src/updateParticleObjects.test.ts +340 -0
  71. package/src/validateParticleEmitterConfig.test.ts +118 -0
@@ -0,0 +1,327 @@
1
+ import { createParticleEmitter } from '@flighthq/sprite';
2
+ import type { TextureAtlas } from '@flighthq/types';
3
+
4
+ import {
5
+ buildParticleColorCurve,
6
+ buildParticleCurve,
7
+ lerpHsvDirect,
8
+ lerpHsvInPlace,
9
+ particleColorCurveFromKeyframes,
10
+ particleColorCurveToKeyframes,
11
+ particleCurveFromKeyframes,
12
+ particleCurveToKeyframes,
13
+ sampleParticleColorCurve,
14
+ sampleParticleCurve,
15
+ } from './curve';
16
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
17
+ import { createParticleEmitterState } from './particleEmitterState';
18
+ import { updateParticleEmitter } from './updateParticleEmitter';
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 TextureAtlas;
25
+ }
26
+
27
+ describe('buildParticleColorCurve', () => {
28
+ it('bakes an RGB function into an interleaved LUT', () => {
29
+ const lut = buildParticleColorCurve((t) => [t, 0, 1 - t], 3);
30
+ expect(lut.length).toBe(9);
31
+ expect(lut[0]).toBe(0); // R at t=0
32
+ expect(lut[2]).toBe(1); // B at t=0
33
+ });
34
+ });
35
+
36
+ describe('buildParticleCurve', () => {
37
+ it('bakes a scalar function into a LUT sampled at the endpoints', () => {
38
+ const lut = buildParticleCurve((t) => t * t, 5);
39
+ expect(lut.length).toBe(5);
40
+ expect(lut[0]).toBe(0);
41
+ expect(lut[4]).toBe(1);
42
+ expect(sampleParticleCurve(lut, 0.5)).toBeCloseTo(0.25, 1);
43
+ });
44
+ });
45
+
46
+ describe('lerpHsvDirect', () => {
47
+ it('interpolates from red to green through yellow at t=0.5', () => {
48
+ const out = [0, 0, 0];
49
+ // Red → Green: at t=0.5, hue should be ~yellow (1/12 of hue circle)
50
+ lerpHsvDirect(out, 0, 1, 0, 0, 0, 1, 0, 0.5);
51
+ // Yellow = R≈1, G≈1, B≈0
52
+ expect(out[0]).toBeGreaterThan(0.7); // R
53
+ expect(out[1]).toBeGreaterThan(0.7); // G
54
+ expect(out[2]).toBeCloseTo(0, 2); // B
55
+ });
56
+
57
+ it('takes the short arc when interpolating hues', () => {
58
+ // From blue (hue=2/3) to red (hue=0) — short arc goes through magenta (hue≈5/6), not through green
59
+ const out = [0, 0, 0];
60
+ lerpHsvDirect(out, 0, 0, 0, 1, 1, 0, 0, 0.5);
61
+ // At midpoint (magenta direction): R should be high, G low
62
+ expect(out[0]).toBeGreaterThan(0.5); // R is prominent
63
+ expect(out[1]).toBeCloseTo(0, 2); // no green in blue/red short arc
64
+ });
65
+
66
+ it('writes at the given offset', () => {
67
+ const out = [9, 9, 9, 0, 0, 0];
68
+ lerpHsvDirect(out, 3, 1, 0, 0, 1, 0, 0, 0);
69
+ expect(out[3]).toBeCloseTo(1); // red at t=0
70
+ expect(out[4]).toBeCloseTo(0);
71
+ expect(out[5]).toBeCloseTo(0);
72
+ });
73
+ });
74
+
75
+ describe('lerpHsvInPlace', () => {
76
+ it('interpolates RGB values stored in birth/death arrays via HSV at the given offset', () => {
77
+ const colorsOut = [0, 0, 0];
78
+ const birth = new Float32Array([1, 0, 0]); // red
79
+ const death = new Float32Array([0, 1, 0]); // green
80
+ lerpHsvInPlace(colorsOut, 0, birth, death, 0.5);
81
+ // Red (hue=0) → green (hue=1/3) at t=0.5 → yellow (hue≈1/6)
82
+ expect(colorsOut[0]).toBeGreaterThan(0.5); // R dominant
83
+ expect(colorsOut[1]).toBeGreaterThan(0.5); // G dominant
84
+ expect(colorsOut[2]).toBeCloseTo(0, 2); // no blue
85
+ });
86
+
87
+ it('returns birth color at t=0 and death color at t=1', () => {
88
+ const birth = new Float32Array([0.2, 0.4, 0.6]);
89
+ const death = new Float32Array([0.6, 0.4, 0.2]);
90
+ const out0 = [0, 0, 0];
91
+ lerpHsvInPlace(out0, 0, birth, death, 0);
92
+ expect(out0[0]).toBeCloseTo(0.2, 3);
93
+ expect(out0[1]).toBeCloseTo(0.4, 3);
94
+ expect(out0[2]).toBeCloseTo(0.6, 3);
95
+ const out1 = [0, 0, 0];
96
+ lerpHsvInPlace(out1, 0, birth, death, 1);
97
+ expect(out1[0]).toBeCloseTo(0.6, 3);
98
+ expect(out1[1]).toBeCloseTo(0.4, 3);
99
+ expect(out1[2]).toBeCloseTo(0.2, 3);
100
+ });
101
+
102
+ it('reads and writes at the specified offset', () => {
103
+ const birth = new Float32Array([9, 9, 9, 1, 0, 0]); // offset 3 = red
104
+ const death = new Float32Array([9, 9, 9, 0, 1, 0]); // offset 3 = green
105
+ const out = [9, 9, 9, 0, 0, 0];
106
+ lerpHsvInPlace(out, 3, birth, death, 0);
107
+ expect(out[0]).toBe(9); // slots before offset unchanged
108
+ expect(out[3]).toBeCloseTo(1); // red at t=0
109
+ expect(out[4]).toBeCloseTo(0);
110
+ expect(out[5]).toBeCloseTo(0);
111
+ });
112
+ });
113
+
114
+ describe('lifetime curves in updateParticleEmitter', () => {
115
+ function spawnOne(configOverrides: Parameters<typeof createParticleEmitterConfig>[0]) {
116
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
117
+ const state = createParticleEmitterState();
118
+ const config = createParticleEmitterConfig({
119
+ spawnRate: 1,
120
+ lifetimeMin: 1,
121
+ lifetimeMax: 1,
122
+ speedMin: 0,
123
+ speedMax: 0,
124
+ ...configOverrides,
125
+ });
126
+ updateParticleEmitter(emitter, state, config, 1); // spawn one particle
127
+ return { emitter, state, config };
128
+ }
129
+
130
+ it('alphaCurve drives alpha non-linearly over lifetime', () => {
131
+ // A curve that is 1 at birth, dips to 0 at mid-life, back to 1 at death.
132
+ const { emitter, state, config } = spawnOne({ alphaCurve: [1, 0, 1] });
133
+ updateParticleEmitter(emitter, state, config, 0.5); // → mid-life
134
+ expect(emitter.data.alphas[0]).toBeCloseTo(0, 2);
135
+ });
136
+
137
+ it('colorCurve overrides the start/end gradient', () => {
138
+ // green at birth, red at death (interleaved RGB).
139
+ const { emitter, state, config } = spawnOne({
140
+ colorCurve: [0, 1, 0, 1, 0, 0],
141
+ colorStartR: 0.123, // would be used by the linear path; curve must win
142
+ });
143
+ updateParticleEmitter(emitter, state, config, 0.5);
144
+ expect(emitter.data.colors[0]).toBeCloseTo(0.5); // R halfway
145
+ expect(emitter.data.colors[1]).toBeCloseTo(0.5); // G halfway
146
+ });
147
+
148
+ it('scaleCurve multiplies the spawn scale', () => {
149
+ const { emitter, state, config } = spawnOne({
150
+ scaleMin: 2,
151
+ scaleMax: 2,
152
+ scaleCurve: [1, 0.5, 0], // shrink to nothing
153
+ });
154
+ updateParticleEmitter(emitter, state, config, 0.5); // mid-life → factor 0.5
155
+ expect(emitter.data.transforms[3]).toBeCloseTo(1, 2); // 2 * 0.5
156
+ });
157
+
158
+ it('sets curve-driven values at spawn time (t=0)', () => {
159
+ const { emitter } = spawnOne({ alphaCurve: [0.25, 1] });
160
+ expect(emitter.data.alphas[0]).toBeCloseTo(0.25, 2);
161
+ });
162
+ });
163
+
164
+ describe('particleColorCurveFromKeyframes', () => {
165
+ it('bakes an RGB timeline through its middle stop', () => {
166
+ const lut = particleColorCurveFromKeyframes([
167
+ { time: 0, r: 1, g: 0, b: 0 },
168
+ { time: 0.5, r: 0, g: 1, b: 0 },
169
+ { time: 1, r: 0, g: 0, b: 1 },
170
+ ]);
171
+ const out = [0, 0, 0];
172
+ sampleParticleColorCurve(out, 0, lut, 0.5);
173
+ expect(out[1]).toBeGreaterThan(0.8); // green at mid
174
+ });
175
+
176
+ it('an empty keyframe list yields a flat transparent curve', () => {
177
+ const lut = particleColorCurveFromKeyframes([]);
178
+ const out = [9, 9, 9];
179
+ sampleParticleColorCurve(out, 0, lut, 0.5);
180
+ expect(out).toEqual([0, 0, 0]);
181
+ });
182
+ });
183
+
184
+ describe('particleColorCurveToKeyframes', () => {
185
+ it('emits one RGB keyframe per LUT sample at uniform times', () => {
186
+ const keys = particleColorCurveToKeyframes([1, 0, 0, 0, 0, 1]); // red → blue
187
+ expect(keys).toEqual([
188
+ { time: 0, r: 1, g: 0, b: 0 },
189
+ { time: 1, r: 0, g: 0, b: 1 },
190
+ ]);
191
+ });
192
+
193
+ it('round-trips a baked color curve back to keyframes', () => {
194
+ const lut = particleColorCurveFromKeyframes([
195
+ { time: 0, r: 1, g: 0, b: 0 },
196
+ { time: 1, r: 0, g: 0, b: 1 },
197
+ ]);
198
+ const keys = particleColorCurveToKeyframes(lut);
199
+ expect(keys[0]).toEqual({ time: 0, r: 1, g: 0, b: 0 });
200
+ expect(keys[keys.length - 1]).toEqual({ time: 1, r: 0, g: 0, b: 1 });
201
+ });
202
+
203
+ it('handles empty and single-entry LUTs', () => {
204
+ expect(particleColorCurveToKeyframes([])).toEqual([]);
205
+ expect(particleColorCurveToKeyframes([0.2, 0.4, 0.6])).toEqual([{ time: 0, r: 0.2, g: 0.4, b: 0.6 }]);
206
+ });
207
+ });
208
+
209
+ describe('particleCurveFromKeyframes', () => {
210
+ it('interpolates a 3-stop scalar timeline through its middle stop', () => {
211
+ const lut = particleCurveFromKeyframes([
212
+ { time: 0, value: 0 },
213
+ { time: 0.5, value: 1 },
214
+ { time: 1, value: 0 },
215
+ ]);
216
+ expect(sampleParticleCurve(lut, 0)).toBeCloseTo(0, 2);
217
+ expect(sampleParticleCurve(lut, 0.5)).toBeGreaterThan(0.9);
218
+ expect(sampleParticleCurve(lut, 1)).toBeCloseTo(0, 2);
219
+ });
220
+
221
+ it('sorts unsorted keyframes by time', () => {
222
+ const lut = particleCurveFromKeyframes([
223
+ { time: 1, value: 10 },
224
+ { time: 0, value: 0 },
225
+ ]);
226
+ expect(sampleParticleCurve(lut, 0)).toBeCloseTo(0, 1);
227
+ expect(sampleParticleCurve(lut, 1)).toBeCloseTo(10, 1);
228
+ });
229
+
230
+ it('handles non-normalized key times by clamping at the ends', () => {
231
+ const lut = particleCurveFromKeyframes([
232
+ { time: 0.25, value: 2 },
233
+ { time: 0.75, value: 6 },
234
+ ]);
235
+ expect(sampleParticleCurve(lut, 0)).toBeCloseTo(2, 1); // below first key → first value
236
+ expect(sampleParticleCurve(lut, 1)).toBeCloseTo(6, 1); // above last key → last value
237
+ expect(sampleParticleCurve(lut, 0.5)).toBeCloseTo(4, 1); // midway between keys
238
+ });
239
+
240
+ it('a single keyframe yields a constant curve', () => {
241
+ const lut = particleCurveFromKeyframes([{ time: 0.5, value: 3 }]);
242
+ expect(sampleParticleCurve(lut, 0)).toBeCloseTo(3);
243
+ expect(sampleParticleCurve(lut, 1)).toBeCloseTo(3);
244
+ });
245
+
246
+ it('an empty keyframe list yields a flat zero curve', () => {
247
+ const lut = particleCurveFromKeyframes([]);
248
+ expect(sampleParticleCurve(lut, 0)).toBe(0);
249
+ expect(sampleParticleCurve(lut, 1)).toBe(0);
250
+ });
251
+ });
252
+
253
+ describe('particleCurveToKeyframes', () => {
254
+ it('emits one keyframe per LUT sample at uniform times', () => {
255
+ const keys = particleCurveToKeyframes([0, 0.5, 1]);
256
+ expect(keys).toEqual([
257
+ { time: 0, value: 0 },
258
+ { time: 0.5, value: 0.5 },
259
+ { time: 1, value: 1 },
260
+ ]);
261
+ });
262
+
263
+ it('round-trips a baked scalar curve back to keyframes', () => {
264
+ const lut = particleCurveFromKeyframes([
265
+ { time: 0, value: 0 },
266
+ { time: 1, value: 10 },
267
+ ]);
268
+ const keys = particleCurveToKeyframes(lut);
269
+ expect(keys[0]).toEqual({ time: 0, value: 0 });
270
+ expect(keys[keys.length - 1]).toEqual({ time: 1, value: 10 });
271
+ // Reconstructing from the keyframes reproduces the original samples.
272
+ expect(sampleParticleCurve(particleCurveFromKeyframes(keys, lut.length), 0.5)).toBeCloseTo(
273
+ sampleParticleCurve(lut, 0.5),
274
+ 5,
275
+ );
276
+ });
277
+
278
+ it('handles empty and single-entry LUTs', () => {
279
+ expect(particleCurveToKeyframes([])).toEqual([]);
280
+ expect(particleCurveToKeyframes([7])).toEqual([{ time: 0, value: 7 }]);
281
+ });
282
+ });
283
+
284
+ describe('sampleParticleColorCurve', () => {
285
+ it('interpolates interleaved RGB channels', () => {
286
+ const lut = [1, 0, 0, 0, 0, 1]; // red → blue
287
+ const out = [0, 0, 0];
288
+ sampleParticleColorCurve(out, 0, lut, 0.5);
289
+ expect(out[0]).toBeCloseTo(0.5); // R
290
+ expect(out[1]).toBeCloseTo(0); // G
291
+ expect(out[2]).toBeCloseTo(0.5); // B
292
+ });
293
+
294
+ it('writes at the given offset', () => {
295
+ const lut = [0.2, 0.4, 0.6];
296
+ const out = [9, 9, 9, 9, 9];
297
+ sampleParticleColorCurve(out, 2, lut, 0);
298
+ expect(out[2]).toBeCloseTo(0.2);
299
+ expect(out[3]).toBeCloseTo(0.4);
300
+ expect(out[4]).toBeCloseTo(0.6);
301
+ });
302
+ });
303
+
304
+ describe('sampleParticleCurve', () => {
305
+ it('returns endpoints at t=0 and t=1', () => {
306
+ const lut = [0, 0.5, 1];
307
+ expect(sampleParticleCurve(lut, 0)).toBe(0);
308
+ expect(sampleParticleCurve(lut, 1)).toBe(1);
309
+ });
310
+
311
+ it('linearly interpolates between samples', () => {
312
+ const lut = [0, 1]; // straight line 0→1
313
+ expect(sampleParticleCurve(lut, 0.25)).toBeCloseTo(0.25);
314
+ expect(sampleParticleCurve(lut, 0.5)).toBeCloseTo(0.5);
315
+ });
316
+
317
+ it('clamps out-of-range t', () => {
318
+ const lut = [2, 4];
319
+ expect(sampleParticleCurve(lut, -1)).toBe(2);
320
+ expect(sampleParticleCurve(lut, 5)).toBe(4);
321
+ });
322
+
323
+ it('handles empty and single-entry curves', () => {
324
+ expect(sampleParticleCurve([], 0.5)).toBe(0);
325
+ expect(sampleParticleCurve([7], 0.5)).toBe(7);
326
+ });
327
+ });
@@ -0,0 +1,54 @@
1
+ import { createRandomSource } from '@flighthq/math';
2
+ import { createParticleEmitter } from '@flighthq/sprite';
3
+ import type { TextureAtlas } from '@flighthq/types';
4
+
5
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
6
+ import { createParticleEmitterState } from './particleEmitterState';
7
+ import { stepParticleEmitter } from './stepParticleEmitter';
8
+
9
+ function makeAtlas(): TextureAtlas {
10
+ return {
11
+ image: null,
12
+ regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
13
+ } as unknown as TextureAtlas;
14
+ }
15
+
16
+ describe('deterministic replay', () => {
17
+ it('produces byte-identical SoA buffers from two identically-seeded emitters', () => {
18
+ const atlas = makeAtlas();
19
+ const config = createParticleEmitterConfig({
20
+ spawnRate: 20,
21
+ maxParticles: 100,
22
+ lifetimeMin: 0.5,
23
+ lifetimeMax: 2,
24
+ speedMin: 50,
25
+ speedMax: 150,
26
+ spread: Math.PI,
27
+ gravityY: 100,
28
+ });
29
+
30
+ const emitterA = createParticleEmitter({ data: { atlas } });
31
+ const stateA = createParticleEmitterState(createRandomSource(42));
32
+
33
+ const emitterB = createParticleEmitter({ data: { atlas } });
34
+ const stateB = createParticleEmitterState(createRandomSource(42));
35
+
36
+ const steps = [0.016, 0.033, 0.016, 0.05, 0.016, 0.016, 0.033, 0.016];
37
+ for (const dt of steps) {
38
+ stepParticleEmitter(emitterA, stateA, config, dt);
39
+ stepParticleEmitter(emitterB, stateB, config, dt);
40
+ }
41
+
42
+ expect(emitterA.data.particleCount).toBeGreaterThan(0);
43
+ expect(emitterA.data.particleCount).toBe(emitterB.data.particleCount);
44
+
45
+ const count = emitterA.data.particleCount;
46
+ expect(emitterA.data.transforms.subarray(0, count * 4)).toEqual(emitterB.data.transforms.subarray(0, count * 4));
47
+ expect(emitterA.data.alphas.subarray(0, count)).toEqual(emitterB.data.alphas.subarray(0, count));
48
+ expect(emitterA.data.colors.subarray(0, count * 3)).toEqual(emitterB.data.colors.subarray(0, count * 3));
49
+ expect(emitterA.data.velocities.subarray(0, count * 2)).toEqual(emitterB.data.velocities.subarray(0, count * 2));
50
+ expect(stateA.lifetimes.subarray(0, count * 2)).toEqual(stateB.lifetimes.subarray(0, count * 2));
51
+ expect(stateA.velocities.subarray(0, count * 2)).toEqual(stateB.velocities.subarray(0, count * 2));
52
+ expect(stateA.scales.subarray(0, count)).toEqual(stateB.scales.subarray(0, count));
53
+ });
54
+ });
@@ -0,0 +1,103 @@
1
+ import { createRandomSource } from '@flighthq/math';
2
+ import { createParticleEmitter } from '@flighthq/sprite';
3
+ import type { TextureAtlas } from '@flighthq/types';
4
+
5
+ import { emitParticleBurst } from './emitParticleBurst';
6
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
7
+ import { createParticleEmitterState } from './particleEmitterState';
8
+ import { updateParticleEmitter } from './updateParticleEmitter';
9
+
10
+ function makeAtlas(): TextureAtlas {
11
+ return {
12
+ image: null,
13
+ regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
14
+ } as TextureAtlas;
15
+ }
16
+
17
+ describe('emitParticleBurst', () => {
18
+ it('spawns the requested count at the given point', () => {
19
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
20
+ const state = createParticleEmitterState();
21
+ const config = createParticleEmitterConfig({ speedMin: 0, speedMax: 0, lifetimeMin: 10, lifetimeMax: 10 });
22
+ const n = emitParticleBurst(emitter, state, config, 8, 200, 300);
23
+ expect(n).toBe(8);
24
+ expect(emitter.data.particleCount).toBe(8);
25
+ // With zero speed and a point emitter, every particle sits at the burst point.
26
+ for (let i = 0; i < 8; i++) {
27
+ expect(emitter.data.transforms[i * 4]).toBeCloseTo(200);
28
+ expect(emitter.data.transforms[i * 4 + 1]).toBeCloseTo(300);
29
+ }
30
+ });
31
+
32
+ it('respects maxParticles and returns the actual spawn count', () => {
33
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
34
+ const state = createParticleEmitterState();
35
+ const config = createParticleEmitterConfig({ maxParticles: 5, lifetimeMin: 10, lifetimeMax: 10 });
36
+ expect(emitParticleBurst(emitter, state, config, 100, 0, 0)).toBe(5);
37
+ expect(emitter.data.particleCount).toBe(5);
38
+ });
39
+
40
+ it('adds to existing particles rather than replacing them', () => {
41
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
42
+ const state = createParticleEmitterState();
43
+ const config = createParticleEmitterConfig({ maxParticles: 100, lifetimeMin: 10, lifetimeMax: 10 });
44
+ emitParticleBurst(emitter, state, config, 3, 0, 0);
45
+ emitParticleBurst(emitter, state, config, 4, 0, 0);
46
+ expect(emitter.data.particleCount).toBe(7);
47
+ });
48
+
49
+ it("does not touch the emitter's continuous-emission bookkeeping", () => {
50
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
51
+ const state = createParticleEmitterState();
52
+ const config = createParticleEmitterConfig({ lifetimeMin: 10, lifetimeMax: 10 });
53
+ emitParticleBurst(emitter, state, config, 3, 0, 0);
54
+ expect(state.spawnAccumulator).toBe(0);
55
+ expect(state.emitterAge).toBe(0);
56
+ });
57
+
58
+ it('is deterministic with a seeded state', () => {
59
+ const run = (): number[] => {
60
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
61
+ const state = createParticleEmitterState(createRandomSource(99));
62
+ const config = createParticleEmitterConfig({
63
+ speedMin: 10,
64
+ speedMax: 200,
65
+ spread: Math.PI,
66
+ lifetimeMin: 1,
67
+ lifetimeMax: 5,
68
+ });
69
+ emitParticleBurst(emitter, state, config, 10, 0, 0);
70
+ return Array.from(state.velocities.slice(0, 20));
71
+ };
72
+ expect(run()).toEqual(run());
73
+ });
74
+
75
+ it('composes into a death sub-emitter via onDeath (explosion on expiry)', () => {
76
+ // Parent: short-lived particles. Child: a burst spawned where each parent dies.
77
+ const parent = createParticleEmitter({ data: { atlas: makeAtlas() } });
78
+ const parentState = createParticleEmitterState();
79
+ const parentConfig = createParticleEmitterConfig({
80
+ spawnRate: 5,
81
+ lifetimeMin: 0.5,
82
+ lifetimeMax: 0.5,
83
+ speedMin: 0,
84
+ speedMax: 0,
85
+ });
86
+
87
+ const child = createParticleEmitter({ data: { atlas: makeAtlas() } });
88
+ const childState = createParticleEmitterState();
89
+ const childConfig = createParticleEmitterConfig({ maxParticles: 1000, lifetimeMin: 10, lifetimeMax: 10 });
90
+
91
+ let deaths = 0;
92
+ const onDeath = (x: number, y: number): void => {
93
+ deaths++;
94
+ emitParticleBurst(child, childState, childConfig, 6, x, y);
95
+ };
96
+
97
+ // Run long enough for the parent particles to spawn and then expire.
98
+ for (let i = 0; i < 30; i++) updateParticleEmitter(parent, parentState, parentConfig, 1 / 30, { onDeath });
99
+
100
+ expect(deaths).toBeGreaterThan(0);
101
+ expect(child.data.particleCount).toBe(deaths * 6); // 6 child particles per parent death
102
+ });
103
+ });
@@ -0,0 +1,65 @@
1
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
2
+
3
+ describe('createParticleEmitterConfig', () => {
4
+ it('returns defaults when called with no arguments', () => {
5
+ const config = createParticleEmitterConfig();
6
+ expect(config.alphaEnd).toBe(0);
7
+ expect(config.alphaStart).toBe(1);
8
+ expect(config.burstCount).toBe(0);
9
+ expect(config.burstInterval).toBe(0);
10
+ expect(config.duration).toBe(0);
11
+ expect(config.loop).toBe(true);
12
+ expect(config.colorEndR).toBe(1);
13
+ expect(config.colorEndG).toBe(1);
14
+ expect(config.colorEndB).toBe(1);
15
+ expect(config.colorStartR).toBe(1);
16
+ expect(config.colorStartG).toBe(1);
17
+ expect(config.colorStartB).toBe(1);
18
+ expect(config.directionX).toBe(0);
19
+ expect(config.directionY).toBe(-1);
20
+ expect(config.emitterShape).toBe('point');
21
+ expect(config.emitterRadius).toBe(0);
22
+ expect(config.emitterWidth).toBe(0);
23
+ expect(config.emitterHeight).toBe(0);
24
+ expect(config.frameCount).toBe(1);
25
+ expect(config.frameRate).toBe(12);
26
+ expect(config.gravityX).toBe(0);
27
+ expect(config.gravityY).toBe(0);
28
+ expect(config.lifetimeMax).toBe(1);
29
+ expect(config.lifetimeMin).toBe(0.5);
30
+ expect(config.maxParticles).toBe(1000);
31
+ expect(config.regionIdMax).toBe(1);
32
+ expect(config.regionIdMin).toBe(0);
33
+ expect(config.rotationSpeedMin).toBe(0);
34
+ expect(config.rotationSpeedMax).toBe(0);
35
+ expect(config.scaleEnd).toBe(1);
36
+ expect(config.velocityInheritance).toBe(0);
37
+ expect(config.worldSpace).toBe(false);
38
+ expect(config.blendMode).toBeNull();
39
+ expect(config.colorStartVarianceR).toBe(0);
40
+ expect(config.colorStartVarianceG).toBe(0);
41
+ expect(config.colorStartVarianceB).toBe(0);
42
+ expect(config.colorEndVarianceR).toBe(0);
43
+ expect(config.colorEndVarianceG).toBe(0);
44
+ expect(config.colorEndVarianceB).toBe(0);
45
+ expect(config.scaleMax).toBe(1);
46
+ expect(config.scaleMin).toBe(1);
47
+ expect(config.speedMax).toBe(100);
48
+ expect(config.speedMin).toBe(50);
49
+ expect(config.spawnRate).toBe(10);
50
+ expect(config.spread).toBe(Math.PI);
51
+ });
52
+
53
+ it('overrides individual fields', () => {
54
+ const config = createParticleEmitterConfig({ spawnRate: 60, maxParticles: 500 });
55
+ expect(config.spawnRate).toBe(60);
56
+ expect(config.maxParticles).toBe(500);
57
+ expect(config.alphaEnd).toBe(0); // other fields still default
58
+ });
59
+
60
+ it('returns a new object each call', () => {
61
+ const a = createParticleEmitterConfig();
62
+ const b = createParticleEmitterConfig();
63
+ expect(a).not.toBe(b);
64
+ });
65
+ });
@@ -0,0 +1,125 @@
1
+ import { createParticleEmitter } from '@flighthq/sprite';
2
+ import type { TextureAtlas } from '@flighthq/types';
3
+
4
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
5
+ import {
6
+ createParticleEmitterSignals,
7
+ enableParticleEmitterSignals,
8
+ getParticleEmitterSignals,
9
+ } from './particleEmitterSignals';
10
+ import { createParticleEmitterState } from './particleEmitterState';
11
+ import { updateParticleEmitter } from './updateParticleEmitter';
12
+
13
+ function makeAtlas(): TextureAtlas {
14
+ return {
15
+ image: null,
16
+ regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
17
+ } as unknown as TextureAtlas;
18
+ }
19
+
20
+ function spawnOne(configOverrides: Record<string, unknown> = {}) {
21
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
22
+ const state = createParticleEmitterState();
23
+ const config = createParticleEmitterConfig({
24
+ spawnRate: 1,
25
+ lifetimeMin: 1,
26
+ lifetimeMax: 1,
27
+ speedMin: 0,
28
+ speedMax: 0,
29
+ ...configOverrides,
30
+ });
31
+ return { emitter, state, config };
32
+ }
33
+
34
+ describe('createParticleEmitterSignals', () => {
35
+ it('creates a signals group with all three signal slots', () => {
36
+ const signals = createParticleEmitterSignals();
37
+ expect(signals.onParticleSpawn).toBeDefined();
38
+ expect(signals.onParticleDeath).toBeDefined();
39
+ expect(signals.onEmitterComplete).toBeDefined();
40
+ });
41
+ });
42
+
43
+ describe('enableParticleEmitterSignals', () => {
44
+ it('returns the same object on repeated calls', () => {
45
+ const state = createParticleEmitterState();
46
+ const a = enableParticleEmitterSignals(state);
47
+ const b = enableParticleEmitterSignals(state);
48
+ expect(a).toBe(b);
49
+ });
50
+
51
+ it('returns null from getParticleEmitterSignals before enablement', () => {
52
+ const state = createParticleEmitterState();
53
+ expect(getParticleEmitterSignals(state)).toBeNull();
54
+ });
55
+
56
+ it('returns non-null from getParticleEmitterSignals after enablement', () => {
57
+ const state = createParticleEmitterState();
58
+ enableParticleEmitterSignals(state);
59
+ expect(getParticleEmitterSignals(state)).not.toBeNull();
60
+ });
61
+ });
62
+
63
+ describe('getParticleEmitterSignals', () => {
64
+ it('returns null when signals have not been enabled', () => {
65
+ const state = createParticleEmitterState();
66
+ expect(getParticleEmitterSignals(state)).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe('onEmitterComplete signal', () => {
71
+ it('fires once when a finite non-looping emitter completes', () => {
72
+ const { emitter, state } = spawnOne({ duration: 0.5, loop: false });
73
+ const config = createParticleEmitterConfig({
74
+ duration: 0.5,
75
+ loop: false,
76
+ spawnRate: 1,
77
+ lifetimeMin: 0.1,
78
+ lifetimeMax: 0.1,
79
+ speedMin: 0,
80
+ speedMax: 0,
81
+ });
82
+ const signals = enableParticleEmitterSignals(state);
83
+ const completes: number[] = [];
84
+ signals.onEmitterComplete.emit = () => completes.push(1);
85
+ updateParticleEmitter(emitter, state, config, 1); // spawn + age past duration
86
+ updateParticleEmitter(emitter, state, config, 1); // particles die → complete
87
+ expect(completes.length).toBeGreaterThan(0);
88
+ });
89
+
90
+ it('does not fire for infinite emitters', () => {
91
+ const { emitter, state, config } = spawnOne({ duration: 0, loop: true });
92
+ const signals = enableParticleEmitterSignals(state);
93
+ const completes: number[] = [];
94
+ signals.onEmitterComplete.emit = () => completes.push(1);
95
+ for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 1);
96
+ expect(completes.length).toBe(0);
97
+ });
98
+ });
99
+
100
+ describe('onParticleDeath signal', () => {
101
+ it('fires with the particle position when a particle dies', () => {
102
+ const { emitter, state, config } = spawnOne();
103
+ const signals = enableParticleEmitterSignals(state);
104
+ const deaths: Array<[number, number]> = [];
105
+ signals.onParticleDeath.emit = (x: number, y: number) => deaths.push([x, y]);
106
+ updateParticleEmitter(emitter, state, config, 1); // spawn
107
+ updateParticleEmitter(emitter, state, config, 1.1); // age past lifetime → death
108
+ expect(deaths.length).toBe(1);
109
+ expect(Number.isFinite(deaths[0][0])).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe('onParticleSpawn signal', () => {
114
+ it('fires with spawn position and velocity when a particle is spawned', () => {
115
+ const { emitter, state, config } = spawnOne({ speedMin: 50, speedMax: 50 });
116
+ const signals = enableParticleEmitterSignals(state);
117
+ const spawns: Array<[number, number, number, number]> = [];
118
+ signals.onParticleSpawn.emit = (x: number, y: number, vx: number, vy: number) => spawns.push([x, y, vx, vy]);
119
+ updateParticleEmitter(emitter, state, config, 1);
120
+ expect(spawns.length).toBe(1);
121
+ const [, , vx, vy] = spawns[0];
122
+ // Speed must match config (allow sign variations by checking magnitude).
123
+ expect(Math.sqrt(vx * vx + vy * vy)).toBeCloseTo(50, 0);
124
+ });
125
+ });