@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.
- package/dist/applyParticleCollisions.d.ts +12 -0
- package/dist/applyParticleCollisions.d.ts.map +1 -0
- package/dist/applyParticleCollisions.js +175 -0
- package/dist/applyParticleCollisions.js.map +1 -0
- package/dist/applyParticleForces.d.ts +13 -0
- package/dist/applyParticleForces.d.ts.map +1 -0
- package/dist/applyParticleForces.js +129 -0
- package/dist/applyParticleForces.js.map +1 -0
- package/dist/curve.d.ts +35 -0
- package/dist/curve.d.ts.map +1 -0
- package/dist/curve.js +227 -0
- package/dist/curve.js.map +1 -0
- package/dist/emitParticleBurst.d.ts +15 -0
- package/dist/emitParticleBurst.d.ts.map +1 -0
- package/dist/emitParticleBurst.js +114 -0
- package/dist/emitParticleBurst.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/particleEmitterConfig.d.ts +4 -0
- package/dist/particleEmitterConfig.d.ts.map +1 -0
- package/dist/particleEmitterConfig.js +53 -0
- package/dist/particleEmitterConfig.js.map +1 -0
- package/dist/particleEmitterSignals.d.ts +20 -0
- package/dist/particleEmitterSignals.d.ts.map +1 -0
- package/dist/particleEmitterSignals.js +33 -0
- package/dist/particleEmitterSignals.js.map +1 -0
- package/dist/particleEmitterState.d.ts +7 -0
- package/dist/particleEmitterState.d.ts.map +1 -0
- package/dist/particleEmitterState.js +38 -0
- package/dist/particleEmitterState.js.map +1 -0
- package/dist/particleObjectsState.d.ts +4 -0
- package/dist/particleObjectsState.d.ts.map +1 -0
- package/dist/particleObjectsState.js +24 -0
- package/dist/particleObjectsState.js.map +1 -0
- package/dist/prewarmParticleEmitter.d.ts +5 -0
- package/dist/prewarmParticleEmitter.d.ts.map +1 -0
- package/dist/prewarmParticleEmitter.js +13 -0
- package/dist/prewarmParticleEmitter.js.map +1 -0
- package/dist/stepParticleEmitter.d.ts +38 -0
- package/dist/stepParticleEmitter.d.ts.map +1 -0
- package/dist/stepParticleEmitter.js +57 -0
- package/dist/stepParticleEmitter.js.map +1 -0
- package/dist/updateParticleEmitter.d.ts +8 -0
- package/dist/updateParticleEmitter.d.ts.map +1 -0
- package/dist/updateParticleEmitter.js +315 -0
- package/dist/updateParticleEmitter.js.map +1 -0
- package/dist/updateParticleObjects.d.ts +8 -0
- package/dist/updateParticleObjects.d.ts.map +1 -0
- package/dist/updateParticleObjects.js +151 -0
- package/dist/updateParticleObjects.js.map +1 -0
- package/dist/validateParticleEmitterConfig.d.ts +15 -0
- package/dist/validateParticleEmitterConfig.d.ts.map +1 -0
- package/dist/validateParticleEmitterConfig.js +206 -0
- package/dist/validateParticleEmitterConfig.js.map +1 -0
- package/package.json +42 -0
- package/src/applyParticleCollisions.test.ts +191 -0
- package/src/applyParticleForces.test.ts +174 -0
- package/src/curve.test.ts +327 -0
- package/src/deterministic.test.ts +54 -0
- package/src/emitParticleBurst.test.ts +103 -0
- package/src/particleEmitterConfig.test.ts +65 -0
- package/src/particleEmitterSignals.test.ts +125 -0
- package/src/particleEmitterState.test.ts +113 -0
- package/src/particleObjectsState.test.ts +34 -0
- package/src/prewarmParticleEmitter.test.ts +68 -0
- package/src/stepParticleEmitter.test.ts +113 -0
- package/src/updateParticleEmitter.test.ts +631 -0
- package/src/updateParticleObjects.test.ts +340 -0
- package/src/validateParticleEmitterConfig.test.ts +118 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createRandomSource } from '@flighthq/math';
|
|
2
|
+
|
|
3
|
+
import { createParticleEmitterConfig } from './particleEmitterConfig';
|
|
4
|
+
import { createParticleObjectsState } from './particleObjectsState';
|
|
5
|
+
import type { ParticleObject } from './updateParticleObjects';
|
|
6
|
+
import { isParticleObjectsComplete, updateParticleObjects } from './updateParticleObjects';
|
|
7
|
+
|
|
8
|
+
function makeObject(): ParticleObject {
|
|
9
|
+
return {
|
|
10
|
+
alpha: 1,
|
|
11
|
+
blendMode: null,
|
|
12
|
+
colorTransform: null,
|
|
13
|
+
rotation: 0,
|
|
14
|
+
scaleX: 1,
|
|
15
|
+
scaleY: 1,
|
|
16
|
+
visible: false,
|
|
17
|
+
x: 0,
|
|
18
|
+
y: 0,
|
|
19
|
+
} as unknown as ParticleObject;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('updateParticleObjects', () => {
|
|
23
|
+
it('does nothing when objects array is empty', () => {
|
|
24
|
+
const state = createParticleObjectsState(0);
|
|
25
|
+
const config = createParticleEmitterConfig();
|
|
26
|
+
expect(() => updateParticleObjects([], state, config, 1)).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('spawns objects from the pool', () => {
|
|
30
|
+
const objects = [makeObject(), makeObject(), makeObject()];
|
|
31
|
+
const state = createParticleObjectsState(3);
|
|
32
|
+
const config = createParticleEmitterConfig({ spawnRate: 2, lifetimeMin: 10, lifetimeMax: 10 });
|
|
33
|
+
updateParticleObjects(objects, state, config, 1);
|
|
34
|
+
const liveCount = objects.filter((o) => o.visible).length;
|
|
35
|
+
expect(liveCount).toBe(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('sets spawned objects to visible and positions them at origin', () => {
|
|
39
|
+
const objects = [makeObject()];
|
|
40
|
+
const state = createParticleObjectsState(1);
|
|
41
|
+
const config = createParticleEmitterConfig({
|
|
42
|
+
spawnRate: 1,
|
|
43
|
+
lifetimeMin: 10,
|
|
44
|
+
lifetimeMax: 10,
|
|
45
|
+
speedMin: 0,
|
|
46
|
+
speedMax: 0,
|
|
47
|
+
});
|
|
48
|
+
updateParticleObjects(objects, state, config, 1);
|
|
49
|
+
expect(objects[0].visible).toBe(true);
|
|
50
|
+
expect(objects[0].x).toBe(0);
|
|
51
|
+
expect(objects[0].y).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('kills objects when their lifetime expires', () => {
|
|
55
|
+
const objects = [makeObject()];
|
|
56
|
+
const state = createParticleObjectsState(1);
|
|
57
|
+
const config = createParticleEmitterConfig({
|
|
58
|
+
spawnRate: 1,
|
|
59
|
+
lifetimeMin: 0.5,
|
|
60
|
+
lifetimeMax: 0.5,
|
|
61
|
+
speedMin: 0,
|
|
62
|
+
speedMax: 0,
|
|
63
|
+
});
|
|
64
|
+
updateParticleObjects(objects, state, config, 1); // spawn (spawnRate*deltaTime=1)
|
|
65
|
+
expect(objects[0].visible).toBe(true);
|
|
66
|
+
updateParticleObjects(objects, state, config, 0.6); // advance past lifetime
|
|
67
|
+
expect(objects[0].visible).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('moves objects according to velocity and gravity', () => {
|
|
71
|
+
const objects = [makeObject()];
|
|
72
|
+
const state = createParticleObjectsState(1);
|
|
73
|
+
const config = createParticleEmitterConfig({
|
|
74
|
+
spawnRate: 1,
|
|
75
|
+
lifetimeMin: 10,
|
|
76
|
+
lifetimeMax: 10,
|
|
77
|
+
speedMin: 0,
|
|
78
|
+
speedMax: 0,
|
|
79
|
+
gravityX: 100,
|
|
80
|
+
gravityY: 0,
|
|
81
|
+
});
|
|
82
|
+
updateParticleObjects(objects, state, config, 1); // spawn at (0,0)
|
|
83
|
+
updateParticleObjects(objects, state, config, 1); // integrate gravity
|
|
84
|
+
expect(objects[0].x).toBeGreaterThan(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('fades alpha from alphaStart to alphaEnd over lifetime', () => {
|
|
88
|
+
const objects = [makeObject()];
|
|
89
|
+
const state = createParticleObjectsState(1);
|
|
90
|
+
const config = createParticleEmitterConfig({
|
|
91
|
+
spawnRate: 1,
|
|
92
|
+
lifetimeMin: 1,
|
|
93
|
+
lifetimeMax: 1,
|
|
94
|
+
alphaStart: 1,
|
|
95
|
+
alphaEnd: 0,
|
|
96
|
+
speedMin: 0,
|
|
97
|
+
speedMax: 0,
|
|
98
|
+
});
|
|
99
|
+
updateParticleObjects(objects, state, config, 1); // spawn 1 particle
|
|
100
|
+
updateParticleObjects(objects, state, config, 0.5); // advance to half lifetime
|
|
101
|
+
expect(objects[0].alpha).toBeCloseTo(0.5, 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('respects maxParticles — does not exceed pool size', () => {
|
|
105
|
+
const objects = [makeObject(), makeObject()];
|
|
106
|
+
const state = createParticleObjectsState(2);
|
|
107
|
+
const config = createParticleEmitterConfig({
|
|
108
|
+
spawnRate: 100,
|
|
109
|
+
lifetimeMin: 10,
|
|
110
|
+
lifetimeMax: 10,
|
|
111
|
+
});
|
|
112
|
+
updateParticleObjects(objects, state, config, 1);
|
|
113
|
+
const liveCount = objects.filter((o) => o.visible).length;
|
|
114
|
+
expect(liveCount).toBeLessThanOrEqual(objects.length);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('can re-use dead slots for new spawns', () => {
|
|
118
|
+
const objects = [makeObject()];
|
|
119
|
+
const state = createParticleObjectsState(1);
|
|
120
|
+
const config = createParticleEmitterConfig({
|
|
121
|
+
spawnRate: 2,
|
|
122
|
+
lifetimeMin: 0.1,
|
|
123
|
+
lifetimeMax: 0.1,
|
|
124
|
+
speedMin: 0,
|
|
125
|
+
speedMax: 0,
|
|
126
|
+
});
|
|
127
|
+
updateParticleObjects(objects, state, config, 1); // spawn (kills immediately in next frame)
|
|
128
|
+
updateParticleObjects(objects, state, config, 1); // kills expired, spawns fresh one
|
|
129
|
+
expect(objects[0].visible).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('animates scale over lifetime when scaleEnd differs from 1', () => {
|
|
133
|
+
const objects = [makeObject()];
|
|
134
|
+
const state = createParticleObjectsState(1);
|
|
135
|
+
const config = createParticleEmitterConfig({
|
|
136
|
+
spawnRate: 1,
|
|
137
|
+
lifetimeMin: 1,
|
|
138
|
+
lifetimeMax: 1,
|
|
139
|
+
speedMin: 0,
|
|
140
|
+
speedMax: 0,
|
|
141
|
+
scaleMin: 2,
|
|
142
|
+
scaleMax: 2,
|
|
143
|
+
scaleEnd: 0,
|
|
144
|
+
});
|
|
145
|
+
updateParticleObjects(objects, state, config, 1); // spawn
|
|
146
|
+
updateParticleObjects(objects, state, config, 0.5); // half-life → scale=1
|
|
147
|
+
expect(objects[0].scaleX).toBeCloseTo(1, 1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rotates objects at their per-particle rotation speed', () => {
|
|
151
|
+
const objects = [makeObject()];
|
|
152
|
+
const state = createParticleObjectsState(1);
|
|
153
|
+
const config = createParticleEmitterConfig({
|
|
154
|
+
spawnRate: 1,
|
|
155
|
+
lifetimeMin: 10,
|
|
156
|
+
lifetimeMax: 10,
|
|
157
|
+
speedMin: 0,
|
|
158
|
+
speedMax: 0,
|
|
159
|
+
spread: 0,
|
|
160
|
+
directionX: 0,
|
|
161
|
+
directionY: -1,
|
|
162
|
+
rotationSpeedMin: Math.PI,
|
|
163
|
+
rotationSpeedMax: Math.PI,
|
|
164
|
+
});
|
|
165
|
+
updateParticleObjects(objects, state, config, 1); // spawn
|
|
166
|
+
const rotBefore = objects[0].rotation;
|
|
167
|
+
updateParticleObjects(objects, state, config, 1); // advance 1s
|
|
168
|
+
expect(objects[0].rotation - rotBefore).toBeCloseTo(Math.PI, 3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('spawns objects within circle emitter shape', () => {
|
|
172
|
+
const objects = Array.from({ length: 20 }, makeObject);
|
|
173
|
+
const state = createParticleObjectsState(20);
|
|
174
|
+
const config = createParticleEmitterConfig({
|
|
175
|
+
spawnRate: 20,
|
|
176
|
+
maxParticles: 20,
|
|
177
|
+
lifetimeMin: 10,
|
|
178
|
+
lifetimeMax: 10,
|
|
179
|
+
speedMin: 0,
|
|
180
|
+
speedMax: 0,
|
|
181
|
+
emitterShape: 'circle',
|
|
182
|
+
emitterRadius: 50,
|
|
183
|
+
});
|
|
184
|
+
updateParticleObjects(objects, state, config, 1);
|
|
185
|
+
for (const obj of objects.filter((o) => o.visible)) {
|
|
186
|
+
expect(Math.sqrt(obj.x * obj.x + obj.y * obj.y)).toBeLessThanOrEqual(50 + 1e-4);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('fires a one-shot burst on first frame', () => {
|
|
191
|
+
const objects = Array.from({ length: 30 }, makeObject);
|
|
192
|
+
const state = createParticleObjectsState(30);
|
|
193
|
+
const config = createParticleEmitterConfig({
|
|
194
|
+
spawnRate: 0,
|
|
195
|
+
burstCount: 10,
|
|
196
|
+
burstInterval: 0,
|
|
197
|
+
lifetimeMin: 10,
|
|
198
|
+
lifetimeMax: 10,
|
|
199
|
+
});
|
|
200
|
+
updateParticleObjects(objects, state, config, 1 / 60);
|
|
201
|
+
expect(objects.filter((o) => o.visible).length).toBe(10);
|
|
202
|
+
updateParticleObjects(objects, state, config, 1 / 60);
|
|
203
|
+
expect(objects.filter((o) => o.visible).length).toBe(10); // no second burst
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('fires onSpawn callback for each spawned object', () => {
|
|
207
|
+
const objects = [makeObject(), makeObject()];
|
|
208
|
+
const state = createParticleObjectsState(2);
|
|
209
|
+
const config = createParticleEmitterConfig({ spawnRate: 2, lifetimeMin: 10, lifetimeMax: 10 });
|
|
210
|
+
let count = 0;
|
|
211
|
+
updateParticleObjects(objects, state, config, 1, {
|
|
212
|
+
callbacks: {
|
|
213
|
+
onSpawn: () => {
|
|
214
|
+
count++;
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
expect(count).toBe(2);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('ignores a zero-deltaTime frame: no spawning or velocity corruption', () => {
|
|
222
|
+
const objects = [makeObject()];
|
|
223
|
+
const state = createParticleObjectsState(1);
|
|
224
|
+
const config = createParticleEmitterConfig({
|
|
225
|
+
spawnRate: 0,
|
|
226
|
+
burstCount: 1,
|
|
227
|
+
burstInterval: 0,
|
|
228
|
+
lifetimeMin: 10,
|
|
229
|
+
lifetimeMax: 10,
|
|
230
|
+
speedMin: 0,
|
|
231
|
+
speedMax: 0,
|
|
232
|
+
spread: 0,
|
|
233
|
+
velocityInheritance: 1,
|
|
234
|
+
});
|
|
235
|
+
// Establish a previous emitter position, then move it and step with deltaTime=0.
|
|
236
|
+
updateParticleObjects(objects, state, config, 1 / 60, { emitterX: 0, emitterY: 0 });
|
|
237
|
+
const liveBefore = objects.filter((o) => o.visible).length;
|
|
238
|
+
state.burstTimer = 0; // arm a burst that would otherwise fire next step
|
|
239
|
+
updateParticleObjects(objects, state, config, 0, { emitterX: 100, emitterY: 0 });
|
|
240
|
+
expect(objects.filter((o) => o.visible).length).toBe(liveBefore); // nothing new spawned
|
|
241
|
+
expect(Number.isFinite(state.velocities[0])).toBe(true);
|
|
242
|
+
expect(Number.isFinite(state.velocities[1])).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('ignores a negative-deltaTime frame', () => {
|
|
246
|
+
const objects = [makeObject(), makeObject()];
|
|
247
|
+
const state = createParticleObjectsState(2);
|
|
248
|
+
const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 10, lifetimeMax: 10 });
|
|
249
|
+
updateParticleObjects(objects, state, config, 1); // spawn
|
|
250
|
+
const liveBefore = objects.filter((o) => o.visible).length;
|
|
251
|
+
updateParticleObjects(objects, state, config, -1);
|
|
252
|
+
expect(objects.filter((o) => o.visible).length).toBe(liveBefore);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('a finite, non-looping object emitter stops spawning after its duration', () => {
|
|
256
|
+
const objects = Array.from({ length: 50 }, makeObject);
|
|
257
|
+
const state = createParticleObjectsState(50);
|
|
258
|
+
const config = createParticleEmitterConfig({
|
|
259
|
+
spawnRate: 10,
|
|
260
|
+
lifetimeMin: 100, // long-lived so none die during the test window
|
|
261
|
+
lifetimeMax: 100,
|
|
262
|
+
duration: 1,
|
|
263
|
+
loop: false,
|
|
264
|
+
});
|
|
265
|
+
// Step well past the 1s duration so emission has definitely stopped.
|
|
266
|
+
for (let i = 0; i < 20; i++) updateParticleObjects(objects, state, config, 0.1);
|
|
267
|
+
const liveAfterDuration = objects.filter((o) => o.visible).length;
|
|
268
|
+
expect(liveAfterDuration).toBeGreaterThan(0);
|
|
269
|
+
// Stepping further spawns nothing more (and nothing dies yet).
|
|
270
|
+
for (let i = 0; i < 20; i++) updateParticleObjects(objects, state, config, 0.1);
|
|
271
|
+
expect(objects.filter((o) => o.visible).length).toBe(liveAfterDuration);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('isParticleObjectsComplete', () => {
|
|
275
|
+
it('is true once finished and all objects are dead', () => {
|
|
276
|
+
const objects = Array.from({ length: 50 }, makeObject);
|
|
277
|
+
const state = createParticleObjectsState(50);
|
|
278
|
+
const config = createParticleEmitterConfig({
|
|
279
|
+
spawnRate: 10,
|
|
280
|
+
lifetimeMin: 0.5,
|
|
281
|
+
lifetimeMax: 0.5,
|
|
282
|
+
duration: 1,
|
|
283
|
+
loop: false,
|
|
284
|
+
});
|
|
285
|
+
expect(isParticleObjectsComplete(objects, state, config)).toBe(false);
|
|
286
|
+
for (let i = 0; i < 10; i++) updateParticleObjects(objects, state, config, 0.1); // emit 1s
|
|
287
|
+
expect(isParticleObjectsComplete(objects, state, config)).toBe(false); // still alive
|
|
288
|
+
for (let i = 0; i < 10; i++) updateParticleObjects(objects, state, config, 0.1); // let them die
|
|
289
|
+
expect(objects.some((o) => o.visible)).toBe(false);
|
|
290
|
+
expect(isParticleObjectsComplete(objects, state, config)).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('is always false for infinite or looping emitters', () => {
|
|
294
|
+
const objects = [makeObject()];
|
|
295
|
+
const state = createParticleObjectsState(1);
|
|
296
|
+
expect(isParticleObjectsComplete(objects, state, createParticleEmitterConfig({ duration: 0 }))).toBe(false);
|
|
297
|
+
expect(isParticleObjectsComplete(objects, state, createParticleEmitterConfig({ duration: 1, loop: true }))).toBe(
|
|
298
|
+
false,
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('applies alpha and scale curves over lifetime', () => {
|
|
304
|
+
const objects = [makeObject()];
|
|
305
|
+
const state = createParticleObjectsState(1);
|
|
306
|
+
const config = createParticleEmitterConfig({
|
|
307
|
+
spawnRate: 1,
|
|
308
|
+
lifetimeMin: 1,
|
|
309
|
+
lifetimeMax: 1,
|
|
310
|
+
speedMin: 0,
|
|
311
|
+
speedMax: 0,
|
|
312
|
+
scaleMin: 4,
|
|
313
|
+
scaleMax: 4,
|
|
314
|
+
alphaCurve: [1, 0, 1],
|
|
315
|
+
scaleCurve: [1, 0.5, 0],
|
|
316
|
+
});
|
|
317
|
+
updateParticleObjects(objects, state, config, 1); // spawn
|
|
318
|
+
updateParticleObjects(objects, state, config, 0.5); // mid-life
|
|
319
|
+
expect(objects[0].alpha).toBeCloseTo(0, 2); // alpha curve dips to 0
|
|
320
|
+
expect(objects[0].scaleX).toBeCloseTo(2, 2); // 4 * 0.5
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('uses the injected RNG for deterministic spawning', () => {
|
|
324
|
+
const config = createParticleEmitterConfig({
|
|
325
|
+
spawnRate: 5,
|
|
326
|
+
lifetimeMin: 10,
|
|
327
|
+
lifetimeMax: 10,
|
|
328
|
+
speedMin: 10,
|
|
329
|
+
speedMax: 200,
|
|
330
|
+
spread: Math.PI,
|
|
331
|
+
});
|
|
332
|
+
const run = (): number[] => {
|
|
333
|
+
const objects = Array.from({ length: 20 }, makeObject);
|
|
334
|
+
const state = createParticleObjectsState(20, createRandomSource(12345));
|
|
335
|
+
updateParticleObjects(objects, state, config, 1);
|
|
336
|
+
return objects.map((o) => o.rotation);
|
|
337
|
+
};
|
|
338
|
+
expect(run()).toEqual(run());
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createParticleEmitter } from '@flighthq/sprite';
|
|
2
|
+
import type { TextureAtlas } from '@flighthq/types';
|
|
3
|
+
|
|
4
|
+
import { createParticleEmitterConfig } from './particleEmitterConfig';
|
|
5
|
+
import { createParticleEmitterState } from './particleEmitterState';
|
|
6
|
+
import { updateParticleEmitter } from './updateParticleEmitter';
|
|
7
|
+
import { normalizeParticleEmitterConfig, validateParticleEmitterConfig } from './validateParticleEmitterConfig';
|
|
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('normalizeParticleEmitterConfig', () => {
|
|
17
|
+
it('leaves a valid config materially unchanged', () => {
|
|
18
|
+
const input = createParticleEmitterConfig({ spawnRate: 25, maxParticles: 500, lifetimeMin: 1, lifetimeMax: 2 });
|
|
19
|
+
const out = normalizeParticleEmitterConfig(input);
|
|
20
|
+
expect(out.spawnRate).toBe(25);
|
|
21
|
+
expect(out.maxParticles).toBe(500);
|
|
22
|
+
expect(out.lifetimeMin).toBe(1);
|
|
23
|
+
expect(out.lifetimeMax).toBe(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('replaces non-finite fields with their defaults', () => {
|
|
27
|
+
const out = normalizeParticleEmitterConfig({ gravityY: NaN, spawnRate: Infinity, lifetimeMax: -Infinity });
|
|
28
|
+
expect(out.gravityY).toBe(0); // default
|
|
29
|
+
expect(out.spawnRate).toBe(10); // default
|
|
30
|
+
expect(Number.isFinite(out.lifetimeMax)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('clamps negatives and floors integer fields', () => {
|
|
34
|
+
const out = normalizeParticleEmitterConfig({
|
|
35
|
+
maxParticles: -3,
|
|
36
|
+
spawnRate: -10,
|
|
37
|
+
burstCount: 4.7,
|
|
38
|
+
frameCount: 0,
|
|
39
|
+
emitterRadius: -50,
|
|
40
|
+
});
|
|
41
|
+
expect(out.maxParticles).toBe(0);
|
|
42
|
+
expect(out.spawnRate).toBe(0);
|
|
43
|
+
expect(out.burstCount).toBe(4);
|
|
44
|
+
expect(out.frameCount).toBe(1);
|
|
45
|
+
expect(out.emitterRadius).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps regionIdMax >= regionIdMin', () => {
|
|
49
|
+
const out = normalizeParticleEmitterConfig({ regionIdMin: 5, regionIdMax: 2 });
|
|
50
|
+
expect(out.regionIdMax).toBeGreaterThanOrEqual(out.regionIdMin);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('drops a curve containing non-finite samples (falls back to linear)', () => {
|
|
54
|
+
const out = normalizeParticleEmitterConfig({
|
|
55
|
+
alphaCurve: [1, NaN, 0],
|
|
56
|
+
scaleCurve: [],
|
|
57
|
+
colorCurve: [0, 0, 0, 1, 1, 1],
|
|
58
|
+
});
|
|
59
|
+
expect(out.alphaCurve).toBeNull(); // had NaN → dropped
|
|
60
|
+
expect(out.scaleCurve).toBeNull(); // empty → dropped
|
|
61
|
+
expect(out.colorCurve).not.toBeNull(); // valid → kept
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('validate flags a non-finite curve sample as an error', () => {
|
|
65
|
+
const issues = validateParticleEmitterConfig(createParticleEmitterConfig({ alphaCurve: [0, NaN, 1] }));
|
|
66
|
+
expect(issues.some((i) => i.field === 'alphaCurve' && i.severity === 'error')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('produces a config that simulates without NaN poisoning', () => {
|
|
70
|
+
// A pathologically corrupt config that would otherwise inject NaN everywhere.
|
|
71
|
+
const corrupt = createParticleEmitterConfig({
|
|
72
|
+
spawnRate: NaN,
|
|
73
|
+
lifetimeMin: NaN,
|
|
74
|
+
lifetimeMax: NaN,
|
|
75
|
+
gravityY: Infinity,
|
|
76
|
+
speedMin: NaN,
|
|
77
|
+
speedMax: NaN,
|
|
78
|
+
});
|
|
79
|
+
const safe = normalizeParticleEmitterConfig(corrupt);
|
|
80
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
81
|
+
const state = createParticleEmitterState();
|
|
82
|
+
for (let i = 0; i < 30; i++) updateParticleEmitter(emitter, state, safe, 1 / 60);
|
|
83
|
+
expect(emitter.data.particleCount).toBeGreaterThan(0);
|
|
84
|
+
for (let i = 0; i < emitter.data.particleCount * 4; i++) {
|
|
85
|
+
expect(Number.isFinite(emitter.data.transforms[i])).toBe(true);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('validateParticleEmitterConfig', () => {
|
|
91
|
+
it('reports no issues for a default config', () => {
|
|
92
|
+
expect(validateParticleEmitterConfig(createParticleEmitterConfig())).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('flags non-finite numeric fields as errors', () => {
|
|
96
|
+
const config = createParticleEmitterConfig({ gravityY: NaN, spawnRate: Infinity });
|
|
97
|
+
const issues = validateParticleEmitterConfig(config);
|
|
98
|
+
const errors = issues.filter((i) => i.severity === 'error').map((i) => i.field);
|
|
99
|
+
expect(errors).toContain('gravityY');
|
|
100
|
+
expect(errors).toContain('spawnRate');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('flags negative counts/rates as warnings', () => {
|
|
104
|
+
const issues = validateParticleEmitterConfig(createParticleEmitterConfig({ spawnRate: -5, maxParticles: -1 }));
|
|
105
|
+
expect(issues.some((i) => i.field === 'spawnRate' && i.severity === 'warning')).toBe(true);
|
|
106
|
+
expect(issues.some((i) => i.field === 'maxParticles')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('flags inverted min/max ranges', () => {
|
|
110
|
+
const issues = validateParticleEmitterConfig(createParticleEmitterConfig({ lifetimeMin: 5, lifetimeMax: 1 }));
|
|
111
|
+
expect(issues.some((i) => i.field === 'lifetimeMin')).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('flags alpha outside the 0–1 range', () => {
|
|
115
|
+
const issues = validateParticleEmitterConfig(createParticleEmitterConfig({ alphaStart: 2 }));
|
|
116
|
+
expect(issues.some((i) => i.field === 'alphaStart')).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|