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