@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.
Files changed (71) hide show
  1. package/dist/applyParticleCollisions.d.ts +12 -0
  2. package/dist/applyParticleCollisions.d.ts.map +1 -0
  3. package/dist/applyParticleCollisions.js +175 -0
  4. package/dist/applyParticleCollisions.js.map +1 -0
  5. package/dist/applyParticleForces.d.ts +13 -0
  6. package/dist/applyParticleForces.d.ts.map +1 -0
  7. package/dist/applyParticleForces.js +129 -0
  8. package/dist/applyParticleForces.js.map +1 -0
  9. package/dist/curve.d.ts +35 -0
  10. package/dist/curve.d.ts.map +1 -0
  11. package/dist/curve.js +227 -0
  12. package/dist/curve.js.map +1 -0
  13. package/dist/emitParticleBurst.d.ts +15 -0
  14. package/dist/emitParticleBurst.d.ts.map +1 -0
  15. package/dist/emitParticleBurst.js +114 -0
  16. package/dist/emitParticleBurst.js.map +1 -0
  17. package/dist/index.d.ts +14 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +14 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/particleEmitterConfig.d.ts +4 -0
  22. package/dist/particleEmitterConfig.d.ts.map +1 -0
  23. package/dist/particleEmitterConfig.js +53 -0
  24. package/dist/particleEmitterConfig.js.map +1 -0
  25. package/dist/particleEmitterSignals.d.ts +20 -0
  26. package/dist/particleEmitterSignals.d.ts.map +1 -0
  27. package/dist/particleEmitterSignals.js +33 -0
  28. package/dist/particleEmitterSignals.js.map +1 -0
  29. package/dist/particleEmitterState.d.ts +7 -0
  30. package/dist/particleEmitterState.d.ts.map +1 -0
  31. package/dist/particleEmitterState.js +38 -0
  32. package/dist/particleEmitterState.js.map +1 -0
  33. package/dist/particleObjectsState.d.ts +4 -0
  34. package/dist/particleObjectsState.d.ts.map +1 -0
  35. package/dist/particleObjectsState.js +24 -0
  36. package/dist/particleObjectsState.js.map +1 -0
  37. package/dist/prewarmParticleEmitter.d.ts +5 -0
  38. package/dist/prewarmParticleEmitter.d.ts.map +1 -0
  39. package/dist/prewarmParticleEmitter.js +13 -0
  40. package/dist/prewarmParticleEmitter.js.map +1 -0
  41. package/dist/stepParticleEmitter.d.ts +38 -0
  42. package/dist/stepParticleEmitter.d.ts.map +1 -0
  43. package/dist/stepParticleEmitter.js +57 -0
  44. package/dist/stepParticleEmitter.js.map +1 -0
  45. package/dist/updateParticleEmitter.d.ts +8 -0
  46. package/dist/updateParticleEmitter.d.ts.map +1 -0
  47. package/dist/updateParticleEmitter.js +315 -0
  48. package/dist/updateParticleEmitter.js.map +1 -0
  49. package/dist/updateParticleObjects.d.ts +8 -0
  50. package/dist/updateParticleObjects.d.ts.map +1 -0
  51. package/dist/updateParticleObjects.js +151 -0
  52. package/dist/updateParticleObjects.js.map +1 -0
  53. package/dist/validateParticleEmitterConfig.d.ts +15 -0
  54. package/dist/validateParticleEmitterConfig.d.ts.map +1 -0
  55. package/dist/validateParticleEmitterConfig.js +206 -0
  56. package/dist/validateParticleEmitterConfig.js.map +1 -0
  57. package/package.json +42 -0
  58. package/src/applyParticleCollisions.test.ts +191 -0
  59. package/src/applyParticleForces.test.ts +174 -0
  60. package/src/curve.test.ts +327 -0
  61. package/src/deterministic.test.ts +54 -0
  62. package/src/emitParticleBurst.test.ts +103 -0
  63. package/src/particleEmitterConfig.test.ts +65 -0
  64. package/src/particleEmitterSignals.test.ts +125 -0
  65. package/src/particleEmitterState.test.ts +113 -0
  66. package/src/particleObjectsState.test.ts +34 -0
  67. package/src/prewarmParticleEmitter.test.ts +68 -0
  68. package/src/stepParticleEmitter.test.ts +113 -0
  69. package/src/updateParticleEmitter.test.ts +631 -0
  70. package/src/updateParticleObjects.test.ts +340 -0
  71. package/src/validateParticleEmitterConfig.test.ts +118 -0
