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