@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,516 @@
1
+ import { createRectangle } from '@flighthq/geometry';
2
+ import { getNodeLocalBoundsRectangle, getNodeLocalBoundsRevision } from '@flighthq/node';
3
+ import type { ParticleEmitter, TextureAtlas, TextureAtlasRegion } from '@flighthq/types';
4
+ import { ParticleEmitterKind } from '@flighthq/types';
5
+
6
+ import {
7
+ appendParticleEmitterParticle,
8
+ clearParticleEmitter,
9
+ cloneParticleEmitter,
10
+ compactParticleEmitter,
11
+ computeParticleEmitterLocalBoundsRectangle,
12
+ createParticleEmitter,
13
+ createParticleEmitterData,
14
+ createParticleEmitterRuntime,
15
+ getParticleEmitterCapacity,
16
+ getParticleEmitterParticleAlpha,
17
+ getParticleEmitterParticleId,
18
+ getParticleEmitterParticleVelocity,
19
+ getParticleEmitterRuntime,
20
+ PARTICLE_EMITTER_DELETED_ID,
21
+ removeParticleEmitterParticle,
22
+ reserveParticleEmitter,
23
+ setParticleEmitterLocalBoundsRectangle,
24
+ setParticleEmitterParticle,
25
+ setParticleEmitterParticleAlpha,
26
+ setParticleEmitterParticleColor,
27
+ setParticleEmitterParticleVelocity,
28
+ } from './particleEmitter';
29
+
30
+ function makeAtlasRegion(id = 0, x = 0, y = 0, width = 32, height = 32): TextureAtlasRegion {
31
+ return { id, x, y, width, height, pivotX: null, pivotY: null } as TextureAtlasRegion;
32
+ }
33
+
34
+ function makeAtlas(...regions: TextureAtlasRegion[]): TextureAtlas {
35
+ return { image: null, regions } as TextureAtlas;
36
+ }
37
+
38
+ describe('appendParticleEmitterParticle', () => {
39
+ it('appends a particle and returns its index', () => {
40
+ const emitter = createParticleEmitter();
41
+ const idx = appendParticleEmitterParticle(emitter, 2, 10, 20, 0.5, 1.5);
42
+ expect(idx).toBe(0);
43
+ expect(emitter.data.particleCount).toBe(1);
44
+ expect(emitter.data.ids[0]).toBe(2);
45
+ expect(emitter.data.transforms[0]).toBe(10); // x
46
+ expect(emitter.data.transforms[1]).toBe(20); // y
47
+ expect(emitter.data.transforms[2]).toBe(0.5); // rotation
48
+ expect(emitter.data.transforms[3]).toBe(1.5); // scale
49
+ });
50
+
51
+ it('initializes alpha to 1 and color to white', () => {
52
+ const emitter = createParticleEmitter();
53
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
54
+ expect(emitter.data.alphas[0]).toBe(1);
55
+ expect(emitter.data.colors[0]).toBe(1); // r
56
+ expect(emitter.data.colors[1]).toBe(1); // g
57
+ expect(emitter.data.colors[2]).toBe(1); // b
58
+ });
59
+
60
+ it('returns sequential indices for multiple appends', () => {
61
+ const emitter = createParticleEmitter();
62
+ expect(appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1)).toBe(0);
63
+ expect(appendParticleEmitterParticle(emitter, 1, 5, 5, 0, 1)).toBe(1);
64
+ expect(emitter.data.particleCount).toBe(2);
65
+ });
66
+
67
+ it('auto-grows capacity', () => {
68
+ const emitter = createParticleEmitter();
69
+ for (let i = 0; i < 10; i++) appendParticleEmitterParticle(emitter, i, 0, 0, 0, 1);
70
+ expect(emitter.data.particleCount).toBe(10);
71
+ expect(getParticleEmitterCapacity(emitter)).toBeGreaterThanOrEqual(10);
72
+ });
73
+ });
74
+
75
+ describe('clearParticleEmitter', () => {
76
+ it('sets particleCount to 0 and keeps capacity', () => {
77
+ const emitter = createParticleEmitter();
78
+ reserveParticleEmitter(emitter, 50);
79
+ emitter.data.particleCount = 10;
80
+ const capacityBefore = getParticleEmitterCapacity(emitter);
81
+ clearParticleEmitter(emitter);
82
+ expect(emitter.data.particleCount).toBe(0);
83
+ expect(getParticleEmitterCapacity(emitter)).toBe(capacityBefore);
84
+ });
85
+ });
86
+
87
+ describe('cloneParticleEmitter', () => {
88
+ it('copies count, atlas, and worldSpace into a new emitter', () => {
89
+ const atlas = makeAtlas(makeAtlasRegion(0));
90
+ const source = createParticleEmitter({ data: { atlas, worldSpace: true } });
91
+ appendParticleEmitterParticle(source, 0, 10, 20, 0.5, 2);
92
+ setParticleEmitterParticleColor(source, 0, 0.1, 0.2, 0.3);
93
+ setParticleEmitterParticleAlpha(source, 0, 0.4);
94
+ setParticleEmitterParticleVelocity(source, 0, 5, 6);
95
+
96
+ const clone = cloneParticleEmitter(source);
97
+ expect(clone).not.toBe(source);
98
+ expect(clone.data.particleCount).toBe(1);
99
+ expect(clone.data.atlas).toBe(atlas);
100
+ expect(clone.data.worldSpace).toBe(true);
101
+ expect(getParticleEmitterParticleId(clone, 0)).toBe(0);
102
+ expect(getParticleEmitterParticleAlpha(clone, 0)).toBeCloseTo(0.4);
103
+ });
104
+
105
+ it('clones typed arrays so mutations do not leak back', () => {
106
+ const source = createParticleEmitter();
107
+ appendParticleEmitterParticle(source, 1, 0, 0, 0, 1);
108
+ const clone = cloneParticleEmitter(source);
109
+ expect(clone.data.transforms).not.toBe(source.data.transforms);
110
+ setParticleEmitterParticle(clone, 0, 2, 99, 99, 0, 1);
111
+ expect(getParticleEmitterParticleId(source, 0)).toBe(1);
112
+ expect(source.data.transforms[0]).toBe(0);
113
+ });
114
+ });
115
+
116
+ describe('compactParticleEmitter', () => {
117
+ it('no-ops on an empty emitter', () => {
118
+ const emitter = createParticleEmitter();
119
+ compactParticleEmitter(emitter);
120
+ expect(emitter.data.particleCount).toBe(0);
121
+ });
122
+
123
+ it('removes sentinel-id entries and preserves order', () => {
124
+ const emitter = createParticleEmitter();
125
+ appendParticleEmitterParticle(emitter, 10, 1, 1, 0, 1);
126
+ appendParticleEmitterParticle(emitter, 11, 2, 2, 0, 1);
127
+ appendParticleEmitterParticle(emitter, 12, 3, 3, 0, 1);
128
+ // Mark the middle entry as deleted with the Uint16Array sentinel.
129
+ emitter.data.ids[1] = PARTICLE_EMITTER_DELETED_ID;
130
+ compactParticleEmitter(emitter);
131
+ expect(emitter.data.particleCount).toBe(2);
132
+ expect(getParticleEmitterParticleId(emitter, 0)).toBe(10);
133
+ expect(getParticleEmitterParticleId(emitter, 1)).toBe(12);
134
+ // The surviving second entry kept its transform (x = 3).
135
+ expect(emitter.data.transforms[1 * 4]).toBe(3);
136
+ });
137
+
138
+ it('leaves a fully-live buffer unchanged', () => {
139
+ const emitter = createParticleEmitter();
140
+ appendParticleEmitterParticle(emitter, 1, 0, 0, 0, 1);
141
+ appendParticleEmitterParticle(emitter, 2, 0, 0, 0, 1);
142
+ compactParticleEmitter(emitter);
143
+ expect(emitter.data.particleCount).toBe(2);
144
+ expect(getParticleEmitterParticleId(emitter, 0)).toBe(1);
145
+ expect(getParticleEmitterParticleId(emitter, 1)).toBe(2);
146
+ });
147
+ });
148
+
149
+ describe('computeParticleEmitterLocalBoundsRectangle', () => {
150
+ it('returns zero bounds when atlas is null', () => {
151
+ const emitter = createParticleEmitter();
152
+ const out = createRectangle(1, 2, 3, 4);
153
+ computeParticleEmitterLocalBoundsRectangle(out, emitter);
154
+ expect(out.x).toBe(0);
155
+ expect(out.y).toBe(0);
156
+ expect(out.width).toBe(0);
157
+ expect(out.height).toBe(0);
158
+ });
159
+
160
+ it('returns zero bounds when particleCount is 0', () => {
161
+ const atlas = makeAtlas(makeAtlasRegion());
162
+ const emitter = createParticleEmitter({ data: { atlas } });
163
+ const out = createRectangle(1, 2, 3, 4);
164
+ computeParticleEmitterLocalBoundsRectangle(out, emitter);
165
+ expect(out.width).toBe(0);
166
+ expect(out.height).toBe(0);
167
+ });
168
+
169
+ it('computes AABB for a single axis-aligned particle', () => {
170
+ const atlas = makeAtlas(makeAtlasRegion(0, 0, 0, 10, 20));
171
+ const emitter = createParticleEmitter({ data: { atlas, particleCount: 1 } });
172
+ emitter.data.ids = new Uint16Array([0]);
173
+ // [x=5, y=10, rotation=0, scale=1]
174
+ emitter.data.transforms = new Float32Array([5, 10, 0, 1]);
175
+ const out = createRectangle();
176
+ computeParticleEmitterLocalBoundsRectangle(out, emitter);
177
+ expect(out.x).toBeCloseTo(5);
178
+ expect(out.y).toBeCloseTo(10);
179
+ expect(out.width).toBeCloseTo(10);
180
+ expect(out.height).toBeCloseTo(20);
181
+ });
182
+
183
+ it('computes AABB over multiple particles', () => {
184
+ const atlas = makeAtlas(makeAtlasRegion(0, 0, 0, 10, 10));
185
+ const emitter = createParticleEmitter({ data: { atlas, particleCount: 2 } });
186
+ emitter.data.ids = new Uint16Array([0, 0]);
187
+ // particle 0 at (0,0), particle 1 at (50,50)
188
+ emitter.data.transforms = new Float32Array([0, 0, 0, 1, 50, 50, 0, 1]);
189
+ const out = createRectangle();
190
+ computeParticleEmitterLocalBoundsRectangle(out, emitter);
191
+ expect(out.x).toBeCloseTo(0);
192
+ expect(out.y).toBeCloseTo(0);
193
+ expect(out.width).toBeCloseTo(60);
194
+ expect(out.height).toBeCloseTo(60);
195
+ });
196
+
197
+ it('skips particles with out-of-range region ids', () => {
198
+ const atlas = makeAtlas(makeAtlasRegion(0, 0, 0, 10, 10));
199
+ const emitter = createParticleEmitter({ data: { atlas, particleCount: 2 } });
200
+ emitter.data.ids = new Uint16Array([0, 99]);
201
+ emitter.data.transforms = new Float32Array([0, 0, 0, 1, 1000, 1000, 0, 1]);
202
+ const out = createRectangle();
203
+ computeParticleEmitterLocalBoundsRectangle(out, emitter);
204
+ expect(out.width).toBeCloseTo(10);
205
+ expect(out.height).toBeCloseTo(10);
206
+ });
207
+
208
+ it('does not store the result on the emitter', () => {
209
+ const atlas = makeAtlas(makeAtlasRegion(0, 0, 0, 10, 10));
210
+ const emitter = createParticleEmitter({ data: { atlas, particleCount: 1 } });
211
+ emitter.data.ids = new Uint16Array([0]);
212
+ emitter.data.transforms = new Float32Array([0, 0, 0, 1]);
213
+ const revisionBefore = getNodeLocalBoundsRevision(emitter);
214
+ computeParticleEmitterLocalBoundsRectangle(createRectangle(), emitter);
215
+ expect(getNodeLocalBoundsRevision(emitter)).toBe(revisionBefore);
216
+ expect(getNodeLocalBoundsRectangle(emitter).width).toBe(0);
217
+ });
218
+ });
219
+
220
+ describe('createParticleEmitter', () => {
221
+ let emitter: ParticleEmitter;
222
+
223
+ beforeEach(() => {
224
+ emitter = createParticleEmitter();
225
+ });
226
+
227
+ it('initializes default values', () => {
228
+ expect(emitter.data.alphas).toStrictEqual(new Float32Array());
229
+ expect(emitter.data.atlas).toBeNull();
230
+ expect(emitter.data.ids).toStrictEqual(new Uint16Array());
231
+ expect(emitter.data.particleCount).toBe(0);
232
+ expect(emitter.data.transforms).toStrictEqual(new Float32Array());
233
+ expect(emitter.kind).toBe(ParticleEmitterKind);
234
+ });
235
+
236
+ it('allows pre-defined values', () => {
237
+ const base = {
238
+ data: {
239
+ atlas: {} as TextureAtlas,
240
+ ids: new Uint16Array([1, 2]),
241
+ particleCount: 2,
242
+ transforms: new Float32Array([0, 0, 0, 1, 10, 10, 0, 1]),
243
+ alphas: new Float32Array([1, 0.5]),
244
+ },
245
+ };
246
+ const obj = createParticleEmitter(base);
247
+ expect(obj.data.atlas).toStrictEqual(base.data.atlas);
248
+ expect(obj.data.particleCount).toBe(2);
249
+ });
250
+
251
+ it('returns a new object', () => {
252
+ const base = {};
253
+ const obj = createParticleEmitter(base);
254
+ expect(obj).not.toStrictEqual(base);
255
+ });
256
+ });
257
+
258
+ describe('createParticleEmitterData', () => {
259
+ it('returns default values', () => {
260
+ const data = createParticleEmitterData();
261
+ expect(data.alphas).toBeInstanceOf(Float32Array);
262
+ expect(data.atlas).toBeNull();
263
+ expect(data.ids).toBeInstanceOf(Uint16Array);
264
+ expect(data.particleCount).toBe(0);
265
+ expect(data.transforms).toBeInstanceOf(Float32Array);
266
+ });
267
+
268
+ it('allows pre-defined values', () => {
269
+ const data = createParticleEmitterData({ particleCount: 5 });
270
+ expect(data.particleCount).toBe(5);
271
+ });
272
+ });
273
+
274
+ describe('createParticleEmitterRuntime', () => {
275
+ it('returns a non-null runtime', () => {
276
+ const runtime = createParticleEmitterRuntime();
277
+ expect(runtime).not.toBeNull();
278
+ });
279
+
280
+ it('initializes localBoundsRectangle to null', () => {
281
+ const runtime = createParticleEmitterRuntime();
282
+ expect(runtime.localBoundsRectangle).toBeNull();
283
+ });
284
+ });
285
+
286
+ describe('getParticleEmitterCapacity', () => {
287
+ it('returns 0 for a new emitter', () => {
288
+ const emitter = createParticleEmitter();
289
+ expect(getParticleEmitterCapacity(emitter)).toBe(0);
290
+ });
291
+
292
+ it('returns the minimum across all arrays', () => {
293
+ const emitter = createParticleEmitter();
294
+ emitter.data.ids = new Uint16Array(10);
295
+ emitter.data.alphas = new Float32Array(20);
296
+ emitter.data.transforms = new Float32Array(10 * 4); // 10 particles at stride 4
297
+ expect(getParticleEmitterCapacity(emitter)).toBe(10);
298
+ });
299
+ });
300
+
301
+ describe('getParticleEmitterParticleAlpha', () => {
302
+ it('returns the alpha at a valid index', () => {
303
+ const emitter = createParticleEmitter();
304
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
305
+ setParticleEmitterParticleAlpha(emitter, 0, 0.5);
306
+ expect(getParticleEmitterParticleAlpha(emitter, 0)).toBeCloseTo(0.5);
307
+ });
308
+
309
+ it('returns -1 for out-of-range index', () => {
310
+ const emitter = createParticleEmitter();
311
+ expect(getParticleEmitterParticleAlpha(emitter, 0)).toBe(-1);
312
+ expect(getParticleEmitterParticleAlpha(emitter, -1)).toBe(-1);
313
+ });
314
+ });
315
+
316
+ describe('getParticleEmitterParticleId', () => {
317
+ it('returns the region id at a valid index', () => {
318
+ const emitter = createParticleEmitter();
319
+ appendParticleEmitterParticle(emitter, 7, 0, 0, 0, 1);
320
+ expect(getParticleEmitterParticleId(emitter, 0)).toBe(7);
321
+ });
322
+
323
+ it('returns -1 for out-of-range index', () => {
324
+ const emitter = createParticleEmitter();
325
+ expect(getParticleEmitterParticleId(emitter, 0)).toBe(-1);
326
+ expect(getParticleEmitterParticleId(emitter, -1)).toBe(-1);
327
+ });
328
+ });
329
+
330
+ describe('getParticleEmitterParticleVelocity', () => {
331
+ it('writes velocity into out for a valid index', () => {
332
+ const emitter = createParticleEmitter();
333
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
334
+ setParticleEmitterParticleVelocity(emitter, 0, 3.5, -1.5);
335
+ const out = { x: 0, y: 0 };
336
+ const result = getParticleEmitterParticleVelocity(out, emitter, 0);
337
+ expect(result).toBe(true);
338
+ expect(out.x).toBeCloseTo(3.5);
339
+ expect(out.y).toBeCloseTo(-1.5);
340
+ });
341
+
342
+ it('returns false and does not write for out-of-range index', () => {
343
+ const emitter = createParticleEmitter();
344
+ const out = { x: 99, y: 99 };
345
+ expect(getParticleEmitterParticleVelocity(out, emitter, 0)).toBe(false);
346
+ expect(out.x).toBe(99);
347
+ expect(out.y).toBe(99);
348
+ });
349
+ });
350
+
351
+ describe('getParticleEmitterRuntime', () => {
352
+ it('returns the runtime for a ParticleEmitter', () => {
353
+ const emitter = createParticleEmitter();
354
+ const runtime = getParticleEmitterRuntime(emitter);
355
+ expect(runtime).not.toBeNull();
356
+ });
357
+
358
+ it('returns the same object on repeated calls', () => {
359
+ const emitter = createParticleEmitter();
360
+ expect(getParticleEmitterRuntime(emitter)).toBe(getParticleEmitterRuntime(emitter));
361
+ });
362
+ });
363
+
364
+ describe('removeParticleEmitterParticle', () => {
365
+ it('swap-removes with the last particle', () => {
366
+ const emitter = createParticleEmitter();
367
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
368
+ appendParticleEmitterParticle(emitter, 1, 10, 10, 0, 1);
369
+ appendParticleEmitterParticle(emitter, 2, 20, 20, 0, 1);
370
+ removeParticleEmitterParticle(emitter, 0);
371
+ expect(emitter.data.particleCount).toBe(2);
372
+ // The last particle (id=2) should now be at index 0
373
+ expect(getParticleEmitterParticleId(emitter, 0)).toBe(2);
374
+ });
375
+
376
+ it('removes the only particle', () => {
377
+ const emitter = createParticleEmitter();
378
+ appendParticleEmitterParticle(emitter, 5, 0, 0, 0, 1);
379
+ removeParticleEmitterParticle(emitter, 0);
380
+ expect(emitter.data.particleCount).toBe(0);
381
+ });
382
+
383
+ it('no-ops for out-of-range index', () => {
384
+ const emitter = createParticleEmitter();
385
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
386
+ removeParticleEmitterParticle(emitter, -1);
387
+ removeParticleEmitterParticle(emitter, 1);
388
+ expect(emitter.data.particleCount).toBe(1);
389
+ });
390
+ });
391
+
392
+ describe('reserveParticleEmitter', () => {
393
+ it('allocates all arrays when capacity is larger', () => {
394
+ const emitter = createParticleEmitter();
395
+ reserveParticleEmitter(emitter, 50);
396
+ expect(emitter.data.ids.length).toBe(50);
397
+ expect(emitter.data.alphas.length).toBe(50);
398
+ expect(emitter.data.transforms.length).toBe(50 * 4);
399
+ });
400
+
401
+ it('does not reallocate when capacity is already sufficient', () => {
402
+ const emitter = createParticleEmitter();
403
+ reserveParticleEmitter(emitter, 50);
404
+ const { ids, alphas, transforms } = emitter.data;
405
+ reserveParticleEmitter(emitter, 50);
406
+ expect(emitter.data.ids).toBe(ids);
407
+ expect(emitter.data.alphas).toBe(alphas);
408
+ expect(emitter.data.transforms).toBe(transforms);
409
+ });
410
+ });
411
+
412
+ describe('setParticleEmitterLocalBoundsRectangle', () => {
413
+ it('makes getNodeLocalBoundsRectangle reflect the new bounds', () => {
414
+ const emitter = createParticleEmitter();
415
+ setParticleEmitterLocalBoundsRectangle(emitter, createRectangle(10, 20, 32, 16));
416
+ const local = getNodeLocalBoundsRectangle(emitter);
417
+ expect(local.x).toBe(10);
418
+ expect(local.y).toBe(20);
419
+ expect(local.width).toBe(32);
420
+ expect(local.height).toBe(16);
421
+ });
422
+
423
+ it('copies the rect so later mutations do not affect stored bounds', () => {
424
+ const emitter = createParticleEmitter();
425
+ const rect = createRectangle(10, 20, 32, 16);
426
+ setParticleEmitterLocalBoundsRectangle(emitter, rect);
427
+ rect.width = 999;
428
+ expect(getNodeLocalBoundsRectangle(emitter).width).toBe(32);
429
+ });
430
+
431
+ it('invalidates local bounds', () => {
432
+ const emitter = createParticleEmitter();
433
+ const before = getNodeLocalBoundsRevision(emitter);
434
+ setParticleEmitterLocalBoundsRectangle(emitter, createRectangle());
435
+ expect(getNodeLocalBoundsRevision(emitter)).not.toBe(before);
436
+ });
437
+ });
438
+
439
+ describe('setParticleEmitterParticle', () => {
440
+ it('sets id and transform for a valid index', () => {
441
+ const emitter = createParticleEmitter();
442
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
443
+ setParticleEmitterParticle(emitter, 0, 3, 5, 10, 1.57, 2.0);
444
+ expect(emitter.data.ids[0]).toBe(3);
445
+ expect(emitter.data.transforms[0]).toBeCloseTo(5); // x
446
+ expect(emitter.data.transforms[1]).toBeCloseTo(10); // y
447
+ expect(emitter.data.transforms[2]).toBeCloseTo(1.57); // rotation
448
+ expect(emitter.data.transforms[3]).toBeCloseTo(2.0); // scale
449
+ });
450
+
451
+ it('no-ops for out-of-range index', () => {
452
+ const emitter = createParticleEmitter();
453
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
454
+ emitter.data.ids[0] = 0;
455
+ setParticleEmitterParticle(emitter, -1, 9, 0, 0, 0, 1);
456
+ setParticleEmitterParticle(emitter, 1, 9, 0, 0, 0, 1);
457
+ expect(emitter.data.ids[0]).toBe(0);
458
+ });
459
+ });
460
+
461
+ describe('setParticleEmitterParticleAlpha', () => {
462
+ it('sets alpha for a valid index', () => {
463
+ const emitter = createParticleEmitter();
464
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
465
+ setParticleEmitterParticleAlpha(emitter, 0, 0.25);
466
+ expect(emitter.data.alphas[0]).toBeCloseTo(0.25);
467
+ });
468
+
469
+ it('no-ops for out-of-range index', () => {
470
+ const emitter = createParticleEmitter();
471
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
472
+ emitter.data.alphas[0] = 0.5;
473
+ setParticleEmitterParticleAlpha(emitter, -1, 0.9);
474
+ setParticleEmitterParticleAlpha(emitter, 1, 0.9);
475
+ expect(emitter.data.alphas[0]).toBeCloseTo(0.5);
476
+ });
477
+ });
478
+
479
+ describe('setParticleEmitterParticleColor', () => {
480
+ it('sets r, g, b for a valid index', () => {
481
+ const emitter = createParticleEmitter();
482
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
483
+ setParticleEmitterParticleColor(emitter, 0, 0.2, 0.4, 0.8);
484
+ expect(emitter.data.colors[0]).toBeCloseTo(0.2);
485
+ expect(emitter.data.colors[1]).toBeCloseTo(0.4);
486
+ expect(emitter.data.colors[2]).toBeCloseTo(0.8);
487
+ });
488
+
489
+ it('no-ops for out-of-range index', () => {
490
+ const emitter = createParticleEmitter();
491
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
492
+ emitter.data.colors[0] = 0.5;
493
+ setParticleEmitterParticleColor(emitter, -1, 0.9, 0.9, 0.9);
494
+ setParticleEmitterParticleColor(emitter, 1, 0.9, 0.9, 0.9);
495
+ expect(emitter.data.colors[0]).toBeCloseTo(0.5);
496
+ });
497
+ });
498
+
499
+ describe('setParticleEmitterParticleVelocity', () => {
500
+ it('sets vx and vy for a valid index', () => {
501
+ const emitter = createParticleEmitter();
502
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
503
+ setParticleEmitterParticleVelocity(emitter, 0, 2.5, -3.0);
504
+ expect(emitter.data.velocities[0]).toBeCloseTo(2.5);
505
+ expect(emitter.data.velocities[1]).toBeCloseTo(-3.0);
506
+ });
507
+
508
+ it('no-ops for out-of-range index', () => {
509
+ const emitter = createParticleEmitter();
510
+ appendParticleEmitterParticle(emitter, 0, 0, 0, 0, 1);
511
+ emitter.data.velocities[0] = 1;
512
+ setParticleEmitterParticleVelocity(emitter, -1, 9, 9);
513
+ setParticleEmitterParticleVelocity(emitter, 1, 9, 9);
514
+ expect(emitter.data.velocities[0]).toBeCloseTo(1);
515
+ });
516
+ });
@@ -0,0 +1,67 @@
1
+ import { createParticleEmitterConfig, createParticleEmitterState } from '@flighthq/particles';
2
+ import type { TextureAtlas } from '@flighthq/types';
3
+
4
+ import { createParticleEmitter } from './particleEmitter';
5
+ import { prewarmParticleEmitter } from './prewarmParticleEmitter';
6
+
7
+ function makeAtlas(): TextureAtlas {
8
+ return {
9
+ image: null,
10
+ regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
11
+ } as TextureAtlas;
12
+ }
13
+
14
+ describe('prewarmParticleEmitter', () => {
15
+ it('produces live particles after warming for one lifetime', () => {
16
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
17
+ const state = createParticleEmitterState();
18
+ const config = createParticleEmitterConfig({ spawnRate: 20, lifetimeMin: 1, lifetimeMax: 1 });
19
+ prewarmParticleEmitter(emitter, state, config, 1);
20
+ expect(emitter.data.particleCount).toBeGreaterThan(0);
21
+ });
22
+
23
+ it('does not exceed maxParticles', () => {
24
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
25
+ const state = createParticleEmitterState();
26
+ const config = createParticleEmitterConfig({ spawnRate: 1000, maxParticles: 10, lifetimeMin: 10, lifetimeMax: 10 });
27
+ prewarmParticleEmitter(emitter, state, config, 5);
28
+ expect(emitter.data.particleCount).toBeLessThanOrEqual(10);
29
+ });
30
+
31
+ it('accepts a custom stepDeltaTime and still produces particles', () => {
32
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
33
+ const state = createParticleEmitterState();
34
+ const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 5, lifetimeMax: 5 });
35
+ prewarmParticleEmitter(emitter, state, config, 1, 1 / 30);
36
+ expect(emitter.data.particleCount).toBeGreaterThan(0);
37
+ });
38
+
39
+ it('fires onSpawn callback during warm-up', () => {
40
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
41
+ const state = createParticleEmitterState();
42
+ const config = createParticleEmitterConfig({ spawnRate: 5, lifetimeMin: 10, lifetimeMax: 10 });
43
+ let spawned = 0;
44
+ prewarmParticleEmitter(emitter, state, config, 1, 1 / 60, {
45
+ onSpawn: () => {
46
+ spawned++;
47
+ },
48
+ });
49
+ expect(spawned).toBeGreaterThan(0);
50
+ });
51
+
52
+ it('leaves emitter unmodified when duration is zero', () => {
53
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
54
+ const state = createParticleEmitterState();
55
+ const config = createParticleEmitterConfig({ spawnRate: 100, lifetimeMin: 1, lifetimeMax: 1 });
56
+ prewarmParticleEmitter(emitter, state, config, 0);
57
+ expect(emitter.data.particleCount).toBe(0);
58
+ });
59
+
60
+ it('does not hang when stepDeltaTime is zero (falls back to a single step)', () => {
61
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
62
+ const state = createParticleEmitterState();
63
+ const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 10, lifetimeMax: 10 });
64
+ prewarmParticleEmitter(emitter, state, config, 1, 0);
65
+ expect(emitter.data.particleCount).toBeGreaterThan(0);
66
+ });
67
+ });
@@ -0,0 +1,86 @@
1
+ import { createParticleEmitterConfig, createParticleEmitterState } from '@flighthq/particles';
2
+ import type { ParticleCollider, ParticleForce, TextureAtlas } from '@flighthq/types';
3
+
4
+ import { createParticleEmitter } from './particleEmitter';
5
+ import { stepParticleEmitter } from './stepParticleEmitter';
6
+
7
+ function makeAtlas(): TextureAtlas {
8
+ return {
9
+ image: null,
10
+ regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
11
+ } as unknown as TextureAtlas;
12
+ }
13
+
14
+ describe('stepParticleEmitter', () => {
15
+ it('spawns particles when called with no forces or colliders', () => {
16
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
17
+ const state = createParticleEmitterState();
18
+ const config = createParticleEmitterConfig({ spawnRate: 10, maxParticles: 100 });
19
+ stepParticleEmitter(emitter, state, config, 1);
20
+ expect(emitter.data.particleCount).toBe(10);
21
+ });
22
+
23
+ it('applies wind force before update — particle receives velocity from wind', () => {
24
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
25
+ const state = createParticleEmitterState();
26
+ const config = createParticleEmitterConfig({
27
+ spawnRate: 1,
28
+ lifetimeMin: 10,
29
+ lifetimeMax: 10,
30
+ speedMin: 0,
31
+ speedMax: 0,
32
+ spread: 0,
33
+ directionX: 0,
34
+ directionY: -1,
35
+ });
36
+ // Spawn one particle first
37
+ stepParticleEmitter(emitter, state, config, 1);
38
+ expect(emitter.data.particleCount).toBe(1);
39
+ // Now step with a wind force pushing right
40
+ const wind: ParticleForce = { kind: 'WindForce', x: 200, y: 0 };
41
+ stepParticleEmitter(emitter, state, config, 1, [wind]);
42
+ // After wind: vx starts at 0, gets +200 from wind in applyForces, then particle moves right
43
+ const x = emitter.data.transforms[0];
44
+ expect(x).toBeGreaterThan(0);
45
+ });
46
+
47
+ it('resolves collisions after update — particle stays above a plane at y=0', () => {
48
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
49
+ const state = createParticleEmitterState();
50
+ const config = createParticleEmitterConfig({
51
+ spawnRate: 1,
52
+ lifetimeMin: 10,
53
+ lifetimeMax: 10,
54
+ speedMin: 100,
55
+ speedMax: 100,
56
+ spread: 0,
57
+ directionX: 0,
58
+ directionY: 1, // moving downward
59
+ gravityY: 0,
60
+ });
61
+ stepParticleEmitter(emitter, state, config, 1);
62
+ // Particle moves down; plane at y=0 (normal pointing up = (0,-1), distance 0)
63
+ const plane: ParticleCollider = { kind: 'PlaneCollider', nx: 0, ny: -1, distance: 0, restitution: 1, friction: 0 };
64
+ stepParticleEmitter(emitter, state, config, 1, undefined, [plane]);
65
+ // Particle should be reflected above y=0
66
+ const y = emitter.data.transforms[1];
67
+ expect(y).toBeGreaterThanOrEqual(-1e-4); // at or above plane
68
+ });
69
+
70
+ it('passes callbacks down to updateParticleEmitter', () => {
71
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
72
+ const state = createParticleEmitterState();
73
+ const config = createParticleEmitterConfig({ spawnRate: 3, lifetimeMin: 10, lifetimeMax: 10 });
74
+ let spawnCount = 0;
75
+ stepParticleEmitter(emitter, state, config, 1, undefined, undefined, { onSpawn: () => spawnCount++ });
76
+ expect(spawnCount).toBe(3);
77
+ });
78
+
79
+ it('is a no-op for zero deltaTime', () => {
80
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
81
+ const state = createParticleEmitterState();
82
+ const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 10, lifetimeMax: 10 });
83
+ stepParticleEmitter(emitter, state, config, 0);
84
+ expect(emitter.data.particleCount).toBe(0);
85
+ });
86
+ });