@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,631 @@
1
+ import { createRandomSource } from '@flighthq/math';
2
+ import { createParticleEmitter, reserveParticleEmitter } from '@flighthq/sprite';
3
+ import type { TextureAtlas } from '@flighthq/types';
4
+
5
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
6
+ import { createParticleEmitterState } from './particleEmitterState';
7
+ import { prewarmParticleEmitter } from './prewarmParticleEmitter';
8
+ import { isParticleEmitterComplete, 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('isParticleEmitterComplete', () => {
18
+ it('isParticleEmitterComplete is true once a one-shot emitter has finished and all particles died', () => {
19
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
20
+ const state = createParticleEmitterState();
21
+ const config = createParticleEmitterConfig({
22
+ spawnRate: 10,
23
+ maxParticles: 1000,
24
+ lifetimeMin: 0.5,
25
+ lifetimeMax: 0.5,
26
+ duration: 1,
27
+ loop: false,
28
+ });
29
+ expect(isParticleEmitterComplete(emitter, state, config)).toBe(false);
30
+ // Emit for 1s.
31
+ for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 0.1);
32
+ expect(emitter.data.particleCount).toBeGreaterThan(0);
33
+ expect(isParticleEmitterComplete(emitter, state, config)).toBe(false); // particles still alive
34
+ // Let every particle die out (lifetime 0.5s).
35
+ for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 0.1);
36
+ expect(emitter.data.particleCount).toBe(0);
37
+ expect(isParticleEmitterComplete(emitter, state, config)).toBe(true);
38
+ });
39
+
40
+ it('isParticleEmitterComplete is always false for infinite or looping emitters', () => {
41
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
42
+ const state = createParticleEmitterState();
43
+ const infinite = createParticleEmitterConfig({ spawnRate: 0, duration: 0 });
44
+ expect(isParticleEmitterComplete(emitter, state, infinite)).toBe(false);
45
+ const looping = createParticleEmitterConfig({ spawnRate: 0, duration: 1, loop: true });
46
+ expect(isParticleEmitterComplete(emitter, state, looping)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('updateParticleEmitter', () => {
51
+ it('spawns particles up to spawnRate × deltaTime', () => {
52
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
53
+ const state = createParticleEmitterState();
54
+ const config = createParticleEmitterConfig({ spawnRate: 10, maxParticles: 100 });
55
+ updateParticleEmitter(emitter, state, config, 1);
56
+ expect(emitter.data.particleCount).toBe(10);
57
+ });
58
+
59
+ it('respects maxParticles limit', () => {
60
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
61
+ const state = createParticleEmitterState();
62
+ const config = createParticleEmitterConfig({ spawnRate: 100, maxParticles: 5 });
63
+ updateParticleEmitter(emitter, state, config, 1);
64
+ expect(emitter.data.particleCount).toBe(5);
65
+ });
66
+
67
+ it('ages particles over time', () => {
68
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
69
+ const state = createParticleEmitterState();
70
+ const config = createParticleEmitterConfig({
71
+ spawnRate: 1,
72
+ lifetimeMin: 10,
73
+ lifetimeMax: 10,
74
+ });
75
+ updateParticleEmitter(emitter, state, config, 1); // spawn 1 particle (spawnRate*deltaTime=1)
76
+ expect(emitter.data.particleCount).toBe(1);
77
+ updateParticleEmitter(emitter, state, config, 0.1); // age by 0.1
78
+ expect(state.lifetimes[0]).toBeCloseTo(0.1);
79
+ });
80
+
81
+ it('removes particles when lifetime expires', () => {
82
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
83
+ const state = createParticleEmitterState();
84
+ const config = createParticleEmitterConfig({
85
+ spawnRate: 1,
86
+ lifetimeMin: 0.5,
87
+ lifetimeMax: 0.5,
88
+ });
89
+ // Spawn one particle
90
+ updateParticleEmitter(emitter, state, config, 1);
91
+ expect(emitter.data.particleCount).toBe(1);
92
+ // Advance past lifetime
93
+ updateParticleEmitter(emitter, state, config, 0.6);
94
+ expect(emitter.data.particleCount).toBe(0);
95
+ });
96
+
97
+ it('integrates velocity with gravity', () => {
98
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
99
+ const state = createParticleEmitterState();
100
+ const config = createParticleEmitterConfig({
101
+ spawnRate: 1,
102
+ lifetimeMin: 10,
103
+ lifetimeMax: 10,
104
+ speedMin: 0,
105
+ speedMax: 0,
106
+ gravityX: 100,
107
+ gravityY: 0,
108
+ spread: 0,
109
+ directionX: 0,
110
+ directionY: -1,
111
+ });
112
+ updateParticleEmitter(emitter, state, config, 1);
113
+ // After spawn, position is (0,0). After next update with gravity...
114
+ updateParticleEmitter(emitter, state, config, 1);
115
+ // vx = 100*1 = 100, x += 100*1 = 100 (gravity applied to velocity then integrated)
116
+ const x = emitter.data.transforms[0];
117
+ expect(x).toBeGreaterThan(0);
118
+ });
119
+
120
+ it('interpolates alpha from alphaStart to alphaEnd over lifetime', () => {
121
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
122
+ const state = createParticleEmitterState();
123
+ const config = createParticleEmitterConfig({
124
+ spawnRate: 1,
125
+ lifetimeMin: 1,
126
+ lifetimeMax: 1,
127
+ alphaStart: 1,
128
+ alphaEnd: 0,
129
+ speedMin: 0,
130
+ speedMax: 0,
131
+ });
132
+ updateParticleEmitter(emitter, state, config, 1); // spawn 1 particle
133
+ updateParticleEmitter(emitter, state, config, 0.5); // advance to half lifetime
134
+ expect(emitter.data.alphas[0]).toBeCloseTo(0.5, 1);
135
+ });
136
+
137
+ it('accumulates fractional spawn debt across frames', () => {
138
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
139
+ const state = createParticleEmitterState();
140
+ const config = createParticleEmitterConfig({
141
+ spawnRate: 10,
142
+ lifetimeMin: 10,
143
+ lifetimeMax: 10,
144
+ maxParticles: 100,
145
+ });
146
+ // 10 frames of deltaTime=0.05 → total deltaTime=0.5 → 5 particles
147
+ for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 0.05);
148
+ expect(emitter.data.particleCount).toBe(5);
149
+ });
150
+
151
+ it('grows emitter arrays as needed', () => {
152
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
153
+ const state = createParticleEmitterState();
154
+ const config = createParticleEmitterConfig({
155
+ spawnRate: 100,
156
+ maxParticles: 50,
157
+ lifetimeMin: 10,
158
+ lifetimeMax: 10,
159
+ });
160
+ updateParticleEmitter(emitter, state, config, 1);
161
+ expect(emitter.data.particleCount).toBe(50);
162
+ expect(emitter.data.transforms.length).toBeGreaterThanOrEqual(50 * 4);
163
+ });
164
+
165
+ it('pre-reserved emitters do not allocate during update', () => {
166
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
167
+ reserveParticleEmitter(emitter, 100);
168
+ const state = createParticleEmitterState();
169
+ const config = createParticleEmitterConfig({ spawnRate: 10, maxParticles: 100 });
170
+ const { transforms } = emitter.data;
171
+ updateParticleEmitter(emitter, state, config, 1);
172
+ expect(emitter.data.transforms).toBe(transforms);
173
+ });
174
+
175
+ it('interpolates color from colorStart to colorEnd over lifetime', () => {
176
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
177
+ const state = createParticleEmitterState();
178
+ const config = createParticleEmitterConfig({
179
+ spawnRate: 1,
180
+ lifetimeMin: 1,
181
+ lifetimeMax: 1,
182
+ speedMin: 0,
183
+ speedMax: 0,
184
+ colorStartR: 1,
185
+ colorStartG: 0,
186
+ colorStartB: 0,
187
+ colorEndR: 0,
188
+ colorEndG: 0,
189
+ colorEndB: 1,
190
+ });
191
+ updateParticleEmitter(emitter, state, config, 1); // spawn
192
+ updateParticleEmitter(emitter, state, config, 0.5); // half-life
193
+ expect(emitter.data.colors[0]).toBeCloseTo(0.5, 1); // R
194
+ expect(emitter.data.colors[1]).toBeCloseTo(0, 1); // G
195
+ expect(emitter.data.colors[2]).toBeCloseTo(0.5, 1); // B
196
+ });
197
+
198
+ it('animates scale over lifetime when scaleEnd differs from 1', () => {
199
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
200
+ const state = createParticleEmitterState();
201
+ const config = createParticleEmitterConfig({
202
+ spawnRate: 1,
203
+ lifetimeMin: 1,
204
+ lifetimeMax: 1,
205
+ speedMin: 0,
206
+ speedMax: 0,
207
+ scaleMin: 2,
208
+ scaleMax: 2,
209
+ scaleEnd: 0,
210
+ });
211
+ updateParticleEmitter(emitter, state, config, 1); // spawn with scale=2
212
+ updateParticleEmitter(emitter, state, config, 0.5); // half-life → scale=1
213
+ expect(emitter.data.transforms[3]).toBeCloseTo(1, 1);
214
+ });
215
+
216
+ it('rotates particles at their individual rotation speed', () => {
217
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
218
+ const state = createParticleEmitterState();
219
+ const config = createParticleEmitterConfig({
220
+ spawnRate: 1,
221
+ lifetimeMin: 10,
222
+ lifetimeMax: 10,
223
+ speedMin: 0,
224
+ speedMax: 0,
225
+ spread: 0,
226
+ directionX: 0,
227
+ directionY: -1,
228
+ rotationSpeedMin: Math.PI,
229
+ rotationSpeedMax: Math.PI,
230
+ });
231
+ updateParticleEmitter(emitter, state, config, 1); // spawn
232
+ const rotBefore = emitter.data.transforms[2];
233
+ updateParticleEmitter(emitter, state, config, 1); // advance 1s at π rad/s
234
+ const rotAfter = emitter.data.transforms[2];
235
+ expect(rotAfter - rotBefore).toBeCloseTo(Math.PI, 3);
236
+ });
237
+
238
+ it('spawns particles within the circle radius', () => {
239
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
240
+ const state = createParticleEmitterState();
241
+ const config = createParticleEmitterConfig({
242
+ spawnRate: 50,
243
+ maxParticles: 50,
244
+ lifetimeMin: 10,
245
+ lifetimeMax: 10,
246
+ speedMin: 0,
247
+ speedMax: 0,
248
+ emitterShape: 'circle',
249
+ emitterRadius: 100,
250
+ });
251
+ updateParticleEmitter(emitter, state, config, 1);
252
+ for (let i = 0; i < emitter.data.particleCount; i++) {
253
+ const x = emitter.data.transforms[i * 4];
254
+ const y = emitter.data.transforms[i * 4 + 1];
255
+ expect(Math.sqrt(x * x + y * y)).toBeLessThanOrEqual(100 + 1e-4);
256
+ }
257
+ });
258
+
259
+ it('spawns particles within the rect bounds', () => {
260
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
261
+ const state = createParticleEmitterState();
262
+ const config = createParticleEmitterConfig({
263
+ spawnRate: 50,
264
+ maxParticles: 50,
265
+ lifetimeMin: 10,
266
+ lifetimeMax: 10,
267
+ speedMin: 0,
268
+ speedMax: 0,
269
+ emitterShape: 'rect',
270
+ emitterWidth: 200,
271
+ emitterHeight: 100,
272
+ });
273
+ updateParticleEmitter(emitter, state, config, 1);
274
+ for (let i = 0; i < emitter.data.particleCount; i++) {
275
+ const x = emitter.data.transforms[i * 4];
276
+ const y = emitter.data.transforms[i * 4 + 1];
277
+ expect(Math.abs(x)).toBeLessThanOrEqual(100 + 1e-4);
278
+ expect(Math.abs(y)).toBeLessThanOrEqual(50 + 1e-4);
279
+ }
280
+ });
281
+
282
+ it('fires a one-shot burst on the first frame', () => {
283
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
284
+ const state = createParticleEmitterState();
285
+ const config = createParticleEmitterConfig({
286
+ spawnRate: 0,
287
+ burstCount: 20,
288
+ burstInterval: 0,
289
+ maxParticles: 100,
290
+ lifetimeMin: 10,
291
+ lifetimeMax: 10,
292
+ });
293
+ updateParticleEmitter(emitter, state, config, 1 / 60);
294
+ expect(emitter.data.particleCount).toBe(20);
295
+ updateParticleEmitter(emitter, state, config, 1 / 60);
296
+ expect(emitter.data.particleCount).toBe(20); // no second burst
297
+ });
298
+
299
+ it('fires repeated bursts at the configured interval', () => {
300
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
301
+ const state = createParticleEmitterState();
302
+ const config = createParticleEmitterConfig({
303
+ spawnRate: 0,
304
+ burstCount: 5,
305
+ burstInterval: 1,
306
+ maxParticles: 100,
307
+ lifetimeMin: 10,
308
+ lifetimeMax: 10,
309
+ });
310
+ updateParticleEmitter(emitter, state, config, 0.01); // burst 1 fires (burstTimer=0)
311
+ expect(emitter.data.particleCount).toBe(5);
312
+ updateParticleEmitter(emitter, state, config, 1.0); // burst 2 fires after 1s
313
+ expect(emitter.data.particleCount).toBe(10);
314
+ });
315
+
316
+ it('advances flipbook frame based on age and frameRate', () => {
317
+ const atlas = {
318
+ image: null,
319
+ regions: [
320
+ { id: 0, x: 0, y: 0, width: 16, height: 16, pivotX: null, pivotY: null },
321
+ { id: 1, x: 16, y: 0, width: 16, height: 16, pivotX: null, pivotY: null },
322
+ { id: 2, x: 32, y: 0, width: 16, height: 16, pivotX: null, pivotY: null },
323
+ ],
324
+ } as TextureAtlas;
325
+ const emitter = createParticleEmitter({ data: { atlas } });
326
+ const state = createParticleEmitterState();
327
+ const config = createParticleEmitterConfig({
328
+ spawnRate: 1,
329
+ lifetimeMin: 10,
330
+ lifetimeMax: 10,
331
+ speedMin: 0,
332
+ speedMax: 0,
333
+ regionIdMin: 0,
334
+ frameCount: 3,
335
+ frameRate: 1,
336
+ });
337
+ updateParticleEmitter(emitter, state, config, 1); // spawn → frame 0
338
+ expect(emitter.data.ids[0]).toBe(0);
339
+ updateParticleEmitter(emitter, state, config, 1); // +1s → frame 1
340
+ expect(emitter.data.ids[0]).toBe(1);
341
+ updateParticleEmitter(emitter, state, config, 1); // +1s → frame 2
342
+ expect(emitter.data.ids[0]).toBe(2);
343
+ updateParticleEmitter(emitter, state, config, 1); // +1s → frame 0 (wraps)
344
+ expect(emitter.data.ids[0]).toBe(0);
345
+ });
346
+
347
+ it('fires onSpawn callback for each spawned particle', () => {
348
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
349
+ const state = createParticleEmitterState();
350
+ const config = createParticleEmitterConfig({ spawnRate: 3, lifetimeMin: 10, lifetimeMax: 10 });
351
+ let spawnCount = 0;
352
+ updateParticleEmitter(emitter, state, config, 1, {
353
+ onSpawn: () => {
354
+ spawnCount++;
355
+ },
356
+ });
357
+ expect(spawnCount).toBe(3);
358
+ });
359
+
360
+ it('fires onDeath callback with particle position when it expires', () => {
361
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
362
+ const state = createParticleEmitterState();
363
+ const config = createParticleEmitterConfig({
364
+ spawnRate: 1,
365
+ lifetimeMin: 0.5,
366
+ lifetimeMax: 0.5,
367
+ speedMin: 0,
368
+ speedMax: 0,
369
+ });
370
+ updateParticleEmitter(emitter, state, config, 1); // spawn
371
+ let deathFired = false;
372
+ updateParticleEmitter(emitter, state, config, 0.6, {
373
+ onDeath: () => {
374
+ deathFired = true;
375
+ },
376
+ });
377
+ expect(deathFired).toBe(true);
378
+ });
379
+
380
+ it('prewarm produces a non-empty particle state', () => {
381
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
382
+ const state = createParticleEmitterState();
383
+ const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 2, lifetimeMax: 2 });
384
+ prewarmParticleEmitter(emitter, state, config, 1);
385
+ expect(emitter.data.particleCount).toBeGreaterThan(0);
386
+ });
387
+
388
+ it('applies color variance — each particle gets its own birth/death color', () => {
389
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
390
+ const state = createParticleEmitterState();
391
+ const config = createParticleEmitterConfig({
392
+ spawnRate: 5,
393
+ lifetimeMin: 10,
394
+ lifetimeMax: 10,
395
+ speedMin: 0,
396
+ speedMax: 0,
397
+ colorStartR: 0.5,
398
+ colorStartVarianceR: 0.5,
399
+ colorEndR: 0.5,
400
+ colorEndVarianceR: 0.5,
401
+ });
402
+ updateParticleEmitter(emitter, state, config, 1);
403
+ // With full variance all born-red channels should be in [0, 1]
404
+ for (let i = 0; i < emitter.data.particleCount; i++) {
405
+ expect(state.colorBirth[i * 3]).toBeGreaterThanOrEqual(0);
406
+ expect(state.colorBirth[i * 3]).toBeLessThanOrEqual(1);
407
+ }
408
+ });
409
+
410
+ it('world-space flag is synced to emitter.data.worldSpace', () => {
411
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
412
+ const state = createParticleEmitterState();
413
+ const config = createParticleEmitterConfig({ worldSpace: true, spawnRate: 0 });
414
+ updateParticleEmitter(emitter, state, config, 1 / 60);
415
+ expect(emitter.data.worldSpace).toBe(true);
416
+ });
417
+
418
+ it('world-space: spawned particle position is transformed to world space', () => {
419
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
420
+ const state = createParticleEmitterState();
421
+ const config = createParticleEmitterConfig({
422
+ spawnRate: 1,
423
+ lifetimeMin: 10,
424
+ lifetimeMax: 10,
425
+ speedMin: 0,
426
+ speedMax: 0,
427
+ worldSpace: true,
428
+ });
429
+ const worldTransform = { a: 1, b: 0, c: 0, d: 1, tx: 200, ty: 300 };
430
+ updateParticleEmitter(emitter, state, config, 1, undefined, worldTransform);
431
+ expect(emitter.data.particleCount).toBe(1);
432
+ // Particle origin should be at world position (200, 300), not (0, 0)
433
+ expect(emitter.data.transforms[0]).toBeCloseTo(200);
434
+ expect(emitter.data.transforms[1]).toBeCloseTo(300);
435
+ });
436
+
437
+ it('trail interpolation: particles spawned at interpolated positions along path', () => {
438
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
439
+ const state = createParticleEmitterState();
440
+ // spawnRate=5 with deltaTime=1 → 5 particles per frame; maxParticles=10 leaves room for frame 2
441
+ const config = createParticleEmitterConfig({
442
+ spawnRate: 5,
443
+ maxParticles: 10,
444
+ lifetimeMin: 100,
445
+ lifetimeMax: 100,
446
+ speedMin: 0,
447
+ speedMax: 0,
448
+ worldSpace: true,
449
+ });
450
+ // Frame 1: emitter at (0, 0)
451
+ const wt1 = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
452
+ updateParticleEmitter(emitter, state, config, 1, undefined, wt1);
453
+ const countAfterFrame1 = emitter.data.particleCount;
454
+ expect(countAfterFrame1).toBe(5);
455
+
456
+ // Frame 2: emitter moves to (100, 0) — trail interpolates spawn positions
457
+ const wt2 = { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 0 };
458
+ updateParticleEmitter(emitter, state, config, 1, undefined, wt2);
459
+ expect(emitter.data.particleCount).toBe(10);
460
+
461
+ // The 5 new particles should span the path from x=0 to x=100
462
+ let minX = Infinity,
463
+ maxX = -Infinity;
464
+ for (let i = countAfterFrame1; i < emitter.data.particleCount; i++) {
465
+ const x = emitter.data.transforms[i * 4];
466
+ if (x < minX) minX = x;
467
+ if (x > maxX) maxX = x;
468
+ }
469
+ expect(maxX - minX).toBeGreaterThan(10);
470
+ });
471
+
472
+ it('velocity inheritance: new particles receive fraction of emitter velocity', () => {
473
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
474
+ const state = createParticleEmitterState();
475
+ const config = createParticleEmitterConfig({
476
+ spawnRate: 0,
477
+ burstCount: 1,
478
+ burstInterval: 0,
479
+ lifetimeMin: 10,
480
+ lifetimeMax: 10,
481
+ speedMin: 0,
482
+ speedMax: 0,
483
+ spread: 0,
484
+ directionX: 0,
485
+ directionY: -1,
486
+ velocityInheritance: 1,
487
+ });
488
+ // Establish prev position
489
+ emitter.x = 0;
490
+ updateParticleEmitter(emitter, state, config, 1 / 60);
491
+
492
+ // Move emitter, trigger burst
493
+ state.burstTimer = 0;
494
+ emitter.x = 100;
495
+ updateParticleEmitter(emitter, state, config, 1 / 60);
496
+ // Emitter moved 100px in 1/60s → velocity ≈ 6000 px/s
497
+ // With inheritance=1, vx of new particle should be ≈ 6000
498
+ const vx = state.velocities[(emitter.data.particleCount - 1) * 2];
499
+ expect(vx).toBeGreaterThan(100);
500
+ });
501
+
502
+ it('ignores a zero-deltaTime frame: no spawning, aging, or velocity corruption', () => {
503
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
504
+ const state = createParticleEmitterState();
505
+ const config = createParticleEmitterConfig({
506
+ spawnRate: 0,
507
+ burstCount: 1,
508
+ burstInterval: 0,
509
+ lifetimeMin: 10,
510
+ lifetimeMax: 10,
511
+ speedMin: 0,
512
+ speedMax: 0,
513
+ spread: 0,
514
+ velocityInheritance: 1,
515
+ });
516
+ // Establish a previous position, then move the emitter and step with deltaTime=0.
517
+ emitter.x = 0;
518
+ updateParticleEmitter(emitter, state, config, 1 / 60);
519
+ const countBefore = emitter.data.particleCount;
520
+ state.burstTimer = 0; // arm a burst that would otherwise fire on the next step
521
+ emitter.x = 100;
522
+ updateParticleEmitter(emitter, state, config, 0);
523
+ // No new particles spawned on a zero-deltaTime frame...
524
+ expect(emitter.data.particleCount).toBe(countBefore);
525
+ // ...and existing particle velocities remain finite (no divide-by-deltaTime poisoning).
526
+ for (let i = 0; i < emitter.data.particleCount; i++) {
527
+ expect(Number.isFinite(state.velocities[i * 2])).toBe(true);
528
+ expect(Number.isFinite(state.velocities[i * 2 + 1])).toBe(true);
529
+ }
530
+ });
531
+
532
+ it('ignores a negative-deltaTime frame', () => {
533
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
534
+ const state = createParticleEmitterState();
535
+ const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 10, lifetimeMax: 10 });
536
+ updateParticleEmitter(emitter, state, config, 1); // spawn 10
537
+ const countBefore = emitter.data.particleCount;
538
+ const accBefore = state.spawnAccumulator;
539
+ updateParticleEmitter(emitter, state, config, -1);
540
+ expect(emitter.data.particleCount).toBe(countBefore);
541
+ expect(state.spawnAccumulator).toBe(accBefore); // accumulator not driven negative
542
+ });
543
+
544
+ it('still syncs the world-space flag on a zero-deltaTime frame', () => {
545
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
546
+ const state = createParticleEmitterState();
547
+ const config = createParticleEmitterConfig({ worldSpace: true, spawnRate: 10 });
548
+ updateParticleEmitter(emitter, state, config, 0);
549
+ expect(emitter.data.worldSpace).toBe(true);
550
+ });
551
+
552
+ it('a finite, non-looping emitter stops spawning after its duration', () => {
553
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
554
+ const state = createParticleEmitterState();
555
+ const config = createParticleEmitterConfig({
556
+ spawnRate: 10,
557
+ maxParticles: 1000,
558
+ lifetimeMin: 100, // long-lived so none die during the test window
559
+ lifetimeMax: 100,
560
+ duration: 1,
561
+ loop: false,
562
+ });
563
+ // Step well past the 1s duration so emission has definitely stopped.
564
+ for (let i = 0; i < 20; i++) updateParticleEmitter(emitter, state, config, 0.1);
565
+ const countAfterDuration = emitter.data.particleCount;
566
+ expect(countAfterDuration).toBeGreaterThan(0);
567
+ expect(countAfterDuration).toBeLessThan(15); // ~10 particles, not unbounded
568
+ // Stepping further spawns nothing more (and nothing dies yet).
569
+ for (let i = 0; i < 20; i++) updateParticleEmitter(emitter, state, config, 0.1);
570
+ expect(emitter.data.particleCount).toBe(countAfterDuration);
571
+ });
572
+
573
+ it('a looping emitter keeps spawning past its duration', () => {
574
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
575
+ const state = createParticleEmitterState();
576
+ const config = createParticleEmitterConfig({
577
+ spawnRate: 10,
578
+ maxParticles: 1000,
579
+ lifetimeMin: 100,
580
+ lifetimeMax: 100,
581
+ duration: 1,
582
+ loop: true,
583
+ });
584
+ for (let i = 0; i < 30; i++) updateParticleEmitter(emitter, state, config, 0.1);
585
+ // 3 seconds at rate 10 → ~30 particles, well past the 1s duration.
586
+ expect(emitter.data.particleCount).toBeGreaterThan(20);
587
+ });
588
+
589
+ it('replays byte-identically when stepped from the same seed (deterministic replay)', () => {
590
+ // North star: the same seed + the same step sequence reproduces the same SoA
591
+ // buffers exactly. The engine draws only from the injected RandomSource, so two
592
+ // runs seeded identically must produce bit-identical particle state.
593
+ const run = (): {
594
+ transforms: number[];
595
+ colors: number[];
596
+ velocities: number[];
597
+ lifetimes: number[];
598
+ count: number;
599
+ } => {
600
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
601
+ const state = createParticleEmitterState(createRandomSource(1234));
602
+ const config = createParticleEmitterConfig({
603
+ spawnRate: 30,
604
+ maxParticles: 200,
605
+ speedMin: 10,
606
+ speedMax: 200,
607
+ spread: Math.PI,
608
+ lifetimeMin: 0.5,
609
+ lifetimeMax: 2,
610
+ rotationSpeedMin: -2,
611
+ rotationSpeedMax: 2,
612
+ scaleMin: 0.5,
613
+ scaleMax: 2,
614
+ gravityY: 50,
615
+ });
616
+ for (let i = 0; i < 60; i++) updateParticleEmitter(emitter, state, config, 1 / 60);
617
+ const count = emitter.data.particleCount;
618
+ return {
619
+ transforms: Array.from(emitter.data.transforms.slice(0, count * 4)),
620
+ colors: Array.from(emitter.data.colors.slice(0, count * 3)),
621
+ velocities: Array.from(state.velocities.slice(0, count * 2)),
622
+ lifetimes: Array.from(state.lifetimes.slice(0, count * 2)),
623
+ count,
624
+ };
625
+ };
626
+ const first = run();
627
+ const second = run();
628
+ expect(first.count).toBeGreaterThan(0);
629
+ expect(second).toEqual(first);
630
+ });
631
+ });