@flighthq/particles 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.d.ts +1 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/stepParticleObjects.d.ts +19 -0
  6. package/dist/stepParticleObjects.d.ts.map +1 -0
  7. package/dist/stepParticleObjects.js +29 -0
  8. package/dist/stepParticleObjects.js.map +1 -0
  9. package/package.json +10 -7
  10. package/src/applyParticleCollisions.test.ts +20 -32
  11. package/src/applyParticleForces.test.ts +21 -37
  12. package/src/curve.test.ts +0 -63
  13. package/src/particleEmitterSignals.test.ts +0 -83
  14. package/src/particleEmitterState.test.ts +0 -42
  15. package/src/stepParticleObjects.test.ts +30 -0
  16. package/src/validateParticleEmitterConfig.test.ts +0 -32
  17. package/dist/emitParticleBurst.d.ts +0 -15
  18. package/dist/emitParticleBurst.d.ts.map +0 -1
  19. package/dist/emitParticleBurst.js +0 -114
  20. package/dist/emitParticleBurst.js.map +0 -1
  21. package/dist/prewarmParticleEmitter.d.ts +0 -5
  22. package/dist/prewarmParticleEmitter.d.ts.map +0 -1
  23. package/dist/prewarmParticleEmitter.js +0 -13
  24. package/dist/prewarmParticleEmitter.js.map +0 -1
  25. package/dist/stepParticleEmitter.d.ts +0 -38
  26. package/dist/stepParticleEmitter.d.ts.map +0 -1
  27. package/dist/stepParticleEmitter.js +0 -57
  28. package/dist/stepParticleEmitter.js.map +0 -1
  29. package/dist/updateParticleEmitter.d.ts +0 -8
  30. package/dist/updateParticleEmitter.d.ts.map +0 -1
  31. package/dist/updateParticleEmitter.js +0 -315
  32. package/dist/updateParticleEmitter.js.map +0 -1
  33. package/src/deterministic.test.ts +0 -54
  34. package/src/emitParticleBurst.test.ts +0 -103
  35. package/src/prewarmParticleEmitter.test.ts +0 -68
  36. package/src/stepParticleEmitter.test.ts +0 -113
  37. package/src/updateParticleEmitter.test.ts +0 -631
