@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,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
|
+
});
|