@@ -0,0 +1,206 @@
1
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
2
+ // Every numeric field of ParticleEmitterConfig. Kept as a typed tuple so the
3
+ // validate/sanitize passes can iterate without missing a field as the config grows.
4
+ const NUMERIC_FIELDS = [
5
+ 'alphaEnd',
6
+ 'alphaStart',
7
+ 'burstCount',
8
+ 'burstInterval',
9
+ 'duration',
10
+ 'colorEndB',
11
+ 'colorEndG',
12
+ 'colorEndR',
13
+ 'colorEndVarianceB',
14
+ 'colorEndVarianceG',
15
+ 'colorEndVarianceR',
16
+ 'colorStartB',
17
+ 'colorStartG',
18
+ 'colorStartR',
19
+ 'colorStartVarianceB',
20
+ 'colorStartVarianceG',
21
+ 'colorStartVarianceR',
22
+ 'directionX',
23
+ 'directionY',
24
+ 'emitterHeight',
25
+ 'emitterRadius',
26
+ 'emitterWidth',
27
+ 'frameCount',
28
+ 'frameRate',
29
+ 'gravityX',
30
+ 'gravityY',
31
+ 'lifetimeMax',
32
+ 'lifetimeMin',
33
+ 'maxParticles',
34
+ 'regionIdMax',
35
+ 'regionIdMin',
36
+ 'rotationSpeedMax',
37
+ 'rotationSpeedMin',
38
+ 'scaleEnd',
39
+ 'scaleMax',
40
+ 'scaleMin',
41
+ 'speedMax',
42
+ 'speedMin',
43
+ 'spawnRate',
44
+ 'spread',
45
+ 'velocityInheritance',
46
+ ];
47
+ // Fields that must never be negative — a negative value is either meaningless
48
+ // (a count, a rate, a duration) or actively breaks the simulation.
49
+ const NON_NEGATIVE_FIELDS = [
50
+ 'burstCount',
51
+ 'burstInterval',
52
+ 'duration',
53
+ 'emitterHeight',
54
+ 'emitterRadius',
55
+ 'emitterWidth',
56
+ 'frameRate',
57
+ 'lifetimeMin',
58
+ 'lifetimeMax',
59
+ 'maxParticles',
60
+ 'scaleMax',
61
+ 'scaleMin',
62
+ 'speedMax',
63
+ 'speedMin',
64
+ 'spawnRate',
65
+ ];
66
+ /** Return a copy of the config with every value coerced to something the
67
+ * simulation can run safely: non-finite numbers fall back to their defaults,
68
+ * negative counts/rates/sizes are clamped to 0, integer fields are floored, and
69
+ * `frameCount` is forced to >= 1. Range inversions (e.g. lifetimeMin > max) are
70
+ * left intact since they simulate correctly — use {@link validateParticleEmitterConfig}
71
+ * to surface those to the author. Safe to call on partial input. */
72
+ export function normalizeParticleEmitterConfig(config) {
73
+ const out = createParticleEmitterConfig(config);
74
+ const defaults = createParticleEmitterConfig();
75
+ // Replace any non-finite numeric field with its canonical default.
76
+ const mutable = out;
77
+ const defaultsRec = defaults;
78
+ for (const field of NUMERIC_FIELDS) {
79
+ if (!Number.isFinite(mutable[field]))
80
+ mutable[field] = defaultsRec[field];
81
+ }
82
+ return {
83
+ ...out,
84
+ // Drop any curve that is empty or contains a non-finite sample so it can never
85
+ // inject NaN — the simulation falls back to its linear interpolation path.
86
+ alphaCurve: isFiniteCurve(out.alphaCurve) ? out.alphaCurve : null,
87
+ colorCurve: isFiniteCurve(out.colorCurve) ? out.colorCurve : null,
88
+ scaleCurve: isFiniteCurve(out.scaleCurve) ? out.scaleCurve : null,
89
+ maxParticles: Math.max(0, Math.floor(out.maxParticles)),
90
+ burstCount: Math.max(0, Math.floor(out.burstCount)),
91
+ burstInterval: Math.max(0, out.burstInterval),
92
+ duration: Math.max(0, out.duration),
93
+ frameCount: Math.max(1, Math.floor(out.frameCount)),
94
+ frameRate: Math.max(0, out.frameRate),
95
+ regionIdMin: Math.max(0, Math.floor(out.regionIdMin)),
96
+ regionIdMax: Math.max(Math.max(0, Math.floor(out.regionIdMin)), Math.floor(out.regionIdMax)),
97
+ spawnRate: Math.max(0, out.spawnRate),
98
+ lifetimeMin: Math.max(0, out.lifetimeMin),
99
+ lifetimeMax: Math.max(0, out.lifetimeMax),
100
+ speedMin: Math.max(0, out.speedMin),
101
+ speedMax: Math.max(0, out.speedMax),
102
+ scaleMin: Math.max(0, out.scaleMin),
103
+ scaleMax: Math.max(0, out.scaleMax),
104
+ emitterRadius: Math.max(0, out.emitterRadius),
105
+ emitterWidth: Math.max(0, out.emitterWidth),
106
+ emitterHeight: Math.max(0, out.emitterHeight),
107
+ };
108
+ }
109
+ /** Report problems in a particle config without modifying it — for asset-import
110
+ * validation, editor inspectors, or CI checks on authored effects. Returns an
111
+ * empty array for a clean config. Use {@link normalizeParticleEmitterConfig} to
112
+ * get a corrected config for safe runtime use. */
113
+ export function validateParticleEmitterConfig(config) {
114
+ const issues = [];
115
+ for (const field of NUMERIC_FIELDS) {
116
+ const value = config[field];
117
+ if (!Number.isFinite(value)) {
118
+ issues.push({ field, message: `${field} must be a finite number (got ${String(value)})`, severity: 'error' });
119
+ }
120
+ }
121
+ for (const field of NON_NEGATIVE_FIELDS) {
122
+ const value = config[field];
123
+ if (Number.isFinite(value) && value < 0) {
124
+ issues.push({ field, message: `${field} must not be negative (got ${value})`, severity: 'warning' });
125
+ }
126
+ }
127
+ // A non-positive maximum lifetime means particles die the frame they spawn.
128
+ if (Number.isFinite(config.lifetimeMax) && config.lifetimeMax <= 0) {
129
+ issues.push({
130
+ field: 'lifetimeMax',
131
+ message: 'lifetimeMax must be > 0 or particles die instantly',
132
+ severity: 'warning',
133
+ });
134
+ }
135
+ // A non-positive cap means the emitter can never hold a particle.
136
+ if (Number.isFinite(config.maxParticles) && config.maxParticles <= 0) {
137
+ issues.push({
138
+ field: 'maxParticles',
139
+ message: 'maxParticles must be >= 1 or nothing ever spawns',
140
+ severity: 'warning',
141
+ });
142
+ }
143
+ if (Number.isFinite(config.frameCount) && config.frameCount < 1) {
144
+ issues.push({ field: 'frameCount', message: 'frameCount must be >= 1', severity: 'warning' });
145
+ }
146
+ // Inverted min/max ranges still simulate (the spawn interpolation handles a > b)
147
+ // but almost always indicate an authoring mistake.
148
+ reportInvertedRange(issues, config, 'lifetimeMin', 'lifetimeMax');
149
+ reportInvertedRange(issues, config, 'speedMin', 'speedMax');
150
+ reportInvertedRange(issues, config, 'scaleMin', 'scaleMax');
151
+ reportInvertedRange(issues, config, 'rotationSpeedMin', 'rotationSpeedMax');
152
+ reportUnitRange(issues, config, 'alphaStart');
153
+ reportUnitRange(issues, config, 'alphaEnd');
154
+ reportCurve(issues, config.alphaCurve, 'alphaCurve', 1);
155
+ reportCurve(issues, config.colorCurve, 'colorCurve', 3);
156
+ reportCurve(issues, config.scaleCurve, 'scaleCurve', 1);
157
+ return issues;
158
+ }
159
+ function isFiniteCurve(curve) {
160
+ if (curve == null || curve.length === 0)
161
+ return false;
162
+ for (let i = 0; i < curve.length; i++) {
163
+ if (!Number.isFinite(curve[i]))
164
+ return false;
165
+ }
166
+ return true;
167
+ }
168
+ function reportCurve(issues, curve, field, stride) {
169
+ if (curve == null)
170
+ return;
171
+ if (curve.length === 0) {
172
+ issues.push({ field, message: `${field} is empty and will be ignored`, severity: 'warning' });
173
+ return;
174
+ }
175
+ if (curve.length % stride !== 0) {
176
+ issues.push({
177
+ field,
178
+ message: `${field} length (${curve.length}) is not a multiple of ${stride}`,
179
+ severity: 'warning',
180
+ });
181
+ }
182
+ for (let i = 0; i < curve.length; i++) {
183
+ if (!Number.isFinite(curve[i])) {
184
+ issues.push({ field, message: `${field} contains a non-finite sample at index ${i}`, severity: 'error' });
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ function reportInvertedRange(issues, config, minField, maxField) {
190
+ const min = config[minField];
191
+ const max = config[maxField];
192
+ if (Number.isFinite(min) && Number.isFinite(max) && min > max) {
193
+ issues.push({
194
+ field: minField,
195
+ message: `${minField} (${min}) is greater than ${maxField} (${max})`,
196
+ severity: 'warning',
197
+ });
198
+ }
199
+ }
200
+ function reportUnitRange(issues, config, field) {
201
+ const value = config[field];
202
+ if (Number.isFinite(value) && (value < 0 || value > 1)) {
203
+ issues.push({ field, message: `${field} (${value}) is outside the expected 0–1 range`, severity: 'warning' });
204
+ }
205
+ }
206
+ //# sourceMappingURL=validateParticleEmitterConfig.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validateParticleEmitterConfig.js","sourceRoot":"","sources":["../src/validateParticleEmitterConfig.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@flighthq/particles",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "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"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.3.0"
39
+ },
40
+ "description": "Particle system: emitter simulation and typed-array-backed particle primitives",
41
+ "sideEffects": false
42
+ }
@@ -0,0 +1,191 @@
1
+ import { createParticleEmitter } from '@flighthq/sprite';
2
+ import type { TextureAtlas } from '@flighthq/types';
3
+
4
+ import { applyParticleCollisions, applyParticleObjectCollisions } from './applyParticleCollisions';
5
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
6
+ import { createParticleEmitterState } from './particleEmitterState';
7
+ import { createParticleObjectsState } from './particleObjectsState';
8
+ import { updateParticleEmitter } from './updateParticleEmitter';
9
+ import type { ParticleObject } from './updateParticleObjects';
10
+
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;
16
+ }
17
+
18
+ // Spawn one particle, then place it at a known position with a known velocity.
19
+ function place(px: number, py: number, vx: number, vy: number) {
20
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
21
+ 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);
30
+ emitter.data.transforms[0] = px;
31
+ emitter.data.transforms[1] = py;
32
+ state.velocities[0] = vx;
33
+ state.velocities[1] = vy;
34
+ return { emitter, state };
35
+ }
36
+
37
+ describe('applyParticleCollisions', () => {
38
+ describe('circle', () => {
39
+ it('exclude mode keeps a particle outside the disc', () => {
40
+ const { emitter, state } = place(5, 0, -10, 0); // inside radius-10 disc at origin, moving inward
41
+ applyParticleCollisions(emitter, state, [
42
+ { kind: 'CircleCollider', x: 0, y: 0, radius: 10, mode: 'exclude', restitution: 1 },
43
+ ]);
44
+ const d = Math.hypot(emitter.data.transforms[0], emitter.data.transforms[1]);
45
+ expect(d).toBeCloseTo(10); // pushed to the surface
46
+ expect(state.velocities[0]).toBeCloseTo(10); // bounced outward
47
+ });
48
+
49
+ it('contain mode keeps a particle inside the boundary', () => {
50
+ const { emitter, state } = place(20, 0, 10, 0); // outside radius-10 boundary, moving further out
51
+ applyParticleCollisions(emitter, state, [{ kind: 'CircleCollider', x: 0, y: 0, radius: 10, mode: 'contain' }]);
52
+ const d = Math.hypot(emitter.data.transforms[0], emitter.data.transforms[1]);
53
+ expect(d).toBeCloseTo(10);
54
+ });
55
+ });
56
+
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
+ it('is a no-op with no colliders', () => {
71
+ const { emitter, state } = place(0, 520, 0, 100);
72
+ applyParticleCollisions(emitter, state, []);
73
+ expect(emitter.data.transforms[1]).toBe(520);
74
+ });
75
+ });
76
+
77
+ describe('plane (floor)', () => {
78
+ it('pushes a penetrating particle back to the surface and stops it (restitution 0)', () => {
79
+ // Floor at y = 500, valid region is y <= 500 (normal points up = -y).
80
+ const { emitter, state } = place(0, 520, 0, 100); // 20px below floor, moving down
81
+ applyParticleCollisions(emitter, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]);
82
+ expect(emitter.data.transforms[1]).toBeCloseTo(500); // snapped to surface
83
+ expect(state.velocities[1]).toBeCloseTo(0); // normal velocity killed
84
+ });
85
+
86
+ it('bounces with restitution', () => {
87
+ const { emitter, state } = place(0, 520, 0, 100);
88
+ applyParticleCollisions(emitter, state, [
89
+ { kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500, restitution: 0.5 },
90
+ ]);
91
+ expect(state.velocities[1]).toBeCloseTo(-50); // 100 down → 50 up
92
+ });
93
+
94
+ it('applies friction to the tangential component', () => {
95
+ const { emitter, state } = place(0, 520, 80, 100);
96
+ applyParticleCollisions(emitter, state, [
97
+ { kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500, friction: 0.25 },
98
+ ]);
99
+ expect(state.velocities[0]).toBeCloseTo(60); // 80 * (1 - 0.25)
100
+ });
101
+
102
+ it('leaves particles above the floor untouched', () => {
103
+ const { emitter, state } = place(0, 100, 0, 100);
104
+ applyParticleCollisions(emitter, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]);
105
+ expect(emitter.data.transforms[1]).toBe(100);
106
+ expect(state.velocities[1]).toBe(100);
107
+ });
108
+ });
109
+
110
+ describe('rect', () => {
111
+ it('contain mode clamps particles to the bounds and reflects velocity', () => {
112
+ // Box centered (0,0), 100×100 → bounds ±50.
113
+ const { emitter, state } = place(80, 0, 30, 0);
114
+ applyParticleCollisions(emitter, state, [
115
+ { kind: 'RectangleCollider', x: 0, y: 0, width: 100, height: 100, mode: 'contain', restitution: 1 },
116
+ ]);
117
+ expect(emitter.data.transforms[0]).toBeCloseTo(50);
118
+ expect(state.velocities[0]).toBeCloseTo(-30);
119
+ });
120
+
121
+ it('exclude mode pushes a particle out of a solid box along the shallowest axis', () => {
122
+ // Particle just inside the right edge of a box centered (0,0), 100×100.
123
+ const { emitter, state } = place(40, 0, 0, 0);
124
+ applyParticleCollisions(emitter, state, [
125
+ { kind: 'RectangleCollider', x: 0, y: 0, width: 100, height: 100, mode: 'exclude' },
126
+ ]);
127
+ expect(emitter.data.transforms[0]).toBeCloseTo(50); // pushed out the near (right) edge
128
+ });
129
+ });
130
+ });
131
+
132
+ function makeObject(): ParticleObject {
133
+ return {
134
+ alpha: 1,
135
+ blendMode: null,
136
+ colorTransform: null,
137
+ rotation: 0,
138
+ scaleX: 1,
139
+ scaleY: 1,
140
+ visible: true,
141
+ x: 0,
142
+ y: 0,
143
+ } as unknown as ParticleObject;
144
+ }
145
+
146
+ // Build an object-pool collision scenario: a single live object at (px,py) with
147
+ // velocity (vx,vy) in slot 0; slot 1 is left dead.
148
+ function placeObject(px: number, py: number, vx: number, vy: number) {
149
+ const objects = [makeObject(), makeObject()];
150
+ const state = createParticleObjectsState(2);
151
+ state.lifetimes[1] = 0; // slot 0 age
152
+ state.lifetimes[1 * 2 + 1] = 0; // slot 1 maxAge = 0 → dead
153
+ state.lifetimes[0 * 2 + 1] = 100; // slot 0 maxAge > 0 → alive
154
+ objects[0].x = px;
155
+ objects[0].y = py;
156
+ state.velocities[0] = vx;
157
+ state.velocities[1] = vy;
158
+ return { objects, state };
159
+ }
160
+
161
+ describe('applyParticleObjectCollisions', () => {
162
+ it('pushes a live object back onto a floor plane and kills its normal velocity', () => {
163
+ const { objects, state } = placeObject(0, 520, 0, 100); // below floor y=500, moving down
164
+ applyParticleObjectCollisions(objects, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]);
165
+ expect(objects[0].y).toBeCloseTo(500); // snapped to surface
166
+ expect(state.velocities[1]).toBeCloseTo(0); // normal velocity killed
167
+ });
168
+
169
+ it('bounces a live object with restitution', () => {
170
+ const { objects, state } = placeObject(0, 520, 0, 100);
171
+ applyParticleObjectCollisions(objects, state, [
172
+ { kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500, restitution: 0.5 },
173
+ ]);
174
+ expect(state.velocities[1]).toBeCloseTo(-50); // 100 down → 50 up
175
+ });
176
+
177
+ it('skips dead slots', () => {
178
+ const { objects, state } = placeObject(0, 100, 0, 0); // slot 0 above the floor → untouched
179
+ objects[1].x = 0;
180
+ objects[1].y = 520; // dead slot 1 is below the floor but must be ignored
181
+ applyParticleObjectCollisions(objects, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]);
182
+ expect(objects[1].y).toBe(520); // dead object left where it was
183
+ });
184
+
185
+ it('is a no-op with no colliders', () => {
186
+ const { objects, state } = placeObject(0, 520, 0, 100);
187
+ applyParticleObjectCollisions(objects, state, []);
188
+ expect(objects[0].y).toBe(520);
189
+ expect(state.velocities[1]).toBe(100);
190
+ });
191
+ });
@@ -0,0 +1,174 @@
1
+ import { createParticleEmitter } from '@flighthq/sprite';
2
+ import type { TextureAtlas } from '@flighthq/types';
3
+
4
+ import { applyParticleForces, applyParticleObjectForces } from './applyParticleForces';
5
+ import { createParticleEmitterConfig } from './particleEmitterConfig';
6
+ import { createParticleEmitterState } from './particleEmitterState';
7
+ import { createParticleObjectsState } from './particleObjectsState';
8
+ import { updateParticleEmitter } from './updateParticleEmitter';
9
+ import type { ParticleObject } from './updateParticleObjects';
10
+ import { updateParticleObjects } from './updateParticleObjects';
11
+
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;
17
+ }
18
+
19
+ // Spawn a single stationary particle at the origin and return its handles.
20
+ function oneParticle() {
21
+ const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
22
+ 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
+ return { emitter, state };
32
+ }
33
+
34
+ describe('applyParticleForces', () => {
35
+ it('attractor pulls velocity toward the point', () => {
36
+ const { emitter, state } = oneParticle();
37
+ emitter.data.transforms[0] = 0;
38
+ emitter.data.transforms[1] = 0;
39
+ applyParticleForces(emitter, state, [{ kind: 'AttractorForce', x: 100, y: 0, strength: 50 }], 1);
40
+ expect(state.velocities[0]).toBeCloseTo(50); // accelerated +x toward (100,0)
41
+ expect(state.velocities[1]).toBeCloseTo(0);
42
+ });
43
+
44
+ it('negative-strength attractor repels', () => {
45
+ const { emitter, state } = oneParticle();
46
+ applyParticleForces(emitter, state, [{ kind: 'AttractorForce', x: 100, y: 0, strength: -50 }], 1);
47
+ expect(state.velocities[0]).toBeLessThan(0);
48
+ });
49
+
50
+ it('radius acts as a hard cutoff', () => {
51
+ const { emitter, state } = oneParticle();
52
+ emitter.data.transforms[0] = 0;
53
+ emitter.data.transforms[1] = 0;
54
+ state.velocities[0] = 42; // known baseline; an out-of-range force must not change it
55
+ state.velocities[1] = 7;
56
+ applyParticleForces(emitter, state, [{ kind: 'AttractorForce', x: 1000, y: 0, strength: 50, radius: 50 }], 1);
57
+ expect(state.velocities[0]).toBe(42); // 1000 px away, outside 50px radius
58
+ expect(state.velocities[1]).toBe(7);
59
+ });
60
+
61
+ it('vortex applies tangential velocity', () => {
62
+ const { emitter, state } = oneParticle();
63
+ emitter.data.transforms[0] = 10;
64
+ emitter.data.transforms[1] = 0;
65
+ applyParticleForces(emitter, state, [{ kind: 'VortexForce', x: 0, y: 0, strength: 20 }], 1);
66
+ expect(state.velocities[0]).toBeCloseTo(0);
67
+ expect(state.velocities[1]).toBeCloseTo(20); // perpendicular swirl
68
+ });
69
+
70
+ it('drag reduces existing velocity', () => {
71
+ const { emitter, state } = oneParticle();
72
+ state.velocities[0] = 100;
73
+ state.velocities[1] = 0;
74
+ applyParticleForces(emitter, state, [{ kind: 'DragForce', strength: 0.5 }], 1);
75
+ expect(state.velocities[0]).toBeCloseTo(50); // 100 - 0.5*100
76
+ });
77
+
78
+ it('wind adds constant acceleration', () => {
79
+ const { emitter, state } = oneParticle();
80
+ applyParticleForces(emitter, state, [{ kind: 'WindForce', x: 5, y: -3 }], 2);
81
+ expect(state.velocities[0]).toBeCloseTo(10); // 5 * deltaTime(2)
82
+ expect(state.velocities[1]).toBeCloseTo(-6);
83
+ });
84
+
85
+ it('turbulence is finite and deterministic for a given position', () => {
86
+ const a = oneParticle();
87
+ a.emitter.data.transforms[0] = 12.5;
88
+ a.emitter.data.transforms[1] = -7.5;
89
+ applyParticleForces(a.emitter, a.state, [{ kind: 'TurbulenceForce', strength: 100, scale: 0.1 }], 1);
90
+ const b = oneParticle();
91
+ b.emitter.data.transforms[0] = 12.5;
92
+ b.emitter.data.transforms[1] = -7.5;
93
+ applyParticleForces(b.emitter, b.state, [{ kind: 'TurbulenceForce', strength: 100, scale: 0.1 }], 1);
94
+ expect(Number.isFinite(a.state.velocities[0])).toBe(true);
95
+ expect(a.state.velocities[0]).toBe(b.state.velocities[0]); // deterministic
96
+ expect(a.state.velocities[1]).toBe(b.state.velocities[1]);
97
+ });
98
+
99
+ it('composes multiple forces additively', () => {
100
+ const { emitter, state } = oneParticle();
101
+ applyParticleForces(
102
+ emitter,
103
+ state,
104
+ [
105
+ { kind: 'WindForce', x: 10, y: 0 },
106
+ { kind: 'WindForce', x: 5, y: 0 },
107
+ ],
108
+ 1,
109
+ );
110
+ expect(state.velocities[0]).toBeCloseTo(15);
111
+ });
112
+
113
+ it('is a no-op for an empty force list or zero deltaTime', () => {
114
+ const { emitter, state } = oneParticle();
115
+ state.velocities[0] = 42;
116
+ applyParticleForces(emitter, state, [], 1);
117
+ applyParticleForces(emitter, state, [{ kind: 'WindForce', x: 5, y: 0 }], 0);
118
+ expect(state.velocities[0]).toBe(42); // unchanged by either no-op call
119
+ });
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
+ });
139
+
140
+ function makeObject(): ParticleObject {
141
+ return {
142
+ alpha: 1,
143
+ blendMode: null,
144
+ colorTransform: null,
145
+ rotation: 0,
146
+ scaleX: 1,
147
+ scaleY: 1,
148
+ visible: false,
149
+ x: 0,
150
+ y: 0,
151
+ } as unknown as ParticleObject;
152
+ }
153
+
154
+ describe('applyParticleObjectForces', () => {
155
+ it('applies forces only to live objects', () => {
156
+ const objects = [makeObject(), makeObject()];
157
+ const state = createParticleObjectsState(2);
158
+ const config = createParticleEmitterConfig({
159
+ spawnRate: 1,
160
+ lifetimeMin: 100,
161
+ lifetimeMax: 100,
162
+ speedMin: 0,
163
+ speedMax: 0,
164
+ });
165
+ updateParticleObjects(objects, state, config, 1); // spawn exactly one (spawnRate*deltaTime = 1)
166
+ const liveIndex = objects.findIndex((o) => o.visible);
167
+ const deadIndex = liveIndex === 0 ? 1 : 0;
168
+ objects[liveIndex].x = 0;
169
+ objects[liveIndex].y = 0;
170
+ applyParticleObjectForces(objects, state, [{ kind: 'WindForce', x: 7, y: 0 }], 1);
171
+ expect(state.velocities[liveIndex * 2]).toBeCloseTo(7);
172
+ expect(state.velocities[deadIndex * 2]).toBe(0); // dead slot untouched
173
+ });
174
+ });