fireworks-engine 1.0.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.
@@ -0,0 +1,7 @@
1
+ export function prefersReducedMotion() {
2
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
3
+ return false;
4
+ }
5
+
6
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
7
+ }
@@ -0,0 +1,330 @@
1
+ import { prefersReducedMotion as defaultPrefersReducedMotion } from './runtimeCapabilities.js';
2
+
3
+ const HIDDEN_DOCUMENT_TIMEOUT_RETRY_MS = 250;
4
+
5
+ function resolveEngineRef(value, fallback) {
6
+ if (value !== undefined) {
7
+ return value;
8
+ }
9
+
10
+ if (fallback !== undefined) {
11
+ return fallback;
12
+ }
13
+
14
+ return null;
15
+ }
16
+
17
+ function resolveFrameFunction(engine, refKey, methodName) {
18
+ const engineFn = engine[refKey];
19
+ if (typeof engineFn === 'function') {
20
+ return engineFn;
21
+ }
22
+
23
+ const win = getWindow(engine);
24
+ const winFn = win?.[methodName];
25
+ if (typeof winFn === 'function') {
26
+ return winFn.bind(win);
27
+ }
28
+
29
+ const globalFn = globalThis[methodName];
30
+ if (typeof globalFn === 'function') {
31
+ return globalFn.bind(globalThis);
32
+ }
33
+
34
+ return null;
35
+ }
36
+
37
+ export function getDocument(engine) {
38
+ return resolveEngineRef(engine.documentRef, globalThis.document);
39
+ }
40
+
41
+ export function getWindow(engine) {
42
+ return resolveEngineRef(engine.windowRef, globalThis.window);
43
+ }
44
+
45
+ export function getRequestAnimationFrame(engine) {
46
+ return resolveFrameFunction(engine, 'requestAnimationFrameRef', 'requestAnimationFrame');
47
+ }
48
+
49
+ export function getCancelAnimationFrame(engine) {
50
+ return resolveFrameFunction(engine, 'cancelAnimationFrameRef', 'cancelAnimationFrame');
51
+ }
52
+
53
+
54
+ export function getNow(engine) {
55
+ const nowRef = engine.nowRef;
56
+ if (typeof nowRef === 'function') {
57
+ return nowRef();
58
+ }
59
+
60
+ const performanceNow = globalThis.performance?.now;
61
+ if (typeof performanceNow === 'function') {
62
+ return performanceNow.call(globalThis.performance);
63
+ }
64
+
65
+ return Date.now();
66
+ }
67
+
68
+ export function isDocumentHidden(engine) {
69
+ const doc = getDocument(engine);
70
+ return doc?.hidden === true || doc?.visibilityState === 'hidden';
71
+ }
72
+
73
+ export function shouldReduceMotion(engine) {
74
+ if (typeof engine.prefersReducedMotionRef === 'function') {
75
+ return !!engine.prefersReducedMotionRef();
76
+ }
77
+
78
+ return defaultPrefersReducedMotion();
79
+ }
80
+
81
+ export function resolveRoot(engine) {
82
+ const doc = getDocument(engine);
83
+
84
+ if (typeof engine.rootRef === 'function') {
85
+ return engine.rootRef({ document: doc, window: getWindow(engine) }) || null;
86
+ }
87
+
88
+ if (engine.rootRef) {
89
+ return engine.rootRef;
90
+ }
91
+
92
+ return doc?.body || doc?.documentElement || null;
93
+ }
94
+
95
+ export function usesElementScopedCanvas(engine) {
96
+ const doc = getDocument(engine);
97
+ const root = resolveRoot(engine);
98
+ return !!root && root !== doc?.body && root !== doc?.documentElement;
99
+ }
100
+
101
+ export function addResizeListener(engine) {
102
+ if (engine.hasResizeListener) {
103
+ return;
104
+ }
105
+
106
+ const win = getWindow(engine);
107
+ if (typeof win?.addEventListener !== 'function') {
108
+ return;
109
+ }
110
+
111
+ win.addEventListener('resize', engine.resize, { passive: true });
112
+ engine.hasResizeListener = true;
113
+ }
114
+
115
+ export function removeResizeListener(engine) {
116
+ if (!engine.hasResizeListener) {
117
+ return;
118
+ }
119
+
120
+ const win = getWindow(engine);
121
+ win?.removeEventListener?.('resize', engine.resize);
122
+ engine.hasResizeListener = false;
123
+ }
124
+
125
+ export function addResizeObserver(engine) {
126
+ if (engine.hasResizeObserver) {
127
+ return true;
128
+ }
129
+
130
+ const ResizeObserverCtor = globalThis.ResizeObserver;
131
+ if (typeof ResizeObserverCtor !== 'function') {
132
+ return false;
133
+ }
134
+
135
+ const root = resolveRoot(engine);
136
+ if (!root || typeof root.nodeType !== 'number') {
137
+ return false;
138
+ }
139
+
140
+ const observer = new ResizeObserverCtor(() => {
141
+ if (engine.canvas && engine.ctx) {
142
+ engine.resize();
143
+ }
144
+ });
145
+
146
+ observer.observe(root);
147
+ engine.resizeObserver = observer;
148
+ engine.hasResizeObserver = true;
149
+ return true;
150
+ }
151
+
152
+ export function removeResizeObserver(engine) {
153
+ if (!engine.hasResizeObserver) {
154
+ return;
155
+ }
156
+
157
+ engine.resizeObserver?.disconnect?.();
158
+ engine.resizeObserver = null;
159
+ engine.hasResizeObserver = false;
160
+ }
161
+
162
+ export function scheduleTimeout(engine, callback, delay = 0) {
163
+ const schedule = typeof engine.setTimeoutRef === 'function' ? engine.setTimeoutRef : setTimeout;
164
+
165
+ let timeoutId = null;
166
+ const runWhenVisible = () => {
167
+ if (timeoutId !== null) {
168
+ engine.timeoutIds.delete(timeoutId);
169
+ }
170
+
171
+ if (isDocumentHidden(engine)) {
172
+ timeoutId = schedule(runWhenVisible, HIDDEN_DOCUMENT_TIMEOUT_RETRY_MS);
173
+ engine.timeoutIds.add(timeoutId);
174
+ return;
175
+ }
176
+
177
+ callback();
178
+ };
179
+
180
+ timeoutId = schedule(runWhenVisible, delay);
181
+
182
+ engine.timeoutIds.add(timeoutId);
183
+ return timeoutId;
184
+ }
185
+
186
+ export function resolveScaledDelay(engine, delay = 0) {
187
+ const baseDelay = Number.isFinite(delay) ? Math.max(0, delay) : 0;
188
+ const timeScale = Number.isFinite(engine.timeScale) && engine.timeScale > 0
189
+ ? engine.timeScale
190
+ : 1;
191
+ return baseDelay / timeScale;
192
+ }
193
+
194
+ export function clearScheduledTimeouts(engine) {
195
+ const clear = typeof engine.clearTimeoutRef === 'function' ? engine.clearTimeoutRef : clearTimeout;
196
+ engine.timeoutIds.forEach((timeoutId) => {
197
+ clear(timeoutId);
198
+ });
199
+ engine.timeoutIds.clear();
200
+ }
201
+
202
+ export function clearPendingStart(engine) {
203
+ if (engine.hasDomReadyListener) {
204
+ getDocument(engine)?.removeEventListener?.('DOMContentLoaded', engine.handleDOMContentLoaded);
205
+ }
206
+ engine.hasDomReadyListener = false;
207
+ engine.pendingStartConfig = null;
208
+ }
209
+
210
+ export function handleDOMContentLoaded(engine) {
211
+ engine.hasDomReadyListener = false;
212
+ const nextConfig = engine.pendingStartConfig;
213
+ engine.pendingStartConfig = null;
214
+
215
+ if (nextConfig) {
216
+ engine.start(nextConfig);
217
+ }
218
+ }
219
+
220
+ export function schedulePendingStart(engine, config) {
221
+ const doc = getDocument(engine);
222
+ engine.pendingStartConfig = config;
223
+
224
+ if (doc?.readyState !== 'loading' || engine.hasDomReadyListener) {
225
+ return false;
226
+ }
227
+
228
+ doc.addEventListener('DOMContentLoaded', engine.handleDOMContentLoaded, { once: true });
229
+ engine.hasDomReadyListener = true;
230
+ return true;
231
+ }
232
+
233
+ export function ensureCanvas(engine) {
234
+ if (engine.canvas && engine.ctx) {
235
+ return true;
236
+ }
237
+
238
+ const doc = getDocument(engine);
239
+ const root = resolveRoot(engine);
240
+ if (!doc || !root || typeof doc.createElement !== 'function' || typeof root.appendChild !== 'function') {
241
+ return false;
242
+ }
243
+
244
+ const canvas = doc.createElement('canvas');
245
+ const ctx = canvas.getContext?.('2d', { alpha: true });
246
+ if (!ctx) {
247
+ return false;
248
+ }
249
+
250
+ if (usesElementScopedCanvas(engine)) {
251
+ canvas.style.position = 'absolute';
252
+ canvas.style.inset = '0';
253
+ canvas.style.width = '100%';
254
+ canvas.style.height = '100%';
255
+ } else {
256
+ canvas.style.position = 'fixed';
257
+ canvas.style.left = '0';
258
+ canvas.style.top = '0';
259
+ canvas.style.width = '100vw';
260
+ canvas.style.height = '100vh';
261
+ }
262
+
263
+ canvas.style.pointerEvents = 'none';
264
+ canvas.style.zIndex = String(engine.canvasZIndex);
265
+ canvas.style.display = 'none';
266
+
267
+ root.appendChild(canvas);
268
+
269
+ engine.canvas = canvas;
270
+ engine.ctx = ctx;
271
+ if (usesElementScopedCanvas(engine)) {
272
+ if (!addResizeObserver(engine)) {
273
+ addResizeListener(engine);
274
+ }
275
+ } else {
276
+ addResizeListener(engine);
277
+ }
278
+ resizeCanvas(engine);
279
+
280
+ return true;
281
+ }
282
+
283
+ export function startLoop(engine) {
284
+ if (!engine.canvas) {
285
+ return false;
286
+ }
287
+
288
+ const requestAnimationFrameFn = getRequestAnimationFrame(engine);
289
+ if (typeof requestAnimationFrameFn !== 'function') {
290
+ return false;
291
+ }
292
+
293
+ engine.canvas.style.display = 'block';
294
+
295
+ if (engine.running) {
296
+ return true;
297
+ }
298
+
299
+ clearPendingStart(engine);
300
+ engine.running = true;
301
+ engine.lastTime = getNow(engine);
302
+ engine.rafId = requestAnimationFrameFn(engine.loopFrame);
303
+ return true;
304
+ }
305
+
306
+ export function resizeCanvas(engine) {
307
+ if (!engine.canvas || !engine.ctx) {
308
+ return;
309
+ }
310
+
311
+ const win = getWindow(engine);
312
+ const doc = getDocument(engine);
313
+ const root = resolveRoot(engine);
314
+ const rootRect = usesElementScopedCanvas(engine) && typeof root?.getBoundingClientRect === 'function'
315
+ ? root.getBoundingClientRect()
316
+ : null;
317
+ const viewportWidth = Number(win?.innerWidth) || Number(doc?.documentElement?.clientWidth) || 1;
318
+ const viewportHeight = Number(win?.innerHeight) || Number(doc?.documentElement?.clientHeight) || 1;
319
+ const renderWidth = Number(rootRect?.width) || viewportWidth;
320
+ const renderHeight = Number(rootRect?.height) || viewportHeight;
321
+ const devicePixelRatio = Number(win?.devicePixelRatio) || 1;
322
+ const dpr = Math.min(Math.max(devicePixelRatio, 1), engine.maxDevicePixelRatio);
323
+
324
+ engine.dpr = dpr;
325
+ engine.width = Math.max(1, Math.floor(renderWidth));
326
+ engine.height = Math.max(1, Math.floor(renderHeight));
327
+ engine.canvas.width = Math.floor(engine.width * dpr);
328
+ engine.canvas.height = Math.floor(engine.height * dpr);
329
+ engine.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
330
+ }
package/src/update.js ADDED
@@ -0,0 +1,307 @@
1
+ import {
2
+ scheduleCascadeBursts,
3
+ spawnRocketTailEmbers,
4
+ } from './rocketLaunch.js';
5
+ import { launchBurst } from './burstSpawn.js';
6
+
7
+ const MIN_DISTANCE_SQUARED_EPSILON = 0.0001;
8
+
9
+ const TAIL_EMBER_ACCUMULATION = {
10
+ timeDensityFactor: 8,
11
+ travelDensityFactor: 0.04,
12
+ maxSpawnBudget: 6,
13
+ };
14
+
15
+ const FORM_SPIN_UPDATE_TUNING = {
16
+ velocitySpinScale: 0.004,
17
+ maxVelocityFactor: 1.6,
18
+ rateWaveFactorMin: 0.2,
19
+ rateWaveFactorMax: 1.8,
20
+ activeHoldMinFallback: 0.75,
21
+ activeHoldMaxFallback: 3.4,
22
+ inactiveHoldMinFallback: 0.55,
23
+ inactiveHoldMaxFallback: 3.8,
24
+ blendResponse: 5.5,
25
+ };
26
+
27
+ const FORM_WOBBLE_UPDATE_TUNING = {
28
+ velocityWobbleScale: 0.0035,
29
+ maxVelocityFactor: 1.8,
30
+ targetHoldMin: 0.14,
31
+ targetHoldMax: 0.48,
32
+ targetResponse: 3.2,
33
+ calmChance: 0.35,
34
+ sameDirectionBias: 0.48,
35
+ targetContinuity: 0.4,
36
+ maxVelocityDeltaPerSecond: 88,
37
+ };
38
+
39
+ const FORM_VERTICAL_MOTION_TUNING = {
40
+ // Keep prior confetti look while making vertical damping consistent
41
+ // across all form particles (instead of random per-particle drag).
42
+ perFrameDrag: 0.9735,
43
+ };
44
+
45
+ const PARTICLE_PHYSICS_TUNING = {
46
+ dragReferenceDtSeconds: 1 / 60,
47
+ offscreenBottomMargin: 120,
48
+ };
49
+
50
+ function hasRocketReachedTarget(rocket) {
51
+ const totalDx = rocket.totalDx ?? (rocket.targetX - rocket.startX);
52
+ const totalDy = rocket.totalDy ?? (rocket.targetY - rocket.startY);
53
+ const totalDistanceSq = rocket.totalDistanceSq ?? ((totalDx * totalDx) + (totalDy * totalDy));
54
+
55
+ if (totalDistanceSq <= MIN_DISTANCE_SQUARED_EPSILON) {
56
+ return true;
57
+ }
58
+
59
+ const travelDx = rocket.x - rocket.startX;
60
+ const travelDy = rocket.y - rocket.startY;
61
+ const traveledProjection = (travelDx * totalDx) + (travelDy * totalDy);
62
+
63
+ return traveledProjection >= totalDistanceSq;
64
+ }
65
+
66
+ function releaseParticle(engine, particle) {
67
+ if (particle.type === 'form') {
68
+ engine.formPool.push(particle);
69
+ } else if (particle.type === 'burstSpark') {
70
+ engine.sparkPool.push(particle);
71
+ } else {
72
+ engine.emberPool.push(particle);
73
+ }
74
+ }
75
+
76
+ function isParticleBelowCanvas(engine, particle) {
77
+ const radius = Number.isFinite(particle.radius) ? particle.radius : 0;
78
+ const sparkLength = Number.isFinite(particle.sparkLength) ? particle.sparkLength : 0;
79
+ const margin = Math.max(
80
+ PARTICLE_PHYSICS_TUNING.offscreenBottomMargin,
81
+ radius * 8,
82
+ sparkLength * 2,
83
+ );
84
+
85
+ return particle.vy >= 0 && particle.y - margin > engine.height;
86
+ }
87
+
88
+ function randomBetween(min, max) {
89
+ return min + (Math.random() * (max - min));
90
+ }
91
+
92
+ export function updateRockets(engine, dt) {
93
+ const rockets = engine.rockets;
94
+ let writeIndex = 0;
95
+
96
+ for (let index = 0; index < rockets.length; index += 1) {
97
+ const rocket = rockets[index];
98
+ const previousX = rocket.x;
99
+ const previousY = rocket.y;
100
+
101
+ rocket.life -= dt;
102
+ rocket.x += rocket.vx * dt;
103
+ rocket.y += rocket.vy * dt;
104
+ rocket.trailTimer += dt;
105
+ rocket.prevX = previousX;
106
+ rocket.prevY = previousY;
107
+
108
+ if (rocket.tailEmbers?.enabled) {
109
+ const travelDistance = rocket.travelSpeed * dt;
110
+ const tailEmberDensity = Math.max(1, rocket.tailEmbers.count ?? engine.rocketTailEmbers.count);
111
+ rocket.tailEmberAccumulator += (dt * tailEmberDensity * TAIL_EMBER_ACCUMULATION.timeDensityFactor)
112
+ + (travelDistance * tailEmberDensity * TAIL_EMBER_ACCUMULATION.travelDensityFactor);
113
+ const emberBudget = Math.min(TAIL_EMBER_ACCUMULATION.maxSpawnBudget, Math.floor(rocket.tailEmberAccumulator));
114
+
115
+ if (emberBudget > 0) {
116
+ rocket.tailEmberAccumulator -= emberBudget;
117
+ spawnRocketTailEmbers(engine, rocket, emberBudget);
118
+ }
119
+ }
120
+
121
+ if (rocket.life <= 0 || hasRocketReachedTarget(rocket)) {
122
+ const burstX = rocket.hasExplicitTarget ? rocket.targetX : rocket.x;
123
+ const burstY = rocket.hasExplicitTarget ? rocket.targetY : rocket.y;
124
+ rocket.x = burstX;
125
+ rocket.y = burstY;
126
+ launchBurst(engine, burstX, burstY, rocket.burst);
127
+ scheduleCascadeBursts(engine, rocket);
128
+ continue;
129
+ }
130
+
131
+ if (writeIndex !== index) {
132
+ rockets[writeIndex] = rocket;
133
+ }
134
+ writeIndex += 1;
135
+ }
136
+
137
+ rockets.length = writeIndex;
138
+ }
139
+
140
+ export function updateParticles(engine, dt) {
141
+ const particles = engine.particles;
142
+ let writeIndex = 0;
143
+ const dragExponent = dt / PARTICLE_PHYSICS_TUNING.dragReferenceDtSeconds;
144
+ const formVerticalDragFactor = Math.pow(FORM_VERTICAL_MOTION_TUNING.perFrameDrag, dragExponent);
145
+
146
+ for (let index = 0; index < particles.length; index += 1) {
147
+ const particle = particles[index];
148
+
149
+ particle.life -= dt;
150
+ if (particle.life <= 0) {
151
+ releaseParticle(engine, particle);
152
+ continue;
153
+ }
154
+
155
+ const dragFactor = Math.pow(particle.drag, dragExponent);
156
+ particle.vx *= dragFactor;
157
+ if (particle.type === 'form') {
158
+ particle.vy *= formVerticalDragFactor;
159
+ } else {
160
+ particle.vy *= dragFactor;
161
+ }
162
+ particle.vy += particle.gravity * dt;
163
+ let wobbleVelocity = 0;
164
+ if (particle.type === 'form' && particle.formWobble > 0) {
165
+ const wobbleVelocityFactor = Math.min(
166
+ FORM_WOBBLE_UPDATE_TUNING.maxVelocityFactor,
167
+ 1 + (Math.abs(particle.vy) * FORM_WOBBLE_UPDATE_TUNING.velocityWobbleScale),
168
+ );
169
+ const timerRate = 0.6 + (0.4 * wobbleVelocityFactor);
170
+ particle.formWobbleTargetTimer -= dt * timerRate;
171
+ if (particle.formWobbleTargetTimer <= 0) {
172
+ particle.formWobbleTargetTimer = FORM_WOBBLE_UPDATE_TUNING.targetHoldMin
173
+ + (Math.random()
174
+ * (FORM_WOBBLE_UPDATE_TUNING.targetHoldMax - FORM_WOBBLE_UPDATE_TUNING.targetHoldMin));
175
+ const maxLateralSpeed = particle.formWobbleLateralSpeed;
176
+ const hasCalmWindow = Math.random() < FORM_WOBBLE_UPDATE_TUNING.calmChance;
177
+ let nextTarget = hasCalmWindow
178
+ ? 0
179
+ : ((Math.random() * 2) - 1) * maxLateralSpeed;
180
+ if (Math.random() < FORM_WOBBLE_UPDATE_TUNING.sameDirectionBias && particle.formWobbleTarget !== 0) {
181
+ nextTarget = Math.sign(particle.formWobbleTarget) * Math.abs(nextTarget);
182
+ }
183
+ nextTarget = (particle.formWobbleTarget * FORM_WOBBLE_UPDATE_TUNING.targetContinuity)
184
+ + (nextTarget * (1 - FORM_WOBBLE_UPDATE_TUNING.targetContinuity));
185
+ particle.formWobbleTarget = nextTarget;
186
+ }
187
+ const response = Math.min(1, FORM_WOBBLE_UPDATE_TUNING.targetResponse * dt * wobbleVelocityFactor);
188
+ const targetVelocity = particle.formWobbleVelocity
189
+ + ((particle.formWobbleTarget - particle.formWobbleVelocity) * response);
190
+ const maxDelta = FORM_WOBBLE_UPDATE_TUNING.maxVelocityDeltaPerSecond * dt;
191
+ const velocityDelta = targetVelocity - particle.formWobbleVelocity;
192
+ if (velocityDelta > maxDelta) {
193
+ particle.formWobbleVelocity += maxDelta;
194
+ } else if (velocityDelta < -maxDelta) {
195
+ particle.formWobbleVelocity -= maxDelta;
196
+ } else {
197
+ particle.formWobbleVelocity = targetVelocity;
198
+ }
199
+ wobbleVelocity = particle.formWobbleVelocity * wobbleVelocityFactor;
200
+ }
201
+ particle.x += (particle.vx + wobbleVelocity) * dt;
202
+ particle.y += particle.vy * dt;
203
+ if (particle.type === 'form' && particle.formSpin > 0) {
204
+ if (particle.formSpinBudget <= 0) {
205
+ particle.formSpinActive = false;
206
+ } else {
207
+ particle.formSpinToggleTimer -= dt;
208
+ if (particle.formSpinToggleTimer <= 0) {
209
+ particle.formSpinActive = !particle.formSpinActive;
210
+ particle.formSpinToggleTimer = particle.formSpinActive
211
+ ? randomBetween(
212
+ particle.formSpinActiveHoldMin ?? FORM_SPIN_UPDATE_TUNING.activeHoldMinFallback,
213
+ particle.formSpinActiveHoldMax ?? FORM_SPIN_UPDATE_TUNING.activeHoldMaxFallback,
214
+ )
215
+ : randomBetween(
216
+ particle.formSpinInactiveHoldMin ?? FORM_SPIN_UPDATE_TUNING.inactiveHoldMinFallback,
217
+ particle.formSpinInactiveHoldMax ?? FORM_SPIN_UPDATE_TUNING.inactiveHoldMaxFallback,
218
+ );
219
+ }
220
+ }
221
+
222
+ const spinIsActive = particle.formSpinActive && particle.formSpinBudget > 0;
223
+ const blendTarget = spinIsActive ? 1 : 0;
224
+ const blendResponse = Math.min(1, FORM_SPIN_UPDATE_TUNING.blendResponse * dt);
225
+ particle.formSpinBlend += (blendTarget - particle.formSpinBlend) * blendResponse;
226
+
227
+ if (spinIsActive) {
228
+ particle.formSpinBudget = Math.max(0, particle.formSpinBudget - dt);
229
+ const velocityFactor = Math.min(
230
+ FORM_SPIN_UPDATE_TUNING.maxVelocityFactor,
231
+ 1 + (Math.abs(particle.vy) * FORM_SPIN_UPDATE_TUNING.velocitySpinScale),
232
+ );
233
+ particle.formSpinRateWavePhase += particle.formSpinRateWaveSpeed * dt;
234
+ const rawRateWaveFactor = 1
235
+ + (Math.sin(particle.formSpinRateWavePhase) * particle.formSpinRateWaveAmplitude);
236
+ const rateWaveFactor = Math.min(
237
+ FORM_SPIN_UPDATE_TUNING.rateWaveFactorMax,
238
+ Math.max(FORM_SPIN_UPDATE_TUNING.rateWaveFactorMin, rawRateWaveFactor),
239
+ );
240
+ const spinStep = velocityFactor * rateWaveFactor * dt;
241
+ particle.formSpinPhase += particle.formSpinPhaseSpeed * spinStep;
242
+ particle.formSpinAngle += particle.formSpinAngleSpeed * spinStep;
243
+ const flipWave = Math.abs(Math.sin(particle.formSpinPhase));
244
+ const shapedFlipWave = Math.pow(flipWave, particle.formSpinFlipExponent ?? 1);
245
+ particle.formWidthScale = particle.formSpinMinWidthScale
246
+ + ((1 - particle.formSpinMinWidthScale) * shapedFlipWave);
247
+ } else if (particle.formSpinBlend <= 0.001) {
248
+ particle.formSpinBlend = 0;
249
+ particle.formWidthScale = 1;
250
+ }
251
+ }
252
+
253
+ if (isParticleBelowCanvas(engine, particle)) {
254
+ releaseParticle(engine, particle);
255
+ continue;
256
+ }
257
+
258
+ if (writeIndex !== index) {
259
+ particles[writeIndex] = particle;
260
+ }
261
+ writeIndex += 1;
262
+ }
263
+
264
+ particles.length = writeIndex;
265
+ }
266
+
267
+ export function updateFlashes(engine, dt) {
268
+ const flashes = engine.flashes;
269
+ let writeIndex = 0;
270
+
271
+ for (let index = 0; index < flashes.length; index += 1) {
272
+ const flash = flashes[index];
273
+
274
+ flash.age += dt;
275
+ if (flash.age >= flash.maxLife) {
276
+ continue;
277
+ }
278
+
279
+ if (writeIndex !== index) {
280
+ flashes[writeIndex] = flash;
281
+ }
282
+ writeIndex += 1;
283
+ }
284
+
285
+ flashes.length = writeIndex;
286
+ }
287
+
288
+ export function updateTwinkleClouds(engine, dt) {
289
+ const twinkleClouds = engine.twinkleClouds;
290
+ let writeIndex = 0;
291
+
292
+ for (let index = 0; index < twinkleClouds.length; index += 1) {
293
+ const cloud = twinkleClouds[index];
294
+
295
+ cloud.age += dt;
296
+ if (cloud.age >= cloud.maxLife) {
297
+ continue;
298
+ }
299
+
300
+ if (writeIndex !== index) {
301
+ twinkleClouds[writeIndex] = cloud;
302
+ }
303
+ writeIndex += 1;
304
+ }
305
+
306
+ twinkleClouds.length = writeIndex;
307
+ }