@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
package/dist/index.d.ts CHANGED
@@ -1,14 +1,11 @@
1
1
  export * from './applyParticleCollisions';
2
2
  export * from './applyParticleForces';
3
3
  export * from './curve';
4
- export * from './emitParticleBurst';
5
4
  export * from './particleEmitterConfig';
6
5
  export * from './particleEmitterSignals';
7
6
  export * from './particleEmitterState';
8
7
  export * from './particleObjectsState';
9
- export * from './prewarmParticleEmitter';
10
- export * from './stepParticleEmitter';
11
- export * from './updateParticleEmitter';
8
+ export * from './stepParticleObjects';
12
9
  export * from './updateParticleObjects';
13
10
  export * from './validateParticleEmitterConfig';
14
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,SAAS,CAAC;AACxB,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC;AACxC,cAAc,iCAAiC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,SAAS,CAAC;AACxB,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,iCAAiC,CAAC"}
package/dist/index.js CHANGED
@@ -1,14 +1,11 @@
1
1
  export * from './applyParticleCollisions';
2
2
  export * from './applyParticleForces';
3
3
  export * from './curve';
4
- export * from './emitParticleBurst';
5
4
  export * from './particleEmitterConfig';
6
5
  export * from './particleEmitterSignals';
7
6
  export * from './particleEmitterState';
8
7
  export * from './particleObjectsState';
9
- export * from './prewarmParticleEmitter';
10
- export * from './stepParticleEmitter';
11
- export * from './updateParticleEmitter';
8
+ export * from './stepParticleObjects';
12
9
  export * from './updateParticleObjects';
13
10
  export * from './validateParticleEmitterConfig';
