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,863 @@
1
+ import { clamp } from './mathResolvers.js';
2
+ import {
3
+ GLYPH_SPRITE_SIZE_MIN,
4
+ GLYPH_SPRITE_RADIUS_SCALE,
5
+ DEFAULT_BURST_TWINKLE_CLOUD_BRIGHTNESS,
6
+ DEFAULT_ROCKET_TAIL_EMBERS_BRIGHTNESS,
7
+ WHITE_BRIGHTNESS_REFERENCE,
8
+ } from './defaults.js';
9
+ import {
10
+ boostHexColor,
11
+ mixHexColor,
12
+ toAlphaColor,
13
+ } from './colorUtils.js';
14
+ import {
15
+ updateRockets,
16
+ updateParticles,
17
+ updateFlashes,
18
+ updateTwinkleClouds,
19
+ } from './update.js';
20
+ import {
21
+ drainBurstQueue,
22
+ resolveBurstFlashAttackDuration,
23
+ } from './burstSpawn.js';
24
+ import {
25
+ getNow,
26
+ getDocument,
27
+ getRequestAnimationFrame,
28
+ } from './runtimeLifecycle.js';
29
+
30
+ const ROCKET_RENDER_TUNING = {
31
+ minSparkLength: 8,
32
+ minLineWidth: 0.6,
33
+ };
34
+
35
+ const BURST_SPARK_RENDER_TUNING = {
36
+ headLengthRatio: 0.18,
37
+ spindleFirstSegmentRatio: 0.32,
38
+ spindleSecondSegmentRatio: 0.68,
39
+ spindleEdgeWidthMin: 0.55,
40
+ spindleEdgeWidthScale: 0.72,
41
+ spindleMiddleWidthMin: 0.8,
42
+ spindleMiddleWidthScale: 1.18,
43
+ spindleEdgeAlpha: 0.82,
44
+ spindleTipAlpha: 0.9,
45
+ fadeWidthScale: 0.82,
46
+ fadeFallbackAlpha: 0.92,
47
+ minDirectionVelocityEpsilon: 0.0001,
48
+ speedFactorMax: 1.1,
49
+ minLength: 2.5,
50
+ minWidth: 0.6,
51
+ spawnTailRevealDuration: 0.09,
52
+ };
53
+
54
+ const PARTICLE_RENDER_LIMITS = {
55
+ fastRectMinSize: 1,
56
+ emberMinRadius: 0.05,
57
+ emberMinSize: 0.25,
58
+ flashMinFade: 0.001,
59
+ flashMinConfigSize: 0.1,
60
+ flashMinRenderSize: 0.35,
61
+ flashPeakAlphaMin: 0,
62
+ flashPeakAlphaMax: 3,
63
+ flashInnerStopMin: 0.05,
64
+ flashInnerStopMax: 1,
65
+ twinkleMinLife: 0.001,
66
+ twinkleMinSize: 0.1,
67
+ maxFrameDtSeconds: 0.033,
68
+ maxScaledFrameDtMultiplier: 6,
69
+ };
70
+
71
+ const WHITE_COLOR = '#ffffff';
72
+ // White sparkles saturate quickly, so brightness is used as a soft alpha boost.
73
+ const FORM_SPIN_RENDER_TUNING = {
74
+ minWidthScale: 0.02,
75
+ highlightMinWidth: 0.01,
76
+ highlightMaxWidth: 1,
77
+ highlightWidthToRadians: Math.PI,
78
+ degToRad: Math.PI / 180,
79
+ brightnessBoostScale: 2.8,
80
+ saturationBoostScale: 1.7,
81
+ whiteMixScale: 0.12,
82
+ };
83
+
84
+ const FORM_SHAPE_RENDER_TUNING = {
85
+ rectangleAspectRatio: 6,
86
+ rectangleScale: 0.45,
87
+ };
88
+
89
+ const SPRITE_CACHE_TUNING = {
90
+ fadeWidth: 64,
91
+ fadeHeight: 16,
92
+ flashSize: 128,
93
+ glyphPadding: 4,
94
+ };
95
+
96
+ function resolveFirstGlyph(glyph, fallback = '★') {
97
+ if (typeof glyph !== 'string') {
98
+ return fallback;
99
+ }
100
+
101
+ const trimmed = glyph.trim();
102
+ if (!trimmed) {
103
+ return fallback;
104
+ }
105
+
106
+ return Array.from(trimmed)[0] ?? fallback;
107
+ }
108
+
109
+ let cachedFillStyle = null;
110
+ let cachedStrokeStyle = null;
111
+ let cachedGlobalAlpha = null;
112
+
113
+ function resetContextStateCache() {
114
+ cachedFillStyle = null;
115
+ cachedStrokeStyle = null;
116
+ cachedGlobalAlpha = null;
117
+ }
118
+
119
+ function invalidateContextStateCache() {
120
+ resetContextStateCache();
121
+ }
122
+
123
+ function setContextFillStyle(ctx, fillStyle) {
124
+ if (cachedFillStyle === fillStyle) {
125
+ return;
126
+ }
127
+
128
+ ctx.fillStyle = fillStyle;
129
+ cachedFillStyle = fillStyle;
130
+ }
131
+
132
+ function setContextStrokeStyle(ctx, strokeStyle) {
133
+ if (cachedStrokeStyle === strokeStyle) {
134
+ return;
135
+ }
136
+
137
+ ctx.strokeStyle = strokeStyle;
138
+ cachedStrokeStyle = strokeStyle;
139
+ }
140
+
141
+ function setContextGlobalAlpha(ctx, alpha) {
142
+ if (cachedGlobalAlpha === alpha) {
143
+ return;
144
+ }
145
+
146
+ ctx.globalAlpha = alpha;
147
+ cachedGlobalAlpha = alpha;
148
+ }
149
+
150
+ function setBaseCanvasTransform(ctx, engine) {
151
+ if (typeof ctx.setTransform !== 'function') {
152
+ return false;
153
+ }
154
+
155
+ const dpr = Number.isFinite(engine.dpr) && engine.dpr > 0 ? engine.dpr : 1;
156
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
157
+ return true;
158
+ }
159
+
160
+ function isEngineVisuallyIdle(engine) {
161
+ return (
162
+ engine.particles.length === 0
163
+ && engine.rockets.length === 0
164
+ && engine.flashes.length === 0
165
+ && engine.twinkleClouds.length === 0
166
+ && engine.burstQueue.length === 0
167
+ );
168
+ }
169
+
170
+ function clearRenderCachesWhenIdle(engine) {
171
+ if (!(engine.spriteCache instanceof Map)) {
172
+ return;
173
+ }
174
+
175
+ if (!isEngineVisuallyIdle(engine)) {
176
+ engine.renderCachesClearedForIdle = false;
177
+ return;
178
+ }
179
+
180
+ if (engine.renderCachesClearedForIdle) {
181
+ return;
182
+ }
183
+
184
+ engine.spriteCache.clear();
185
+ engine.renderCachesClearedForIdle = true;
186
+ }
187
+
188
+ function getFadeSprite(engine, color) {
189
+ const cachedSprite = engine.spriteCache.get(color);
190
+ if (cachedSprite) {
191
+ return cachedSprite;
192
+ }
193
+
194
+ const documentRef = getDocument(engine);
195
+ const canvas = documentRef?.createElement?.('canvas');
196
+ if (!canvas) {
197
+ return null;
198
+ }
199
+
200
+ canvas.width = SPRITE_CACHE_TUNING.fadeWidth;
201
+ canvas.height = SPRITE_CACHE_TUNING.fadeHeight;
202
+
203
+ const ctx = canvas.getContext?.('2d', { alpha: true });
204
+ if (!ctx || typeof ctx.createLinearGradient !== 'function') {
205
+ return null;
206
+ }
207
+
208
+ const centerY = canvas.height / 2;
209
+ const gradient = ctx.createLinearGradient(0, centerY, canvas.width, centerY);
210
+ gradient.addColorStop(0, toAlphaColor(color, 0));
211
+ gradient.addColorStop(1, color);
212
+
213
+ ctx.fillStyle = gradient;
214
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
215
+
216
+ engine.spriteCache.set(color, canvas);
217
+ return canvas;
218
+ }
219
+
220
+
221
+ function getBurstCenterFlashSprite(engine, color, innerStop) {
222
+ const key = `flash:${color}:${innerStop}`;
223
+ const cachedSprite = engine.spriteCache.get(key);
224
+ if (cachedSprite) {
225
+ return cachedSprite;
226
+ }
227
+
228
+ const documentRef = getDocument(engine);
229
+ const canvas = documentRef?.createElement?.('canvas');
230
+ if (!canvas) {
231
+ return null;
232
+ }
233
+
234
+ canvas.width = SPRITE_CACHE_TUNING.flashSize;
235
+ canvas.height = SPRITE_CACHE_TUNING.flashSize;
236
+
237
+ const ctx = canvas.getContext?.('2d', { alpha: true });
238
+ if (!ctx || typeof ctx.createRadialGradient !== 'function') {
239
+ return null;
240
+ }
241
+
242
+ const center = canvas.width / 2;
243
+ const gradient = ctx.createRadialGradient(center, center, 0, center, center, center);
244
+ gradient.addColorStop(0, toAlphaColor(color, 1));
245
+ gradient.addColorStop(innerStop, toAlphaColor(color, 1));
246
+ gradient.addColorStop(1, toAlphaColor(color, 0));
247
+
248
+ ctx.fillStyle = gradient;
249
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
250
+
251
+ engine.spriteCache.set(key, canvas);
252
+ return canvas;
253
+ }
254
+
255
+ function getGlyphSprite(engine, key, glyph, color, roundedSize) {
256
+ const cachedSprite = engine.spriteCache.get(key);
257
+ if (cachedSprite) {
258
+ return cachedSprite;
259
+ }
260
+
261
+ const documentRef = getDocument(engine);
262
+ const canvas = documentRef?.createElement?.('canvas');
263
+ if (!canvas) {
264
+ return null;
265
+ }
266
+
267
+ const padding = SPRITE_CACHE_TUNING.glyphPadding;
268
+ const spriteSize = Math.max(1, roundedSize + (padding * 2));
269
+ canvas.width = spriteSize;
270
+ canvas.height = spriteSize;
271
+
272
+ const ctx = canvas.getContext?.('2d', { alpha: true });
273
+ if (!ctx || typeof ctx.fillText !== 'function') {
274
+ return null;
275
+ }
276
+
277
+ ctx.font = `700 ${roundedSize}px "Segoe UI Symbol", "Arial Unicode MS", "Noto Sans Symbols 2", sans-serif`;
278
+ ctx.textAlign = 'center';
279
+ ctx.textBaseline = 'middle';
280
+ ctx.fillStyle = color;
281
+ ctx.fillText(glyph, spriteSize / 2, spriteSize / 2);
282
+
283
+ engine.spriteCache.set(key, canvas);
284
+ return canvas;
285
+ }
286
+
287
+ function prepareParticleRenderState(engine) {
288
+ const hasQueuedBurstPressure = engine.burstQueue.length > 0;
289
+ const hasParticlePressure = engine.particles.length >= engine.particlePressureThreshold;
290
+
291
+ engine.renderPressureMode = hasQueuedBurstPressure || hasParticlePressure;
292
+ }
293
+
294
+ function drawSparkFadeRocket(engine, rocket) {
295
+ const ctx = engine.ctx;
296
+ const directionX = rocket.directionX;
297
+ const directionY = rocket.directionY;
298
+ const initialSparkLength = Math.max(ROCKET_RENDER_TUNING.minSparkLength, rocket.sparkLength);
299
+ const finalSparkLength = Math.max(ROCKET_RENDER_TUNING.minSparkLength, rocket.sparkFinalLength);
300
+ const maxLife = rocket.maxLife > 0 ? rocket.maxLife : 0;
301
+ const progress = maxLife > 0 ? clamp(1 - (rocket.life / maxLife), 0, 1) : 0;
302
+ const sparkLength = initialSparkLength + ((finalSparkLength - initialSparkLength) * progress);
303
+ const rocketThickness = rocket.thickness;
304
+ const tailX = rocket.x - (directionX * sparkLength);
305
+ const tailY = rocket.y - (directionY * sparkLength);
306
+ const baseColor = rocket.color;
307
+ const lineWidth = Math.max(ROCKET_RENDER_TUNING.minLineWidth, rocketThickness);
308
+ const sprite = getFadeSprite(engine, baseColor);
309
+
310
+ if (sprite) {
311
+ if (setBaseCanvasTransform(ctx, engine)) {
312
+ ctx.translate(rocket.x, rocket.y);
313
+ ctx.rotate(rocket.rotationAngle);
314
+ setContextGlobalAlpha(ctx, 1);
315
+ ctx.drawImage(sprite, -sparkLength, -(lineWidth / 2), sparkLength, lineWidth);
316
+
317
+ setContextFillStyle(ctx, baseColor);
318
+ ctx.beginPath();
319
+ ctx.arc(0, 0, lineWidth / 2, 0, Math.PI * 2);
320
+ ctx.fill();
321
+
322
+ setBaseCanvasTransform(ctx, engine);
323
+ return;
324
+ }
325
+
326
+ ctx.save();
327
+ ctx.translate(rocket.x, rocket.y);
328
+ ctx.rotate(rocket.rotationAngle);
329
+ ctx.globalAlpha = 1;
330
+ ctx.drawImage(sprite, -sparkLength, -(lineWidth / 2), sparkLength, lineWidth);
331
+
332
+ setContextFillStyle(ctx, baseColor);
333
+ ctx.beginPath();
334
+ ctx.arc(0, 0, lineWidth / 2, 0, Math.PI * 2);
335
+ ctx.fill();
336
+
337
+ ctx.restore();
338
+ invalidateContextStateCache();
339
+ return;
340
+ }
341
+
342
+ const gradient = ctx.createLinearGradient?.(tailX, tailY, rocket.x, rocket.y);
343
+
344
+ ctx.lineCap = 'round';
345
+ setContextGlobalAlpha(ctx, 1);
346
+ ctx.lineWidth = lineWidth;
347
+
348
+ if (gradient && typeof gradient.addColorStop === 'function') {
349
+ gradient.addColorStop(0, toAlphaColor(baseColor, 0));
350
+ gradient.addColorStop(1, toAlphaColor(baseColor, 1));
351
+ setContextStrokeStyle(ctx, gradient);
352
+ } else {
353
+ setContextStrokeStyle(ctx, baseColor);
354
+ setContextGlobalAlpha(ctx, 0.9);
355
+ }
356
+
357
+ ctx.beginPath();
358
+ ctx.moveTo(tailX, tailY);
359
+ ctx.lineTo(rocket.x, rocket.y);
360
+ ctx.stroke();
361
+ }
362
+
363
+ function fillParticleShape(engine, particle, radiusScale = 1, x = particle.x, y = particle.y) {
364
+ const ctx = engine.ctx;
365
+ const radius = particle.radius * radiusScale;
366
+ if (radius <= 0) {
367
+ return;
368
+ }
369
+
370
+ if (engine.renderPressureMode && radius <= engine.fastRectRadiusThreshold) {
371
+ const size = Math.max(PARTICLE_RENDER_LIMITS.fastRectMinSize, radius * 2);
372
+ ctx.fillRect(x - (size / 2), y - (size / 2), size, size);
373
+ return;
374
+ }
375
+
376
+ ctx.beginPath();
377
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
378
+ ctx.fill();
379
+ }
380
+
381
+ function drawSquareFormParticle(ctx, particle, x = particle.x, y = particle.y) {
382
+ const radius = particle.radius;
383
+ if (radius <= 0) {
384
+ return;
385
+ }
386
+
387
+ const side = radius * 2;
388
+ const halfSide = side / 2;
389
+ ctx.fillRect(x - halfSide, y - halfSide, side, side);
390
+ }
391
+
392
+ function drawRectangleFormParticle(ctx, particle, x = particle.x, y = particle.y) {
393
+ const radius = particle.radius;
394
+ if (radius <= 0) {
395
+ return;
396
+ }
397
+
398
+ const height = radius * 2 * FORM_SHAPE_RENDER_TUNING.rectangleScale;
399
+ const width = height * FORM_SHAPE_RENDER_TUNING.rectangleAspectRatio;
400
+ ctx.fillRect(x - (width / 2), y - (height / 2), width, height);
401
+ }
402
+
403
+ function drawBurstSparkUniformParticle(engine, particle, alpha, sparkLength, sparkWidth, directionX, directionY) {
404
+ const ctx = engine.ctx;
405
+ const headLength = sparkLength * BURST_SPARK_RENDER_TUNING.headLengthRatio;
406
+ const tailX = particle.x - (directionX * sparkLength);
407
+ const tailY = particle.y - (directionY * sparkLength);
408
+ const headX = particle.x + (directionX * headLength);
409
+ const headY = particle.y + (directionY * headLength);
410
+
411
+ setContextGlobalAlpha(ctx, alpha);
412
+ setContextStrokeStyle(ctx, particle.color);
413
+ ctx.lineWidth = Math.max(ROCKET_RENDER_TUNING.minLineWidth, sparkWidth);
414
+ ctx.lineCap = 'round';
415
+ ctx.beginPath();
416
+ ctx.moveTo(tailX, tailY);
417
+ ctx.lineTo(headX, headY);
418
+ ctx.stroke();
419
+ }
420
+
421
+ function drawBurstSparkSpindleParticle(engine, particle, alpha, sparkLength, sparkWidth, directionX, directionY) {
422
+ const ctx = engine.ctx;
423
+ const headLength = sparkLength * BURST_SPARK_RENDER_TUNING.headLengthRatio;
424
+ const totalLength = sparkLength + headLength;
425
+ const tailX = particle.x - (directionX * sparkLength);
426
+ const tailY = particle.y - (directionY * sparkLength);
427
+ const tipX = particle.x + (directionX * headLength);
428
+ const tipY = particle.y + (directionY * headLength);
429
+ const mid1X = tailX + (directionX * totalLength * BURST_SPARK_RENDER_TUNING.spindleFirstSegmentRatio);
430
+ const mid1Y = tailY + (directionY * totalLength * BURST_SPARK_RENDER_TUNING.spindleFirstSegmentRatio);
431
+ const mid2X = tailX + (directionX * totalLength * BURST_SPARK_RENDER_TUNING.spindleSecondSegmentRatio);
432
+ const mid2Y = tailY + (directionY * totalLength * BURST_SPARK_RENDER_TUNING.spindleSecondSegmentRatio);
433
+ const edgeWidth = Math.max(BURST_SPARK_RENDER_TUNING.spindleEdgeWidthMin, sparkWidth * BURST_SPARK_RENDER_TUNING.spindleEdgeWidthScale);
434
+ const middleWidth = Math.max(BURST_SPARK_RENDER_TUNING.spindleMiddleWidthMin, sparkWidth * BURST_SPARK_RENDER_TUNING.spindleMiddleWidthScale);
435
+
436
+ ctx.lineCap = 'round';
437
+ setContextStrokeStyle(ctx, particle.color);
438
+
439
+ setContextGlobalAlpha(ctx, alpha * BURST_SPARK_RENDER_TUNING.spindleEdgeAlpha);
440
+ ctx.lineWidth = edgeWidth;
441
+ ctx.beginPath();
442
+ ctx.moveTo(tailX, tailY);
443
+ ctx.lineTo(mid1X, mid1Y);
444
+ ctx.stroke();
445
+
446
+ setContextGlobalAlpha(ctx, alpha);
447
+ ctx.lineWidth = middleWidth;
448
+ ctx.beginPath();
449
+ ctx.moveTo(mid1X, mid1Y);
450
+ ctx.lineTo(mid2X, mid2Y);
451
+ ctx.stroke();
452
+
453
+ setContextGlobalAlpha(ctx, alpha * BURST_SPARK_RENDER_TUNING.spindleTipAlpha);
454
+ ctx.lineWidth = edgeWidth;
455
+ ctx.beginPath();
456
+ ctx.moveTo(mid2X, mid2Y);
457
+ ctx.lineTo(tipX, tipY);
458
+ ctx.stroke();
459
+ }
460
+
461
+ function drawBurstSparkFadeParticle(engine, particle, alpha, sparkLength, sparkWidth, directionX, directionY) {
462
+ const ctx = engine.ctx;
463
+ const headLength = sparkLength * BURST_SPARK_RENDER_TUNING.headLengthRatio;
464
+ const totalLength = sparkLength + headLength;
465
+ const sprite = getFadeSprite(engine, particle.color);
466
+
467
+ if (!sprite) {
468
+ const tailX = particle.x - (directionX * sparkLength);
469
+ const tailY = particle.y - (directionY * sparkLength);
470
+ const headX = particle.x + (directionX * headLength);
471
+ const headY = particle.y + (directionY * headLength);
472
+ const gradient = ctx.createLinearGradient?.(tailX, tailY, headX, headY);
473
+
474
+ ctx.lineCap = 'round';
475
+ ctx.lineWidth = Math.max(ROCKET_RENDER_TUNING.minLineWidth, sparkWidth * BURST_SPARK_RENDER_TUNING.fadeWidthScale);
476
+
477
+ if (gradient && typeof gradient.addColorStop === 'function') {
478
+ gradient.addColorStop(0, toAlphaColor(particle.color, 0));
479
+ gradient.addColorStop(1, toAlphaColor(particle.color, alpha));
480
+ setContextStrokeStyle(ctx, gradient);
481
+ setContextGlobalAlpha(ctx, 1);
482
+ } else {
483
+ setContextStrokeStyle(ctx, particle.color);
484
+ setContextGlobalAlpha(ctx, alpha * BURST_SPARK_RENDER_TUNING.fadeFallbackAlpha);
485
+ }
486
+
487
+ ctx.beginPath();
488
+ ctx.moveTo(tailX, tailY);
489
+ ctx.lineTo(headX, headY);
490
+ ctx.stroke();
491
+ return;
492
+ }
493
+
494
+ const renderWidth = Math.max(ROCKET_RENDER_TUNING.minLineWidth, sparkWidth * BURST_SPARK_RENDER_TUNING.fadeWidthScale);
495
+ if (setBaseCanvasTransform(ctx, engine)) {
496
+ ctx.translate(particle.x, particle.y);
497
+ ctx.rotate(Math.atan2(directionY, directionX));
498
+ setContextGlobalAlpha(ctx, alpha * BURST_SPARK_RENDER_TUNING.fadeFallbackAlpha);
499
+ ctx.drawImage(sprite, -sparkLength, -(renderWidth / 2), totalLength, renderWidth);
500
+ setBaseCanvasTransform(ctx, engine);
501
+ return;
502
+ }
503
+
504
+ ctx.save?.();
505
+ ctx.translate(particle.x, particle.y);
506
+ ctx.rotate(Math.atan2(directionY, directionX));
507
+ ctx.globalAlpha = alpha * BURST_SPARK_RENDER_TUNING.fadeFallbackAlpha;
508
+ ctx.drawImage(sprite, -sparkLength, -(renderWidth / 2), totalLength, renderWidth);
509
+ ctx.restore?.();
510
+ invalidateContextStateCache();
511
+ }
512
+
513
+ function drawRocketEmberParticle(engine, particle) {
514
+ const ctx = engine.ctx;
515
+ const alpha = particle.life / particle.maxLife;
516
+ const radius = Math.max(PARTICLE_RENDER_LIMITS.emberMinRadius, particle.radius ?? 1);
517
+ const size = Math.max(PARTICLE_RENDER_LIMITS.emberMinSize, radius * 2);
518
+ const brightnessScale = Number.isFinite(particle.brightnessScale)
519
+ ? particle.brightnessScale
520
+ : clamp(
521
+ (Number.isFinite(particle.brightness)
522
+ ? particle.brightness
523
+ : DEFAULT_ROCKET_TAIL_EMBERS_BRIGHTNESS) / WHITE_BRIGHTNESS_REFERENCE,
524
+ 0,
525
+ 1,
526
+ );
527
+
528
+ setContextGlobalAlpha(ctx, alpha * brightnessScale);
529
+ setContextFillStyle(ctx, WHITE_COLOR);
530
+ ctx.fillRect(particle.x - (size / 2), particle.y - (size / 2), size, size);
531
+ }
532
+
533
+ function drawBurstSparkParticle(engine, particle) {
534
+ const alpha = particle.life / particle.maxLife;
535
+ const velocity = Math.hypot(particle.vx, particle.vy);
536
+ const hasDirectionalVelocity = velocity > BURST_SPARK_RENDER_TUNING.minDirectionVelocityEpsilon;
537
+ const safeVelocity = hasDirectionalVelocity ? velocity : 1;
538
+ const directionX = hasDirectionalVelocity ? particle.vx / safeVelocity : 0;
539
+ const directionY = hasDirectionalVelocity ? particle.vy / safeVelocity : -1;
540
+ const initialSpeed = particle.initialSpeed;
541
+ const speedRatio = clamp(safeVelocity / initialSpeed, 0, BURST_SPARK_RENDER_TUNING.speedFactorMax);
542
+ const speedFactor = speedRatio;
543
+ const sparkDepthFactor = clamp(particle.sparkDepthFactor ?? 1, 0, 1);
544
+ const sparkTailScale = Math.max(0, particle.sparkTailScale ?? 1);
545
+ const sparkAge = Math.max(0, particle.maxLife - particle.life);
546
+ const spawnTailRevealFactor = clamp(
547
+ sparkAge / BURST_SPARK_RENDER_TUNING.spawnTailRevealDuration,
548
+ 0,
549
+ 1,
550
+ );
551
+ const projectedSparkLength = particle.sparkLength * sparkTailScale * speedFactor * sparkDepthFactor;
552
+ const minProjectedLength = BURST_SPARK_RENDER_TUNING.minLength * sparkTailScale * sparkDepthFactor * speedFactor;
553
+ const sparkLength = Math.max(minProjectedLength, projectedSparkLength) * spawnTailRevealFactor;
554
+ const sparkWidth = Math.max(BURST_SPARK_RENDER_TUNING.minWidth, particle.sparkThickness);
555
+
556
+ if (sparkLength <= 0) {
557
+ return;
558
+ }
559
+
560
+ if (particle.sparkShape === 'fade') {
561
+ drawBurstSparkFadeParticle(engine, particle, alpha, sparkLength, sparkWidth, directionX, directionY);
562
+ return;
563
+ }
564
+
565
+ if (particle.sparkShape === 'spindle') {
566
+ drawBurstSparkSpindleParticle(engine, particle, alpha, sparkLength, sparkWidth, directionX, directionY);
567
+ return;
568
+ }
569
+
570
+ drawBurstSparkUniformParticle(engine, particle, alpha, sparkLength, sparkWidth, directionX, directionY);
571
+ }
572
+
573
+ function drawBurstCenterFlash(engine, flash) {
574
+ const ctx = engine.ctx;
575
+ const maxLife = flash.maxLife;
576
+ const age = clamp(flash.age, 0, maxLife);
577
+ const attack = resolveBurstFlashAttackDuration(flash.attack);
578
+ const fade = Math.max(PARTICLE_RENDER_LIMITS.flashMinFade, flash.fade);
579
+ const startSize = Math.max(PARTICLE_RENDER_LIMITS.flashMinConfigSize, flash.startSize);
580
+ const endSize = Math.max(PARTICLE_RENDER_LIMITS.flashMinConfigSize, flash.endSize);
581
+ const peakAlpha = clamp(flash.peakAlpha, PARTICLE_RENDER_LIMITS.flashPeakAlphaMin, PARTICLE_RENDER_LIMITS.flashPeakAlphaMax);
582
+ const innerStop = clamp(flash.innerStop, PARTICLE_RENDER_LIMITS.flashInnerStopMin, PARTICLE_RENDER_LIMITS.flashInnerStopMax);
583
+ const progress = clamp(age / maxLife, 0, 1);
584
+ const size = Math.max(PARTICLE_RENDER_LIMITS.flashMinRenderSize, startSize + ((endSize - startSize) * progress));
585
+ const envelope = age <= attack
586
+ ? (age / attack)
587
+ : (1 - ((age - attack) / fade));
588
+ const alpha = clamp(envelope, 0, 1) * peakAlpha;
589
+ const sprite = getBurstCenterFlashSprite(engine, flash.color, innerStop);
590
+
591
+ if (sprite) {
592
+ const diameter = size * 2;
593
+ setContextGlobalAlpha(ctx, alpha);
594
+ ctx.drawImage(sprite, flash.x - size, flash.y - size, diameter, diameter);
595
+ return;
596
+ }
597
+
598
+ const gradient = ctx.createRadialGradient?.(flash.x, flash.y, 0, flash.x, flash.y, size);
599
+
600
+ ctx.beginPath();
601
+ ctx.arc(flash.x, flash.y, size, 0, Math.PI * 2);
602
+
603
+ if (gradient && typeof gradient.addColorStop === 'function') {
604
+ gradient.addColorStop(0, toAlphaColor(flash.color, alpha));
605
+ gradient.addColorStop(innerStop, toAlphaColor(flash.color, alpha));
606
+ gradient.addColorStop(1, toAlphaColor(flash.color, 0));
607
+ setContextFillStyle(ctx, gradient);
608
+ setContextGlobalAlpha(ctx, 1);
609
+ } else {
610
+ setContextFillStyle(ctx, flash.color);
611
+ setContextGlobalAlpha(ctx, alpha);
612
+ }
613
+
614
+ ctx.fill();
615
+ }
616
+
617
+ function drawBurstTwinkleCloud(engine, cloud) {
618
+ const ctx = engine.ctx;
619
+ const cloudAge = cloud.age;
620
+ if (cloudAge < cloud.startDelay) {
621
+ return;
622
+ }
623
+
624
+ const brightness = Number.isFinite(cloud.brightness)
625
+ ? cloud.brightness
626
+ : DEFAULT_BURST_TWINKLE_CLOUD_BRIGHTNESS;
627
+ const brightnessScale = Number.isFinite(cloud.brightnessScale)
628
+ ? cloud.brightnessScale
629
+ : clamp(brightness / WHITE_BRIGHTNESS_REFERENCE, 0, 1);
630
+ setContextFillStyle(ctx, WHITE_COLOR);
631
+
632
+ for (let index = 0; index < cloud.twinkles.length; index += 1) {
633
+ const twinkle = cloud.twinkles[index];
634
+ const twinkleStart = twinkle.appearAt;
635
+ const twinkleEnd = twinkleStart + twinkle.life;
636
+
637
+ if (cloudAge < twinkleStart || cloudAge > twinkleEnd) {
638
+ continue;
639
+ }
640
+
641
+ const life = Math.max(PARTICLE_RENDER_LIMITS.twinkleMinLife, twinkle.life);
642
+ const progress = clamp((cloudAge - twinkleStart) / life, 0, 1);
643
+ const alpha = clamp(Math.sin(Math.PI * progress) * twinkle.alphaScale, 0, 1);
644
+ const size = Math.max(PARTICLE_RENDER_LIMITS.twinkleMinSize, twinkle.size);
645
+
646
+ setContextGlobalAlpha(ctx, alpha * brightnessScale);
647
+ ctx.fillRect(twinkle.x - (size / 2), twinkle.y - (size / 2), size, size);
648
+ }
649
+ }
650
+
651
+ function drawFormGlyphParticle(engine, particle, drawX = particle.x, drawY = particle.y, renderColor = particle.color) {
652
+ const ctx = engine.ctx;
653
+ const radius = particle.radius ?? 2;
654
+ const glyph = typeof particle.formGlyph === 'string'
655
+ ? particle.formGlyph
656
+ : resolveFirstGlyph(engine.burstForms.glyph);
657
+ const glyphSize = particle.glyphSpriteSize
658
+ ?? Math.max(GLYPH_SPRITE_SIZE_MIN, radius * GLYPH_SPRITE_RADIUS_SCALE);
659
+ const roundedSize = Math.round(glyphSize);
660
+ const spriteKey = `${glyph}|${renderColor}|${roundedSize}`;
661
+
662
+ if (!particle.glyphSpriteSize) {
663
+ particle.glyphSpriteSize = glyphSize;
664
+ }
665
+
666
+ const sprite = getGlyphSprite(engine, spriteKey, glyph, renderColor, roundedSize);
667
+
668
+ if (!sprite) {
669
+ ctx.save?.();
670
+ ctx.font = `700 ${glyphSize}px "Segoe UI Symbol", "Arial Unicode MS", "Noto Sans Symbols 2", sans-serif`;
671
+ ctx.textAlign = 'center';
672
+ ctx.textBaseline = 'middle';
673
+ ctx.fillStyle = renderColor;
674
+ ctx.fillText(glyph, drawX, drawY);
675
+ ctx.restore?.();
676
+ invalidateContextStateCache();
677
+ return;
678
+ }
679
+
680
+ ctx.drawImage(sprite, drawX - (sprite.width / 2), drawY - (sprite.height / 2));
681
+ }
682
+
683
+ function resolveRenderableFormShape(shape) {
684
+ if (
685
+ shape === 'glyph'
686
+ || shape === 'square'
687
+ || shape === 'rectangle'
688
+ ) {
689
+ return shape;
690
+ }
691
+
692
+ return 'circle';
693
+ }
694
+
695
+ function resolveFormParticleRenderColor(particle) {
696
+ const highlight = particle.formHighlight ?? 0;
697
+ const width = particle.formHighlightWidth ?? 0;
698
+ const spinBlend = particle.formSpinBlend ?? 0;
699
+ if (highlight <= 0 || width <= 0 || spinBlend <= 0) {
700
+ return particle.color;
701
+ }
702
+
703
+ const phase = particle.formSpinPhase ?? 0;
704
+ const highlightAngle = (particle.formHighlightAngleDeg ?? 0) * FORM_SPIN_RENDER_TUNING.degToRad;
705
+ const phaseDelta = Math.abs(Math.atan2(
706
+ Math.sin(phase - highlightAngle),
707
+ Math.cos(phase - highlightAngle),
708
+ ));
709
+ const highlightWidth = clamp(
710
+ width,
711
+ FORM_SPIN_RENDER_TUNING.highlightMinWidth,
712
+ FORM_SPIN_RENDER_TUNING.highlightMaxWidth,
713
+ ) * FORM_SPIN_RENDER_TUNING.highlightWidthToRadians;
714
+ const highlightWave = clamp(1 - (phaseDelta / highlightWidth), 0, 1);
715
+ const secondaryPhaseDelta = Math.abs(Math.atan2(
716
+ Math.sin(phase - highlightAngle - Math.PI),
717
+ Math.cos(phase - highlightAngle - Math.PI),
718
+ ));
719
+ const secondaryWave = clamp(1 - (secondaryPhaseDelta / highlightWidth), 0, 1) * 0.35;
720
+ const amount = clamp(Math.max(highlightWave, secondaryWave) * highlight * spinBlend, 0, 1);
721
+
722
+ if (amount <= 0) {
723
+ return particle.color;
724
+ }
725
+
726
+ const boostedColor = boostHexColor(
727
+ particle.color,
728
+ amount * FORM_SPIN_RENDER_TUNING.brightnessBoostScale,
729
+ amount * FORM_SPIN_RENDER_TUNING.saturationBoostScale,
730
+ );
731
+ return mixHexColor(boostedColor, WHITE_COLOR, amount * FORM_SPIN_RENDER_TUNING.whiteMixScale);
732
+ }
733
+
734
+ function drawFormShapeParticle(engine, particle, shape, drawX = particle.x, drawY = particle.y) {
735
+ const ctx = engine.ctx;
736
+ if (shape === 'square') {
737
+ drawSquareFormParticle(ctx, particle, drawX, drawY);
738
+ return;
739
+ }
740
+
741
+ if (shape === 'rectangle') {
742
+ drawRectangleFormParticle(ctx, particle, drawX, drawY);
743
+ return;
744
+ }
745
+
746
+ fillParticleShape(engine, particle, 1, drawX, drawY);
747
+ }
748
+
749
+ function drawFormParticle(engine, particle) {
750
+ const ctx = engine.ctx;
751
+ const alpha = particle.life / particle.maxLife;
752
+ const shape = resolveRenderableFormShape(particle.formShape ?? engine.burstForms.shape);
753
+ const spinStrength = particle.formSpin ?? 0;
754
+ const spinBlend = particle.formSpinBlend ?? (spinStrength > 0 ? 1 : 0);
755
+ const hasSpinTransform = spinStrength > 0;
756
+ const renderColor = resolveFormParticleRenderColor(particle);
757
+ setContextGlobalAlpha(ctx, alpha);
758
+ if (hasSpinTransform) {
759
+ const rawWidthScale = particle.formWidthScale ?? 1;
760
+ const widthScale = Math.max(
761
+ FORM_SPIN_RENDER_TUNING.minWidthScale,
762
+ 1 + ((rawWidthScale - 1) * spinBlend),
763
+ );
764
+ if (setBaseCanvasTransform(ctx, engine)) {
765
+ ctx.translate(particle.x, particle.y);
766
+ ctx.rotate(particle.formSpinAngle ?? 0);
767
+ ctx.scale(widthScale, 1);
768
+ if (shape === 'glyph') {
769
+ drawFormGlyphParticle(engine, particle, 0, 0, renderColor);
770
+ } else {
771
+ setContextFillStyle(ctx, renderColor);
772
+ drawFormShapeParticle(engine, particle, shape, 0, 0);
773
+ }
774
+ setBaseCanvasTransform(ctx, engine);
775
+ return;
776
+ }
777
+
778
+ ctx.save?.();
779
+ ctx.translate(particle.x, particle.y);
780
+ ctx.rotate(particle.formSpinAngle ?? 0);
781
+ ctx.scale(widthScale, 1);
782
+ if (shape === 'glyph') {
783
+ drawFormGlyphParticle(engine, particle, 0, 0, renderColor);
784
+ } else {
785
+ setContextFillStyle(ctx, renderColor);
786
+ drawFormShapeParticle(engine, particle, shape, 0, 0);
787
+ }
788
+ ctx.restore?.();
789
+ invalidateContextStateCache();
790
+ return;
791
+ }
792
+
793
+ if (shape === 'glyph') {
794
+ drawFormGlyphParticle(engine, particle, particle.x, particle.y, renderColor);
795
+ return;
796
+ }
797
+
798
+ setContextFillStyle(ctx, renderColor);
799
+ drawFormShapeParticle(engine, particle, shape);
800
+ }
801
+
802
+ export function loop(engine, now) {
803
+ if (!engine.running || !engine.ctx) {
804
+ return;
805
+ }
806
+
807
+ const safeNow = Number.isFinite(now) ? now : getNow(engine);
808
+ const rawDt = Math.min((safeNow - engine.lastTime) / 1000, PARTICLE_RENDER_LIMITS.maxFrameDtSeconds);
809
+ const timeScale = Number.isFinite(engine.timeScale) && engine.timeScale > 0 ? engine.timeScale : 1;
810
+ const dt = Math.min(
811
+ rawDt * timeScale,
812
+ PARTICLE_RENDER_LIMITS.maxFrameDtSeconds * PARTICLE_RENDER_LIMITS.maxScaledFrameDtMultiplier,
813
+ );
814
+ engine.lastTime = safeNow;
815
+ const ctx = engine.ctx;
816
+ resetContextStateCache();
817
+
818
+ ctx.clearRect(0, 0, engine.width, engine.height);
819
+ updateRockets(engine, dt);
820
+ updateParticles(engine, dt);
821
+ updateFlashes(engine, dt);
822
+ updateTwinkleClouds(engine, dt);
823
+ prepareParticleRenderState(engine);
824
+
825
+ ctx.globalCompositeOperation = 'lighter';
826
+
827
+ for (let index = 0; index < engine.flashes.length; index += 1) {
828
+ drawBurstCenterFlash(engine, engine.flashes[index]);
829
+ }
830
+ for (let index = 0; index < engine.particles.length; index += 1) {
831
+ const particle = engine.particles[index];
832
+ if (particle.type === 'rocketEmber') {
833
+ drawRocketEmberParticle(engine, particle);
834
+ continue;
835
+ }
836
+
837
+ if (particle.type === 'burstSpark') {
838
+ drawBurstSparkParticle(engine, particle);
839
+ continue;
840
+ }
841
+
842
+ drawFormParticle(engine, particle);
843
+ }
844
+
845
+ for (let index = 0; index < engine.twinkleClouds.length; index += 1) {
846
+ drawBurstTwinkleCloud(engine, engine.twinkleClouds[index]);
847
+ }
848
+
849
+ ctx.globalCompositeOperation = 'source-over';
850
+ setContextGlobalAlpha(ctx, 1);
851
+
852
+ for (let index = 0; index < engine.rockets.length; index += 1) {
853
+ drawSparkFadeRocket(engine, engine.rockets[index]);
854
+ }
855
+
856
+ drainBurstQueue(engine);
857
+ clearRenderCachesWhenIdle(engine);
858
+
859
+ const requestAnimationFrameFn = getRequestAnimationFrame(engine);
860
+ if (engine.running && typeof requestAnimationFrameFn === 'function') {
861
+ engine.rafId = requestAnimationFrameFn(engine.loopFrame);
862
+ }
863
+ }