@@ -1,54 +0,0 @@
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
- });
@@ -1,103 +0,0 @@
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
- });
@@ -1,68 +0,0 @@
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 { prewarmParticleEmitter } from './prewarmParticleEmitter';
7
-
8
- function makeAtlas(): TextureAtlas {
9
- return {
10
- image: null,
11
- regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
12
- } as TextureAtlas;
13
- }
14
-
15
- describe('prewarmParticleEmitter', () => {
16
- it('produces live particles after warming for one lifetime', () => {
17
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
18
- const state = createParticleEmitterState();
19
- const config = createParticleEmitterConfig({ spawnRate: 20, lifetimeMin: 1, lifetimeMax: 1 });
20
- prewarmParticleEmitter(emitter, state, config, 1);
21
- expect(emitter.data.particleCount).toBeGreaterThan(0);
22
- });
23
-
24
- it('does not exceed maxParticles', () => {
25
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
26
- const state = createParticleEmitterState();
27
- const config = createParticleEmitterConfig({ spawnRate: 1000, maxParticles: 10, lifetimeMin: 10, lifetimeMax: 10 });
28
- prewarmParticleEmitter(emitter, state, config, 5);
29
- expect(emitter.data.particleCount).toBeLessThanOrEqual(10);
30
- });
31
-
32
- it('accepts a custom stepDeltaTime and still produces particles', () => {
33
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
34
- const state = createParticleEmitterState();
35
- const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 5, lifetimeMax: 5 });
36
- prewarmParticleEmitter(emitter, state, config, 1, 1 / 30);
37
- expect(emitter.data.particleCount).toBeGreaterThan(0);
38
- });
39
-
40
- it('fires onSpawn callback during warm-up', () => {
41
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
42
- const state = createParticleEmitterState();
43
- const config = createParticleEmitterConfig({ spawnRate: 5, lifetimeMin: 10, lifetimeMax: 10 });
44
- let spawned = 0;
45
- prewarmParticleEmitter(emitter, state, config, 1, 1 / 60, {
46
- onSpawn: () => {
47
- spawned++;
48
- },
49
- });
50
- expect(spawned).toBeGreaterThan(0);
51
- });
52
-
53
- it('leaves emitter unmodified when duration is zero', () => {
54
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
55
- const state = createParticleEmitterState();
56
- const config = createParticleEmitterConfig({ spawnRate: 100, lifetimeMin: 1, lifetimeMax: 1 });
57
- prewarmParticleEmitter(emitter, state, config, 0);
58
- expect(emitter.data.particleCount).toBe(0);
59
- });
60
-
61
- it('does not hang when stepDeltaTime is zero (falls back to a single step)', () => {
62
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
63
- const state = createParticleEmitterState();
64
- const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 10, lifetimeMax: 10 });
65
- prewarmParticleEmitter(emitter, state, config, 1, 0);
66
- expect(emitter.data.particleCount).toBeGreaterThan(0);
67
- });
68
- });
@@ -1,113 +0,0 @@
1
- import { createParticleEmitter } from '@flighthq/sprite';
2
- import type { ParticleCollider, ParticleForce, ParticleObject, TextureAtlas } from '@flighthq/types';
3
-
4
- import { createParticleEmitterConfig } from './particleEmitterConfig';
5
- import { createParticleEmitterState } from './particleEmitterState';
6
- import { createParticleObjectsState } from './particleObjectsState';
7
- import { stepParticleEmitter, stepParticleObjects } 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('stepParticleEmitter', () => {
17
- it('spawns particles when called with no forces or colliders', () => {
18
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
19
- const state = createParticleEmitterState();
20
- const config = createParticleEmitterConfig({ spawnRate: 10, maxParticles: 100 });
21
- stepParticleEmitter(emitter, state, config, 1);
22
- expect(emitter.data.particleCount).toBe(10);
23
- });
24
-
25
- it('applies wind force before update — particle receives velocity from wind', () => {
26
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
27
- const state = createParticleEmitterState();
28
- const config = createParticleEmitterConfig({
29
- spawnRate: 1,
30
- lifetimeMin: 10,
31
- lifetimeMax: 10,
32
- speedMin: 0,
33
- speedMax: 0,
34
- spread: 0,
35
- directionX: 0,
36
- directionY: -1,
37
- });
38
- // Spawn one particle first
39
- stepParticleEmitter(emitter, state, config, 1);
40
- expect(emitter.data.particleCount).toBe(1);
41
- // Now step with a wind force pushing right
42
- const wind: ParticleForce = { kind: 'WindForce', x: 200, y: 0 };
43
- stepParticleEmitter(emitter, state, config, 1, [wind]);
44
- // After wind: vx starts at 0, gets +200 from wind in applyForces, then particle moves right
45
- const x = emitter.data.transforms[0];
46
- expect(x).toBeGreaterThan(0);
47
- });
48
-
49
- it('resolves collisions after update — particle stays above a plane at y=0', () => {
50
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
51
- const state = createParticleEmitterState();
52
- const config = createParticleEmitterConfig({
53
- spawnRate: 1,
54
- lifetimeMin: 10,
55
- lifetimeMax: 10,
56
- speedMin: 100,
57
- speedMax: 100,
58
- spread: 0,
59
- directionX: 0,
60
- directionY: 1, // moving downward
61
- gravityY: 0,
62
- });
63
- stepParticleEmitter(emitter, state, config, 1);
64
- // Particle moves down; plane at y=0 (normal pointing up = (0,-1), distance 0)
65
- const plane: ParticleCollider = { kind: 'PlaneCollider', nx: 0, ny: -1, distance: 0, restitution: 1, friction: 0 };
66
- stepParticleEmitter(emitter, state, config, 1, undefined, [plane]);
67
- // Particle should be reflected above y=0
68
- const y = emitter.data.transforms[1];
69
- expect(y).toBeGreaterThanOrEqual(-1e-4); // at or above plane
70
- });
71
-
72
- it('passes callbacks down to updateParticleEmitter', () => {
73
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
74
- const state = createParticleEmitterState();
75
- const config = createParticleEmitterConfig({ spawnRate: 3, lifetimeMin: 10, lifetimeMax: 10 });
76
- let spawnCount = 0;
77
- stepParticleEmitter(emitter, state, config, 1, undefined, undefined, { onSpawn: () => spawnCount++ });
78
- expect(spawnCount).toBe(3);
79
- });
80
-
81
- it('is a no-op for zero deltaTime', () => {
82
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
83
- const state = createParticleEmitterState();
84
- const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 10, lifetimeMax: 10 });
85
- stepParticleEmitter(emitter, state, config, 0);
86
- expect(emitter.data.particleCount).toBe(0);
87
- });
88
- });
89
-
90
- describe('stepParticleObjects', () => {
91
- it('spawns into dead slots when called with no forces or colliders', () => {
92
- const objects = Array.from({ length: 10 }, makeObject);
93
- const state = createParticleObjectsState(10);
94
- const config = createParticleEmitterConfig({ spawnRate: 5, lifetimeMin: 10, lifetimeMax: 10 });
95
- stepParticleObjects(objects, state, config, 1);
96
- const visible = objects.filter((o) => o.visible).length;
97
- expect(visible).toBe(5);
98
- });
99
- });
100
-
101
- function makeObject(): ParticleObject {
102
- return {
103
- alpha: 1,
104
- blendMode: null,
105
- colorTransform: null,
106
- rotation: 0,
107
- scaleX: 1,
108
- scaleY: 1,
109
- visible: false,
110
- x: 0,
111
- y: 0,
112
- } as unknown as ParticleObject;
113
- }