14
11
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,SAAS,CAAC;AACxB,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC;AACxC,cAAc,iCAAiC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,SAAS,CAAC;AACxB,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,iCAAiC,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { ParticleCollider, ParticleEmitterConfig, ParticleForce, ParticleObject, ParticleObjectsState, ParticleObjectsUpdateOptions } from '@flighthq/types';
2
+ /**
3
+ * Convenience wrapper for the object-pool path that folds the canonical three-step update sequence
4
+ * — `applyParticleObjectForces` → `updateParticleObjects` → `applyParticleObjectCollisions` — into
5
+ * a single call.
6
+ *
7
+ * The three primitives remain exported for advanced use. Use this function for the common case
8
+ * where all forces and colliders apply each frame in the documented order.
9
+ *
10
+ * @param objects - The particle object pool (one display object per slot).
11
+ * @param state - Mutable per-emitter simulation state.
12
+ * @param config - Immutable emitter configuration.
13
+ * @param deltaTime - Elapsed time in seconds since the last step.
14
+ * @param forces - Optional force fields to apply before integration.
15
+ * @param colliders - Optional colliders to resolve after integration.
16
+ * @param updateOptions - Optional emitter position, callbacks, and other per-frame options.
17
+ */
18
+ export declare function stepParticleObjects(objects: readonly ParticleObject[], state: ParticleObjectsState, config: Readonly<ParticleEmitterConfig>, deltaTime: number, forces?: ReadonlyArray<ParticleForce>, colliders?: ReadonlyArray<ParticleCollider>, updateOptions?: ParticleObjectsUpdateOptions): void;
19
+ //# sourceMappingURL=stepParticleObjects.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stepParticleObjects.d.ts","sourceRoot":"","sources":["../src/stepParticleObjects.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,qBAAqB,EACrB,aAAa,EACb,cAAc,EACd,oBAAoB,EACpB,4BAA4B,EAC7B,MAAM,iBAAiB,CAAC;AAMzB;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,SAAS,cAAc,EAAE,EAClC,KAAK,EAAE,oBAAoB,EAC3B,MAAM,EAAE,QAAQ,CAAC,qBAAqB,CAAC,EACvC,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,aAAa,CAAC,aAAa,CAAC,EACrC,SAAS,CAAC,EAAE,aAAa,CAAC,gBAAgB,CAAC,EAC3C,aAAa,CAAC,EAAE,4BAA4B,GAC3C,IAAI,CAQN"}
@@ -0,0 +1,29 @@
1
+ import { applyParticleObjectCollisions } from './applyParticleCollisions';
2
+ import { applyParticleObjectForces } from './applyParticleForces';
3
+ import { updateParticleObjects } from './updateParticleObjects';
4
+ /**
5
+ * Convenience wrapper for the object-pool path that folds the canonical three-step update sequence
6
+ * — `applyParticleObjectForces` → `updateParticleObjects` → `applyParticleObjectCollisions` — into
7
+ * a single call.
8
+ *
9
+ * The three primitives remain exported for advanced use. Use this function for the common case
10
+ * where all forces and colliders apply each frame in the documented order.
11
+ *
12
+ * @param objects - The particle object pool (one display object per slot).
13
+ * @param state - Mutable per-emitter simulation state.
14
+ * @param config - Immutable emitter configuration.
15
+ * @param deltaTime - Elapsed time in seconds since the last step.
16
+ * @param forces - Optional force fields to apply before integration.
17
+ * @param colliders - Optional colliders to resolve after integration.
18
+ * @param updateOptions - Optional emitter position, callbacks, and other per-frame options.
19
+ */
20
+ export function stepParticleObjects(objects, state, config, deltaTime, forces, colliders, updateOptions) {
21
+ if (forces != null && forces.length > 0) {
22
+ applyParticleObjectForces(objects, state, forces, deltaTime);
23
+ }
24
+ updateParticleObjects(objects, state, config, deltaTime, updateOptions);
25
+ if (colliders != null && colliders.length > 0) {
26
+ applyParticleObjectCollisions(objects, state, colliders);
27
+ }
28
+ }
29
+ //# sourceMappingURL=stepParticleObjects.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stepParticleObjects.js","sourceRoot":"","sources":["../src/stepParticleObjects.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,6BAA6B,EAAE,MAAM,2BAA2B,CAAC;AAC1E,OAAO,EAAE,yBAAyB,EAAE,MAAM,uBAAuB,CAAC;AAClE,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAEhE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,mBAAmB,CACjC,OAAkC,EAClC,KAA2B,EAC3B,MAAuC,EACvC,SAAiB,EACjB,MAAqC,EACrC,SAA2C,EAC3C,aAA4C;IAE5C,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxC,yBAAyB,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;IAC/D,CAAC;IACD,qBAAqB,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,CAAC;IACxE,IAAI,SAAS,IAAI,IAAI,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC9C,6BAA6B,CAAC,OAAO,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC;IAC3D,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@flighthq/particles",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/flighthq/flight.git",
7
+ "directory": "packages/particles"
8
+ },
4
9
  "type": "module",
5
10
  "main": "dist/index.js",
6
11
  "types": "dist/index.d.ts",
@@ -27,14 +32,12 @@
27
32
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
33
  },
29
34
  "dependencies": {
30
- "@flighthq/geometry": "0.1.0",
31
- "@flighthq/math": "0.1.0",
32
- "@flighthq/node": "0.1.0",
33
- "@flighthq/signals": "0.1.0",
34
- "@flighthq/sprite": "0.1.0",
35
- "@flighthq/types": "0.1.0"
35
+ "@flighthq/geometry": "0.2.0",
36
+ "@flighthq/signals": "0.2.0",
37
+ "@flighthq/types": "0.2.0"
36
38
  },
37
39
  "devDependencies": {
40
+ "@flighthq/math": "*",
38
41
  "typescript": "^5.3.0"
39
42
  },
40
43
  "description": "Particle system: emitter simulation and typed-array-backed particle primitives",
@@ -1,32 +1,32 @@
1
- import { createParticleEmitter } from '@flighthq/sprite';
2
- import type { TextureAtlas } from '@flighthq/types';
1
+ import type { ParticleEmitter, ParticleEmitterData } from '@flighthq/types';
3
2
 
4
3
  import { applyParticleCollisions, applyParticleObjectCollisions } from './applyParticleCollisions';
5
- import { createParticleEmitterConfig } from './particleEmitterConfig';
6
- import { createParticleEmitterState } from './particleEmitterState';
4
+ import { createParticleEmitterState, ensureParticleEmitterStateCapacity } from './particleEmitterState';
7
5
  import { createParticleObjectsState } from './particleObjectsState';
8
- import { updateParticleEmitter } from './updateParticleEmitter';
9
6
  import type { ParticleObject } from './updateParticleObjects';
10
7
 
11
- function makeAtlas(): TextureAtlas {
12
- return {
13
- image: null,
14
- regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
15
- } as TextureAtlas;
8
+ // Node-free SoA emitter fixture: applyParticleCollisions reads only `emitter.data`, so the display
9
+ // node from @flighthq/particleemitter is not needed to unit-test the collision pass.
10
+ function createEmitterFixture(capacity: number): ParticleEmitter {
11
+ const data: ParticleEmitterData = {
12
+ alphas: new Float32Array(capacity),
13
+ atlas: null,
14
+ colors: new Float32Array(capacity * 3),
15
+ ids: new Uint16Array(capacity),
16
+ particleCount: 0,
17
+ transforms: new Float32Array(capacity * 4),
18
+ velocities: new Float32Array(capacity * 2),
19
+ worldSpace: false,
20
+ };
21
+ return { data, x: 0, y: 0 } as unknown as ParticleEmitter;
16
22
  }
17
23
 
18
- // Spawn one particle, then place it at a known position with a known velocity.
24
+ // One particle at a known position with a known velocity.
19
25
  function place(px: number, py: number, vx: number, vy: number) {
20
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
26
+ const emitter = createEmitterFixture(1);
21
27
  const state = createParticleEmitterState();
22
- const config = createParticleEmitterConfig({
23
- spawnRate: 1,
24
- lifetimeMin: 100,
25
- lifetimeMax: 100,
26
- speedMin: 0,
27
- speedMax: 0,
28
- });
29
- updateParticleEmitter(emitter, state, config, 1);
28
+ ensureParticleEmitterStateCapacity(state, 1, false);
29
+ emitter.data.particleCount = 1;
30
30
  emitter.data.transforms[0] = px;
31
31
  emitter.data.transforms[1] = py;
32
32
  state.velocities[0] = vx;
@@ -55,18 +55,6 @@ describe('applyParticleCollisions', () => {
55
55
  });
56
56
 
57
57
  describe('integration', () => {
58
- it('settles a falling particle onto a floor over multiple frames', () => {
59
- const { emitter, state } = place(0, 480, 0, 0);
60
- const config = createParticleEmitterConfig({ spawnRate: 0, lifetimeMin: 100, lifetimeMax: 100, gravityY: 2000 });
61
- const floor = [{ kind: 'PlaneCollider' as const, nx: 0, ny: -1, distance: -500 }];
62
- for (let i = 0; i < 60; i++) {
63
- updateParticleEmitter(emitter, state, config, 1 / 60);
64
- applyParticleCollisions(emitter, state, floor);
65
- }
66
- expect(emitter.data.transforms[1]).toBeLessThanOrEqual(500 + 1e-3); // never falls through
67
- expect(emitter.data.transforms[1]).toBeCloseTo(500, 0); // resting on the floor
68
- });
69
-
70
58
  it('is a no-op with no colliders', () => {
71
59
  const { emitter, state } = place(0, 520, 0, 100);
72
60
  applyParticleCollisions(emitter, state, []);
@@ -1,33 +1,35 @@
1
- import { createParticleEmitter } from '@flighthq/sprite';
2
- import type { TextureAtlas } from '@flighthq/types';
1
+ import type { ParticleEmitter, ParticleEmitterData } from '@flighthq/types';
3
2
 
4
3
  import { applyParticleForces, applyParticleObjectForces } from './applyParticleForces';
5
4
  import { createParticleEmitterConfig } from './particleEmitterConfig';
6
- import { createParticleEmitterState } from './particleEmitterState';
5
+ import { createParticleEmitterState, ensureParticleEmitterStateCapacity } from './particleEmitterState';
7
6
  import { createParticleObjectsState } from './particleObjectsState';
8
- import { updateParticleEmitter } from './updateParticleEmitter';
9
7
  import type { ParticleObject } from './updateParticleObjects';
10
8
  import { updateParticleObjects } from './updateParticleObjects';
11
9
 
12
- function makeAtlas(): TextureAtlas {
13
- return {
14
- image: null,
15
- regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
16
- } as TextureAtlas;
10
+ // Node-free SoA emitter fixture: applyParticleForces reads only `emitter.data`, so the display
11
+ // node from @flighthq/particleemitter is not needed to unit-test the force pass. Keeping this
12
+ // fixture local preserves particles as a pure sim leaf.
13
+ function createEmitterFixture(capacity: number): ParticleEmitter {
14
+ const data: ParticleEmitterData = {
15
+ alphas: new Float32Array(capacity),
16
+ atlas: null,
17
+ colors: new Float32Array(capacity * 3),
18
+ ids: new Uint16Array(capacity),
19
+ particleCount: 0,
20
+ transforms: new Float32Array(capacity * 4),
21
+ velocities: new Float32Array(capacity * 2),
22
+ worldSpace: false,
23
+ };
24
+ return { data, x: 0, y: 0 } as unknown as ParticleEmitter;
17
25
  }
18
26
 
19
- // Spawn a single stationary particle at the origin and return its handles.
27
+ // A single stationary particle at the origin with zeroed velocity state.
20
28
  function oneParticle() {
21
- const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
29
+ const emitter = createEmitterFixture(1);
22
30
  const state = createParticleEmitterState();
23
- const config = createParticleEmitterConfig({
24
- spawnRate: 1,
25
- lifetimeMin: 100,
26
- lifetimeMax: 100,
27
- speedMin: 0,
28
- speedMax: 0,
29
- });
30
- updateParticleEmitter(emitter, state, config, 1);
31
+ ensureParticleEmitterStateCapacity(state, 1, false);
32
+ emitter.data.particleCount = 1;
31
33
  return { emitter, state };
32
34
  }
33
35
 
@@ -117,24 +119,6 @@ describe('applyParticleForces', () => {
117
119
  applyParticleForces(emitter, state, [{ kind: 'WindForce', x: 5, y: 0 }], 0);
118
120
  expect(state.velocities[0]).toBe(42); // unchanged by either no-op call
119
121
  });
120
-
121
- it('integrates with the core update so attractors curve trajectories', () => {
122
- const { emitter, state } = oneParticle();
123
- const config = createParticleEmitterConfig({
124
- spawnRate: 0,
125
- speedMin: 0,
126
- speedMax: 0,
127
- lifetimeMin: 100,
128
- lifetimeMax: 100,
129
- });
130
- const forces = [{ kind: 'AttractorForce' as const, x: 100, y: 0, strength: 200 }];
131
- const startX = emitter.data.transforms[0];
132
- for (let i = 0; i < 10; i++) {
133
- applyParticleForces(emitter, state, forces, 1 / 60);
134
- updateParticleEmitter(emitter, state, config, 1 / 60);
135
- }
136
- expect(emitter.data.transforms[0]).toBeGreaterThan(startX); // drifted toward the attractor
137
- });
138
122
  });
139
123
 
140
124
  function makeObject(): ParticleObject {
package/src/curve.test.ts CHANGED
@@ -1,6 +1,3 @@
1
- import { createParticleEmitter } from '@flighthq/sprite';
2
- import type { TextureAtlas } from '@flighthq/types';
3
-
4
1
  import {
5
2
  buildParticleColorCurve,
6
3
  buildParticleCurve,
@@ -13,16 +10,6 @@ import {
13
10
  sampleParticleColorCurve,
14
11
  sampleParticleCurve,
15
12
  } 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
13
 
27
14
  describe('buildParticleColorCurve', () => {
28
15
  it('bakes an RGB function into an interleaved LUT', () => {
@@ -111,56 +98,6 @@ describe('lerpHsvInPlace', () => {
111
98
  });
112
99
  });
113
100
 
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
101
  describe('particleColorCurveFromKeyframes', () => {
165
102
  it('bakes an RGB timeline through its middle stop', () => {
166
103
  const lut = particleColorCurveFromKeyframes([
@@ -1,35 +1,9 @@
1
- import { createParticleEmitter } from '@flighthq/sprite';
2
- import type { TextureAtlas } from '@flighthq/types';
3
-
4
- import { createParticleEmitterConfig } from './particleEmitterConfig';
5
1
  import {
6
2
  createParticleEmitterSignals,
7
3
  enableParticleEmitterSignals,
8
4
  getParticleEmitterSignals,
9
5
  } from './particleEmitterSignals';
10
6
  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
7
 
34
8
  describe('createParticleEmitterSignals', () => {
35
9
  it('creates a signals group with all three signal slots', () => {
@@ -66,60 +40,3 @@ describe('getParticleEmitterSignals', () => {
66
40
  expect(getParticleEmitterSignals(state)).toBeNull();
67
41
  });
68
42
  });
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
- });
@@ -1,48 +1,6 @@
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
1
  import { createParticleEmitterState, ensureParticleEmitterStateCapacity } from './particleEmitterState';
7
- import { updateParticleEmitter } from './updateParticleEmitter';
8
-
9
- // Runs a short emitter simulation seeded from `seed` and returns the live particle transforms, so a
10
- // seeded state can be shown to simulate reproducibly (the seeded RNG now lives in @flighthq/math).
11
- function simulate(seed: number): number[] {
12
- const emitter = createParticleEmitter({
13
- data: {
14
- atlas: {
15
- image: null,
16
- regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
17
- } as TextureAtlas,
18
- },
19
- });
20
- const state = createParticleEmitterState(createRandomSource(seed));
21
- const config = createParticleEmitterConfig({
22
- spawnRate: 20,
23
- maxParticles: 100,
24
- lifetimeMin: 0.5,
25
- lifetimeMax: 1.5,
26
- speedMin: 10,
27
- speedMax: 200,
28
- spread: Math.PI,
29
- emitterShape: 'circle',
30
- emitterRadius: 25,
31
- colorStartVarianceR: 0.5,
32
- });
33
- for (let f = 0; f < 30; f++) updateParticleEmitter(emitter, state, config, 1 / 60);
34
- return Array.from(emitter.data.transforms.slice(0, emitter.data.particleCount * 4));
35
- }
36
2
 
37
3
  describe('createParticleEmitterState', () => {
38
- it('seeded identically, two states simulate bit-for-bit identically', () => {
39
- expect(simulate(42)).toEqual(simulate(42));
40
- });
41
-
42
- it('diverges under different seeds', () => {
43
- expect(simulate(42)).not.toEqual(simulate(43));
44
- });
45
-
46
4
  it('returns empty arrays, zero accumulator, burstTimer=0, and NaN prev position', () => {
47
5
  const state = createParticleEmitterState();
48
6
  expect(state.lifetimes).toBeInstanceOf(Float32Array);
@@ -0,0 +1,30 @@
1
+ import type { ParticleObject } from '@flighthq/types';
2
+
3
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
4
+ import { createParticleObjectsState } from './particleObjectsState';
5
+ import { stepParticleObjects } from './stepParticleObjects';
6
+
7
+ function makeObject(): ParticleObject {
8
+ return {
9
+ alpha: 1,
10
+ blendMode: null,
11
+ colorTransform: null,
12
+ rotation: 0,
13
+ scaleX: 1,
14
+ scaleY: 1,
15
+ visible: false,
16
+ x: 0,
17
+ y: 0,
18
+ } as unknown as ParticleObject;
19
+ }
20
+
21
+ describe('stepParticleObjects', () => {
22
+ it('spawns into dead slots when called with no forces or colliders', () => {
23
+ const objects = Array.from({ length: 10 }, makeObject);
24
+ const state = createParticleObjectsState(10);
25
+ const config = createParticleEmitterConfig({ spawnRate: 5, lifetimeMin: 10, lifetimeMax: 10 });
26
+ stepParticleObjects(objects, state, config, 1);
27
+ const visible = objects.filter((o) => o.visible).length;
28
+ expect(visible).toBe(5);
29
+ });
30
+ });
@@ -1,18 +1,6 @@
1
- import { createParticleEmitter } from '@flighthq/sprite';
2
- import type { TextureAtlas } from '@flighthq/types';
3
-
4
1
  import { createParticleEmitterConfig } from './particleEmitterConfig';
5
- import { createParticleEmitterState } from './particleEmitterState';
6
- import { updateParticleEmitter } from './updateParticleEmitter';
7
2
  import { normalizeParticleEmitterConfig, validateParticleEmitterConfig } from './validateParticleEmitterConfig';
8
3
 
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
4
  describe('normalizeParticleEmitterConfig', () => {
17
5
  it('leaves a valid config materially unchanged', () => {
18
6
  const input = createParticleEmitterConfig({ spawnRate: 25, maxParticles: 500, lifetimeMin: 1, lifetimeMax: 2 });
@@ -65,26 +53,6 @@ describe('normalizeParticleEmitterConfig', () => {
65
53
  const issues = validateParticleEmitterConfig(createParticleEmitterConfig({ alphaCurve: [0, NaN, 1] }));
66
54
  expect(issues.some((i) => i.field === 'alphaCurve' && i.severity === 'error')).toBe(true);
67
55
  });
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
56
  });
89
57
 
90
58
  describe('validateParticleEmitterConfig', () => {
@@ -1,15 +0,0 @@
1
- import type { ParticleEmitter } from '@flighthq/types';
2
- import type { ParticleEmitterConfig, ParticleEmitterState } from '@flighthq/types';
3
- /** Emit `count` particles immediately at an arbitrary point (in the emitter's
4
- * simulation space), independent of the emitter's own spawn rate, bursts, or
5
- * duration. Returns the number actually spawned (capped by `maxParticles`).
6
- *
7
- * This is the building block for sub-emitters: call it from an `onDeath` /
8
- * `onSpawn` callback to spawn a child effect at the event position (explosions,
9
- * debris, impact sparks), or once per live particle for trails. The child
10
- * emitter/state/config are completely independent of the parent.
11
- *
12
- * Spawn randomness is drawn from `state.random`, so a seeded child state keeps
13
- * sub-emitter output deterministic. */
14
- export declare function emitParticleBurst(emitter: ParticleEmitter, state: ParticleEmitterState, config: Readonly<ParticleEmitterConfig>, count: number, x: number, y: number): number;
15
- //# sourceMappingURL=emitParticleBurst.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"emitParticleBurst.d.ts","sourceRoot":"","sources":["../src/emitParticleBurst.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAQnF;;;;;;;;;;wCAUwC;AACxC,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,eAAe,EACxB,KAAK,EAAE,oBAAoB,EAC3B,MAAM,EAAE,QAAQ,CAAC,qBAAqB,CAAC,EACvC,KAAK,EAAE,MAAM,EACb,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,GACR,MAAM,CAqGR"}