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,791 @@
1
+ import {
2
+ clamp,
3
+ randomRange,
4
+ resolveEnvelopeTimeScale,
5
+ resolveScaledInteger,
6
+ resolveScaledInverseOverride,
7
+ resolveScaledOverride,
8
+ resolveSizeScale,
9
+ } from './mathResolvers.js';
10
+ import {
11
+ WHITE_BRIGHTNESS_REFERENCE,
12
+ } from './defaults.js';
13
+ import {
14
+ isPlainRecord,
15
+ normalizeGroupedPublicOptions,
16
+ resolveBurstColorMode,
17
+ resolveBurstEntityToggle,
18
+ } from './valueNormalizers.js';
19
+ import {
20
+ normalizeHexColor,
21
+ } from './colorUtils.js';
22
+ import { buildBurstOptions } from './burstConfigBuilder.js';
23
+ import {
24
+ launchBurst,
25
+ resolveBurstBaseColor,
26
+ } from './burstSpawn.js';
27
+ import {
28
+ ensureCanvas,
29
+ resolveScaledDelay,
30
+ scheduleTimeout,
31
+ shouldReduceMotion,
32
+ startLoop,
33
+ } from './runtimeLifecycle.js';
34
+
35
+
36
+ const ROCKET_TARGET_TUNING = {
37
+ targetXMinRatio: 0.25,
38
+ targetXMaxRatio: 0.75,
39
+ targetXJitter: 80,
40
+ targetYMinRatio: 0.14,
41
+ targetYMaxRatio: 0.38,
42
+ explicitStartJitterX: 72,
43
+ explicitStartOffsetY: 48,
44
+ fallbackStartOffsetYMin: 20,
45
+ fallbackStartOffsetYMax: 80,
46
+ };
47
+
48
+ const ROCKET_FLIGHT_GUARDS = {
49
+ minDistanceEpsilon: 0.0001,
50
+ };
51
+
52
+ const ROCKET_DURATION_LIMITS = {
53
+ defaultMin: 0.7,
54
+ defaultMax: 1.0,
55
+ };
56
+
57
+ function resolveRocketDuration(distance, duration, speed) {
58
+ if (speed > 0 && distance > ROCKET_FLIGHT_GUARDS.minDistanceEpsilon) {
59
+ return distance / speed;
60
+ }
61
+
62
+ return duration;
63
+ }
64
+
65
+ const ROCKET_CASCADE_LIMITS = {
66
+ burstCountMax: 50,
67
+ defaultDelayMs: 48,
68
+ defaultSpread: 18,
69
+ };
70
+
71
+ const ROCKET_CASCADE_MODES = {
72
+ burst: 'burst',
73
+ rocket: 'rocket',
74
+ };
75
+
76
+ const ROCKET_CASCADE_DIRECTION_TUNING = {
77
+ minConeDeg: 0,
78
+ maxConeDeg: 360,
79
+ defaultDirectionDeg: 0,
80
+ defaultConeDeg: 360,
81
+ };
82
+
83
+ const ROCKET_CASCADE_DIRECTION_CONSTANTS = {
84
+ fullCircleRad: Math.PI * 2,
85
+ degToRad: Math.PI / 180,
86
+ };
87
+
88
+ const ROCKET_TAIL_EMBER_TUNING = {
89
+ speedMin: 0.1,
90
+ fadeMin: 0.1,
91
+ countMin: 1,
92
+ spreadMin: 0,
93
+ sizeMin: 0.05,
94
+ lifeBaseDivisor: 0.09,
95
+ trailBlendDistance: 18,
96
+ trailBlendMin: 0.18,
97
+ requestedCountMin: 1,
98
+ requestedCountFactorBase: 0.12,
99
+ requestedCountFactorPerEmber: 0.05,
100
+ cloudLengthMin: 6,
101
+ cloudLengthSpreadYFactor: 1.15,
102
+ cloudLengthSpreadXFactor: 0.25,
103
+ cloudLengthSpeedFactor: 2.6,
104
+ cloudLengthSizeFactor: 3.4,
105
+ cloudWidthMin: 1.25,
106
+ cloudWidthSpreadXFactor: 0.78,
107
+ cloudWidthSizeFactor: 1.35,
108
+ progressMin: 0.08,
109
+ progressMax: 0.98,
110
+ depthPowMin: 0.05,
111
+ depthPowMax: 1,
112
+ depthPowExponent: 1.45,
113
+ cloudJitterFactor: 0.16,
114
+ cloudOffsetFactor: 0.18,
115
+ cloudOffsetBase: 0.55,
116
+ cloudOffsetTrailBlendFactor: 0.25,
117
+ trailPushMin: 0.5,
118
+ positionJitterFactor: 0.18,
119
+ lifeRandomMin: 0.72,
120
+ lifeRandomMax: 1.15,
121
+ lifeMin: 0.02,
122
+ lifeMax: 0.42,
123
+ speedRandomMin: 0.12,
124
+ speedRandomMax: 0.52,
125
+ distanceScaleBase: 0.38,
126
+ distanceScaleTrailBlendFactor: 0.52,
127
+ lateralVelocityFactor: 0.24,
128
+ gravityBase: 24,
129
+ gravitySpeedFactor: 3.1,
130
+ radiusRandomMin: 0.85,
131
+ radiusRandomMax: 1.2,
132
+ drag: 0.82,
133
+ };
134
+
135
+ function resolveRocketCascadeMode(value) {
136
+ return value === ROCKET_CASCADE_MODES.rocket
137
+ ? ROCKET_CASCADE_MODES.rocket
138
+ : ROCKET_CASCADE_MODES.burst;
139
+ }
140
+
141
+ function resolveOptionalPositiveRange(minValue, maxValue) {
142
+ const hasMin = Number.isFinite(minValue) && minValue > 0;
143
+ const hasMax = Number.isFinite(maxValue) && maxValue > 0;
144
+
145
+ if (!hasMin && !hasMax) {
146
+ return null;
147
+ }
148
+
149
+ const resolvedMin = hasMin ? minValue : maxValue;
150
+ const resolvedMax = hasMax ? maxValue : minValue;
151
+
152
+ if (resolvedMin <= resolvedMax) {
153
+ return {
154
+ min: resolvedMin,
155
+ max: resolvedMax,
156
+ };
157
+ }
158
+
159
+ return {
160
+ min: resolvedMax,
161
+ max: resolvedMin,
162
+ };
163
+ }
164
+
165
+ function resolveCascadeChildLaunchOptions(baseChildOptions, cascade, distance) {
166
+ const childRocketOptions = isPlainRecord(baseChildOptions?.rocket)
167
+ ? baseChildOptions.rocket
168
+ : {};
169
+ const speedRange = resolveOptionalPositiveRange(
170
+ cascade?.childSpeedMin,
171
+ cascade?.childSpeedMax,
172
+ );
173
+
174
+ let childDuration = null;
175
+ if (speedRange && distance > ROCKET_FLIGHT_GUARDS.minDistanceEpsilon) {
176
+ const speed = randomRange(speedRange.min, speedRange.max);
177
+ childDuration = speed > 0 ? distance / speed : null;
178
+ }
179
+
180
+ if (!(Number.isFinite(childDuration) && childDuration > 0)) {
181
+ if (Number.isFinite(childRocketOptions.speed) && childRocketOptions.speed > 0) {
182
+ return baseChildOptions;
183
+ }
184
+
185
+ const durationRange = resolveOptionalPositiveRange(
186
+ childRocketOptions.durationMin,
187
+ childRocketOptions.durationMax,
188
+ );
189
+ if (!durationRange) {
190
+ return baseChildOptions;
191
+ }
192
+
193
+ childDuration = randomRange(durationRange.min, durationRange.max);
194
+ }
195
+
196
+ return {
197
+ ...baseChildOptions,
198
+ rocket: {
199
+ ...childRocketOptions,
200
+ speed: 0,
201
+ durationMin: childDuration,
202
+ durationMax: childDuration,
203
+ },
204
+ };
205
+ }
206
+
207
+ function resolveCascadeDirectedAngle(directionDeg, coneDeg) {
208
+ const safeDirectionDeg = Number.isFinite(directionDeg)
209
+ ? directionDeg
210
+ : ROCKET_CASCADE_DIRECTION_TUNING.defaultDirectionDeg;
211
+ const safeConeDeg = Number.isFinite(coneDeg)
212
+ ? coneDeg
213
+ : ROCKET_CASCADE_DIRECTION_TUNING.defaultConeDeg;
214
+ const boundedConeDeg = clamp(
215
+ safeConeDeg,
216
+ ROCKET_CASCADE_DIRECTION_TUNING.minConeDeg,
217
+ ROCKET_CASCADE_DIRECTION_TUNING.maxConeDeg,
218
+ );
219
+ const coneRad = boundedConeDeg * ROCKET_CASCADE_DIRECTION_CONSTANTS.degToRad;
220
+
221
+ if (coneRad >= ROCKET_CASCADE_DIRECTION_CONSTANTS.fullCircleRad) {
222
+ return randomRange(0, ROCKET_CASCADE_DIRECTION_CONSTANTS.fullCircleRad);
223
+ }
224
+
225
+ const directionRad = -safeDirectionDeg * ROCKET_CASCADE_DIRECTION_CONSTANTS.degToRad;
226
+ const halfCone = coneRad / 2;
227
+ const startAngle = directionRad - halfCone;
228
+ return randomRange(startAngle, startAngle + coneRad);
229
+ }
230
+
231
+ function resolveCascadeTargetPoint(engine, originX, originY, spreadX, spreadY, cascade) {
232
+ const angle = resolveCascadeDirectedAngle(
233
+ cascade?.childDirectionDeg,
234
+ cascade?.childConeDeg,
235
+ );
236
+ const distanceRatio = Math.sqrt(randomRange(0, 1));
237
+
238
+ return {
239
+ x: clamp(originX + (Math.cos(angle) * spreadX * distanceRatio), 0, engine.width),
240
+ y: clamp(originY + (Math.sin(angle) * spreadY * distanceRatio), 0, engine.height),
241
+ };
242
+ }
243
+
244
+ function resolveCascadeRandomTargetPoint(engine, originX, originY, spreadX, spreadY) {
245
+ const angle = randomRange(0, ROCKET_CASCADE_DIRECTION_CONSTANTS.fullCircleRad);
246
+ const distanceRatio = Math.sqrt(randomRange(0, 1));
247
+
248
+ return {
249
+ x: clamp(originX + (Math.cos(angle) * spreadX * distanceRatio), 0, engine.width),
250
+ y: clamp(originY + (Math.sin(angle) * spreadY * distanceRatio), 0, engine.height),
251
+ };
252
+ }
253
+
254
+ function resolveDefaultCascadeChildLaunchOptions(rocket, cascade) {
255
+ const burstOptions = rocket.burst ?? {};
256
+ const tailEmbers = rocket.tailEmbers ?? {};
257
+ const cascadeBursts = clamp(
258
+ Math.floor(cascade?.childBursts ?? cascade?.bursts ?? 0),
259
+ 0,
260
+ ROCKET_CASCADE_LIMITS.burstCountMax,
261
+ );
262
+ const cascadeDelay = cascade?.delay ?? ROCKET_CASCADE_LIMITS.defaultDelayMs;
263
+ const cascadeSpreadX = cascade?.spreadX ?? ROCKET_CASCADE_LIMITS.defaultSpread;
264
+ const cascadeSpreadY = cascade?.spreadY ?? ROCKET_CASCADE_LIMITS.defaultSpread;
265
+ const childDuration = Math.max(
266
+ ROCKET_DURATION_LIMITS.defaultMin,
267
+ Number(rocket.maxLife) || ROCKET_DURATION_LIMITS.defaultMin,
268
+ );
269
+ const childDurationMin = cascade?.childDurationMin ?? childDuration;
270
+ const childDurationMax = cascade?.childDurationMax ?? childDurationMin;
271
+
272
+ return {
273
+ simple: !!rocket.simple,
274
+ rocket: {
275
+ enabled: true,
276
+ colorMode: 'single',
277
+ singleColor: rocket.color,
278
+ streakLength: rocket.sparkLength,
279
+ finalStreakLength: rocket.sparkFinalLength,
280
+ thickness: rocket.thickness,
281
+ speed: 0,
282
+ durationMin: childDurationMin,
283
+ durationMax: childDurationMax,
284
+ cascade: {
285
+ mode: ROCKET_CASCADE_MODES.burst,
286
+ bursts: cascadeBursts,
287
+ delay: cascadeDelay,
288
+ spreadX: cascadeSpreadX,
289
+ spreadY: cascadeSpreadY,
290
+ },
291
+ },
292
+ rocketTailEmbers: {
293
+ enabled: !!tailEmbers.enabled,
294
+ count: tailEmbers.count,
295
+ brightness: tailEmbers.brightness,
296
+ fade: tailEmbers.fade,
297
+ spreadX: tailEmbers.spreadX,
298
+ spreadY: tailEmbers.spreadY,
299
+ speed: tailEmbers.speed,
300
+ size: tailEmbers.size,
301
+ },
302
+ burstForms: { ...burstOptions.burstForms },
303
+ burstSparks: { ...burstOptions.burstSparks },
304
+ burstCenterFlash: { ...burstOptions.burstCenterFlash },
305
+ burstTwinkleCloud: { ...burstOptions.burstTwinkleCloud },
306
+ };
307
+ }
308
+
309
+
310
+ function launchRocketWithExplicitTarget(engine, options, explicitTarget) {
311
+ if (shouldReduceMotion(engine)) {
312
+ engine.stop();
313
+ return false;
314
+ }
315
+
316
+ if (!ensureCanvas(engine)) {
317
+ return false;
318
+ }
319
+
320
+ const wasRunning = engine.running;
321
+ if (!startLoop(engine)) {
322
+ return false;
323
+ }
324
+
325
+ createRocket(engine, options, explicitTarget);
326
+
327
+ if (!wasRunning) {
328
+ engine.finishWhenDone();
329
+ }
330
+
331
+ return true;
332
+ }
333
+
334
+
335
+ function createRocket(engine, options = {}, explicitTarget = null) {
336
+ const normalizedOptions = normalizeGroupedPublicOptions(options);
337
+ const {
338
+ rocket: rocketInput = {},
339
+ rocketTailEmbers: rocketTailEmbersInput = {},
340
+ } = normalizedOptions;
341
+ const rocketEnabled = resolveBurstEntityToggle(rocketInput.enabled) ?? engine.rocket.enabled;
342
+ const rocketCascadeInput = isPlainRecord(rocketInput.cascade) ? rocketInput.cascade : {};
343
+ const rocketCascadeMode = resolveRocketCascadeMode(rocketCascadeInput.mode);
344
+ const rocketCascadeChild = isPlainRecord(rocketCascadeInput.child)
345
+ ? rocketCascadeInput.child
346
+ : null;
347
+ const sizeScale = resolveSizeScale(normalizedOptions.sizeScale, engine.sizeScale);
348
+ const envelopeTimeScale = resolveEnvelopeTimeScale(sizeScale);
349
+ const rocketCascadeChildSpeedRange = resolveOptionalPositiveRange(
350
+ rocketCascadeInput.childSpeedMin,
351
+ rocketCascadeInput.childSpeedMax,
352
+ );
353
+ const rocketCascadeChildDirectionDeg = Number.isFinite(rocketCascadeInput.childDirectionDeg)
354
+ ? rocketCascadeInput.childDirectionDeg
355
+ : ROCKET_CASCADE_DIRECTION_TUNING.defaultDirectionDeg;
356
+ const rocketCascadeChildConeDeg = Number.isFinite(rocketCascadeInput.childConeDeg)
357
+ ? clamp(
358
+ rocketCascadeInput.childConeDeg,
359
+ ROCKET_CASCADE_DIRECTION_TUNING.minConeDeg,
360
+ ROCKET_CASCADE_DIRECTION_TUNING.maxConeDeg,
361
+ )
362
+ : ROCKET_CASCADE_DIRECTION_TUNING.defaultConeDeg;
363
+ const cascadeBurstOverrides = isPlainRecord(rocketCascadeInput.burst) ? rocketCascadeInput.burst : null;
364
+ const cascadeBurstFormsInput = cascadeBurstOverrides?.burstForms;
365
+ const burstFormsInput = isPlainRecord(cascadeBurstFormsInput) ? cascadeBurstFormsInput : {};
366
+ const hasRocketColorAlias = typeof rocketInput.color === 'string';
367
+ const rocketColorMode = hasRocketColorAlias
368
+ ? 'single'
369
+ : resolveBurstColorMode(rocketInput.colorMode ?? engine.rocket.colorMode);
370
+ const rocketSingleColor = normalizeHexColor(
371
+ hasRocketColorAlias ? rocketInput.color : rocketInput.singleColor,
372
+ engine.rocket.singleColor,
373
+ );
374
+ const rocketPalette = Array.isArray(rocketInput.palette) && rocketInput.palette.length > 0
375
+ ? rocketInput.palette
376
+ : engine.rocket.palette;
377
+ const tailEmbersEnabled = resolveBurstEntityToggle(rocketTailEmbersInput.enabled)
378
+ ?? engine.rocketTailEmbers.enabled;
379
+ const tailEmberCount = resolveScaledInteger(
380
+ rocketTailEmbersInput.count,
381
+ engine.rocketTailEmbers.count,
382
+ 1,
383
+ sizeScale,
384
+ );
385
+ const tailEmberBrightness = rocketTailEmbersInput.brightness ?? engine.rocketTailEmbers.brightness;
386
+ const tailEmberFade = resolveScaledInverseOverride(
387
+ rocketTailEmbersInput.fade,
388
+ engine.rocketTailEmbers.fade,
389
+ envelopeTimeScale,
390
+ );
391
+ const tailEmberSpreadX = resolveScaledOverride(
392
+ rocketTailEmbersInput.spreadX,
393
+ engine.rocketTailEmbers.spreadX,
394
+ sizeScale,
395
+ );
396
+ const tailEmberSpreadY = resolveScaledOverride(
397
+ rocketTailEmbersInput.spreadY,
398
+ engine.rocketTailEmbers.spreadY,
399
+ sizeScale,
400
+ );
401
+ const tailEmberSpeed = resolveScaledOverride(
402
+ rocketTailEmbersInput.speed,
403
+ engine.rocketTailEmbers.speed,
404
+ envelopeTimeScale,
405
+ );
406
+ const tailEmberSize = resolveScaledOverride(
407
+ rocketTailEmbersInput.size,
408
+ engine.rocketTailEmbers.size,
409
+ sizeScale,
410
+ );
411
+ const hasExplicitTarget = Number.isFinite(explicitTarget?.x) && Number.isFinite(explicitTarget?.y);
412
+ const hasExplicitStart = Number.isFinite(explicitTarget?.startX) && Number.isFinite(explicitTarget?.startY);
413
+ const targetX = hasExplicitTarget
414
+ ? (hasExplicitStart ? explicitTarget.x : clamp(explicitTarget.x, 0, engine.width))
415
+ : randomRange(engine.width * ROCKET_TARGET_TUNING.targetXMinRatio, engine.width * ROCKET_TARGET_TUNING.targetXMaxRatio) + randomRange(-ROCKET_TARGET_TUNING.targetXJitter, ROCKET_TARGET_TUNING.targetXJitter);
416
+ const targetY = hasExplicitTarget
417
+ ? (hasExplicitStart ? explicitTarget.y : clamp(explicitTarget.y, 0, engine.height))
418
+ : randomRange(engine.height * ROCKET_TARGET_TUNING.targetYMinRatio, engine.height * ROCKET_TARGET_TUNING.targetYMaxRatio);
419
+ const startX = hasExplicitStart
420
+ ? explicitTarget.startX
421
+ : hasExplicitTarget
422
+ ? clamp(targetX + randomRange(-ROCKET_TARGET_TUNING.explicitStartJitterX, ROCKET_TARGET_TUNING.explicitStartJitterX), 0, engine.width)
423
+ : randomRange(engine.width * 0.25, engine.width * 0.75);
424
+ const startY = hasExplicitStart
425
+ ? explicitTarget.startY
426
+ : hasExplicitTarget
427
+ ? engine.height + ROCKET_TARGET_TUNING.explicitStartOffsetY
428
+ : engine.height + randomRange(ROCKET_TARGET_TUNING.fallbackStartOffsetYMin, ROCKET_TARGET_TUNING.fallbackStartOffsetYMax);
429
+ const dx = targetX - startX;
430
+ const dy = targetY - startY;
431
+ const durationMin = resolveScaledOverride(
432
+ rocketInput.durationMin,
433
+ ROCKET_DURATION_LIMITS.defaultMin,
434
+ envelopeTimeScale,
435
+ );
436
+ const durationMax = resolveScaledOverride(
437
+ rocketInput.durationMax,
438
+ ROCKET_DURATION_LIMITS.defaultMax,
439
+ envelopeTimeScale,
440
+ );
441
+ const fallbackDuration = hasExplicitTarget
442
+ ? ((durationMin + durationMax) / 2)
443
+ : randomRange(durationMin, durationMax);
444
+ const distance = Math.hypot(dx, dy);
445
+ const rocketSpeed = rocketInput.speed ?? engine.rocket.speed;
446
+ const duration = resolveRocketDuration(distance, fallbackDuration, rocketSpeed);
447
+ const totalDx = dx;
448
+ const totalDy = dy;
449
+ const totalDistanceSq = (totalDx * totalDx) + (totalDy * totalDy);
450
+ const normalizedDistance = distance > ROCKET_FLIGHT_GUARDS.minDistanceEpsilon ? distance : 1;
451
+ const directionX = distance > ROCKET_FLIGHT_GUARDS.minDistanceEpsilon ? dx / normalizedDistance : 0;
452
+ const directionY = distance > ROCKET_FLIGHT_GUARDS.minDistanceEpsilon ? dy / normalizedDistance : -1;
453
+ const launchColor = resolveBurstBaseColor(
454
+ engine,
455
+ rocketPalette ?? burstFormsInput.palette ?? engine.burstForms.palette,
456
+ rocketColorMode,
457
+ rocketSingleColor,
458
+ );
459
+ const travelSpeed = distance > ROCKET_FLIGHT_GUARDS.minDistanceEpsilon
460
+ ? distance / duration
461
+ : 0;
462
+ const rotationAngle = Math.atan2(directionY, directionX);
463
+ const sparkLength = resolveScaledOverride(
464
+ rocketInput.streakLength,
465
+ engine.rocket.streakLength,
466
+ sizeScale,
467
+ );
468
+ const sparkFinalLength = resolveScaledOverride(
469
+ rocketInput.finalStreakLength,
470
+ engine.rocket.finalStreakLength,
471
+ sizeScale,
472
+ );
473
+ const rocketThickness = resolveScaledOverride(
474
+ rocketInput.thickness,
475
+ engine.rocket.thickness,
476
+ sizeScale,
477
+ );
478
+ const burstCurrentConfig = {
479
+ sizeScale: engine.sizeScale,
480
+ burstForms: engine.burstForms,
481
+ burstSparks: engine.burstSparks,
482
+ burstCenterFlash: engine.burstCenterFlash,
483
+ burstTwinkleCloud: engine.burstTwinkleCloud,
484
+ };
485
+ const baseBurstBuildInput = {
486
+ burstForms: normalizedOptions.burstForms,
487
+ burstSparks: normalizedOptions.burstSparks,
488
+ burstCenterFlash: normalizedOptions.burstCenterFlash,
489
+ burstTwinkleCloud: normalizedOptions.burstTwinkleCloud,
490
+ sizeScale,
491
+ };
492
+ const burstOptions = buildBurstOptions(baseBurstBuildInput, burstCurrentConfig);
493
+ if (!rocketEnabled) {
494
+ launchBurst(engine, targetX, targetY, burstOptions);
495
+ return null;
496
+ }
497
+
498
+ const cascadeBurst = cascadeBurstOverrides
499
+ ? buildBurstOptions(
500
+ {
501
+ ...baseBurstBuildInput,
502
+ burstForms: {
503
+ ...baseBurstBuildInput.burstForms,
504
+ ...cascadeBurstOverrides.burstForms,
505
+ },
506
+ burstSparks: {
507
+ ...baseBurstBuildInput.burstSparks,
508
+ ...cascadeBurstOverrides.burstSparks,
509
+ },
510
+ burstCenterFlash: {
511
+ ...baseBurstBuildInput.burstCenterFlash,
512
+ ...cascadeBurstOverrides.burstCenterFlash,
513
+ },
514
+ burstTwinkleCloud: {
515
+ ...baseBurstBuildInput.burstTwinkleCloud,
516
+ ...cascadeBurstOverrides.burstTwinkleCloud,
517
+ },
518
+ },
519
+ burstCurrentConfig,
520
+ )
521
+ : null;
522
+ const cascadeBurstCount = clamp(
523
+ Math.floor((rocketCascadeInput.bursts ?? 0) * sizeScale),
524
+ 0,
525
+ ROCKET_CASCADE_LIMITS.burstCountMax,
526
+ );
527
+ const cascadeChildBurstCount = clamp(
528
+ Math.floor((rocketCascadeInput.childBursts ?? rocketCascadeInput.bursts ?? 0) * sizeScale),
529
+ 0,
530
+ ROCKET_CASCADE_LIMITS.burstCountMax,
531
+ );
532
+ const burstCascadeDelayMs = resolveScaledOverride(
533
+ rocketCascadeInput.delay,
534
+ ROCKET_CASCADE_LIMITS.defaultDelayMs,
535
+ envelopeTimeScale,
536
+ );
537
+ const cascadeChildDurationMin = resolveScaledOverride(
538
+ rocketCascadeInput.childDurationMin,
539
+ 0,
540
+ envelopeTimeScale,
541
+ );
542
+ const cascadeChildDurationMax = resolveScaledOverride(
543
+ rocketCascadeInput.childDurationMax,
544
+ cascadeChildDurationMin,
545
+ envelopeTimeScale,
546
+ );
547
+ const boundedCascadeChildDurationMax = Math.max(
548
+ cascadeChildDurationMin,
549
+ cascadeChildDurationMax,
550
+ );
551
+ const burstCascadeSpreadX = resolveScaledOverride(
552
+ rocketCascadeInput.spreadX,
553
+ ROCKET_CASCADE_LIMITS.defaultSpread,
554
+ sizeScale,
555
+ );
556
+ const burstCascadeSpreadY = resolveScaledOverride(
557
+ rocketCascadeInput.spreadY,
558
+ ROCKET_CASCADE_LIMITS.defaultSpread,
559
+ sizeScale,
560
+ );
561
+ const cascade = {
562
+ mode: rocketCascadeMode,
563
+ bursts: cascadeBurstCount,
564
+ childBursts: cascadeChildBurstCount,
565
+ childSpeedMin: rocketCascadeChildSpeedRange?.min ?? null,
566
+ childSpeedMax: rocketCascadeChildSpeedRange?.max ?? null,
567
+ childDurationMin: cascadeChildDurationMin > 0 ? cascadeChildDurationMin : null,
568
+ childDurationMax: boundedCascadeChildDurationMax > 0 ? boundedCascadeChildDurationMax : null,
569
+ childDirectionDeg: rocketCascadeChildDirectionDeg,
570
+ childConeDeg: rocketCascadeChildConeDeg,
571
+ delay: burstCascadeDelayMs,
572
+ spreadX: burstCascadeSpreadX,
573
+ spreadY: burstCascadeSpreadY,
574
+ burst: cascadeBurst ?? burstOptions,
575
+ child: rocketCascadeChild,
576
+ };
577
+ const rocket = {
578
+ x: startX,
579
+ y: startY,
580
+ prevX: startX,
581
+ prevY: startY,
582
+ startX,
583
+ startY,
584
+ targetX,
585
+ targetY,
586
+ totalDx,
587
+ totalDy,
588
+ totalDistanceSq,
589
+ hasExplicitTarget,
590
+ hasExplicitStart,
591
+ vx: dx / duration,
592
+ vy: dy / duration,
593
+ travelSpeed,
594
+ life: duration,
595
+ maxLife: duration,
596
+ color: launchColor,
597
+ directionX,
598
+ directionY,
599
+ rotationAngle,
600
+ sparkLength,
601
+ sparkFinalLength,
602
+ thickness: rocketThickness,
603
+ trailTimer: 0,
604
+ tailEmberAccumulator: 0,
605
+ tailEmbers: {
606
+ enabled: !!tailEmbersEnabled,
607
+ count: tailEmberCount,
608
+ brightness: tailEmberBrightness,
609
+ fade: tailEmberFade,
610
+ spreadX: tailEmberSpreadX,
611
+ spreadY: tailEmberSpreadY,
612
+ speed: tailEmberSpeed,
613
+ size: tailEmberSize,
614
+ },
615
+ burst: burstOptions,
616
+ cascade,
617
+ simple: !!normalizedOptions.simple,
618
+ };
619
+
620
+ engine.rockets.push(rocket);
621
+ return rocket;
622
+ }
623
+
624
+ export function launchRocket(engine, options = {}) {
625
+ const count = options.count ?? 1;
626
+
627
+ for (let index = 0; index < count; index += 1) {
628
+ createRocket(engine, options);
629
+ }
630
+ }
631
+
632
+ export function launchRocketAt(engine, targetX, targetY, options = {}) {
633
+ if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) {
634
+ return false;
635
+ }
636
+
637
+ return launchRocketWithExplicitTarget(engine, options, {
638
+ x: targetX,
639
+ y: targetY,
640
+ });
641
+ }
642
+
643
+ export function launchRocketFromTo(engine, startX, startY, targetX, targetY, options = {}) {
644
+ if (
645
+ !Number.isFinite(startX)
646
+ || !Number.isFinite(startY)
647
+ || !Number.isFinite(targetX)
648
+ || !Number.isFinite(targetY)
649
+ ) {
650
+ return false;
651
+ }
652
+
653
+ return launchRocketWithExplicitTarget(engine, options, {
654
+ startX,
655
+ startY,
656
+ x: targetX,
657
+ y: targetY,
658
+ });
659
+ }
660
+
661
+ export function scheduleCascadeBursts(engine, rocket) {
662
+ const cascade = rocket.cascade ?? {};
663
+ const cascadeMode = resolveRocketCascadeMode(cascade.mode);
664
+ const cascadeBurstCount = cascade.bursts ?? 0;
665
+ if (cascadeBurstCount <= 0) {
666
+ return;
667
+ }
668
+
669
+ const burstOptions = cascade.burst ?? rocket.burst;
670
+ const spreadX = cascade.spreadX ?? ROCKET_CASCADE_LIMITS.defaultSpread;
671
+ const spreadY = cascade.spreadY ?? ROCKET_CASCADE_LIMITS.defaultSpread;
672
+ const delay = cascade.delay ?? ROCKET_CASCADE_LIMITS.defaultDelayMs;
673
+ const originX = rocket.x;
674
+ const originY = rocket.y;
675
+ const cascadeChildOptions = isPlainRecord(cascade.child)
676
+ ? cascade.child
677
+ : resolveDefaultCascadeChildLaunchOptions(rocket, cascade);
678
+
679
+ if (cascadeMode === ROCKET_CASCADE_MODES.rocket) {
680
+ for (let index = 0; index < cascadeBurstCount; index += 1) {
681
+ const { x, y } = resolveCascadeTargetPoint(
682
+ engine,
683
+ originX,
684
+ originY,
685
+ spreadX,
686
+ spreadY,
687
+ cascade,
688
+ );
689
+ const distance = Math.hypot(x - originX, y - originY);
690
+ const childLaunchOptions = resolveCascadeChildLaunchOptions(
691
+ cascadeChildOptions,
692
+ cascade,
693
+ distance,
694
+ );
695
+ launchRocketFromTo(engine, originX, originY, x, y, childLaunchOptions);
696
+ }
697
+ return;
698
+ }
699
+
700
+ for (let index = 0; index < cascadeBurstCount; index += 1) {
701
+ scheduleTimeout(engine, () => {
702
+ if (!engine.running) {
703
+ return;
704
+ }
705
+
706
+ const { x, y } = resolveCascadeRandomTargetPoint(
707
+ engine,
708
+ originX,
709
+ originY,
710
+ spreadX,
711
+ spreadY,
712
+ );
713
+
714
+ launchBurst(engine, x, y, burstOptions);
715
+ }, resolveScaledDelay(engine, delay * (index + 1)));
716
+ }
717
+ }
718
+
719
+ export function spawnRocketTailEmbers(engine, rocket, spawnCount = null) {
720
+ const tailEmbers = rocket.tailEmbers;
721
+ if (!tailEmbers?.enabled) {
722
+ return;
723
+ }
724
+
725
+ const directionX = rocket.directionX;
726
+ const directionY = rocket.directionY;
727
+ const lateralX = -directionY;
728
+ const lateralY = directionX;
729
+ const baseSpeed = Math.max(ROCKET_TAIL_EMBER_TUNING.speedMin, tailEmbers.speed ?? engine.rocketTailEmbers.speed);
730
+ const tailEmberBrightness = tailEmbers.brightness ?? engine.rocketTailEmbers.brightness;
731
+ const tailEmberBrightnessScale = clamp(tailEmberBrightness / WHITE_BRIGHTNESS_REFERENCE, 0, 1);
732
+ const emberFade = Math.max(ROCKET_TAIL_EMBER_TUNING.fadeMin, tailEmbers.fade ?? engine.rocketTailEmbers.fade);
733
+ const emberCount = Math.max(ROCKET_TAIL_EMBER_TUNING.countMin, Math.floor(tailEmbers.count ?? engine.rocketTailEmbers.count));
734
+ const emberSpreadX = Math.max(ROCKET_TAIL_EMBER_TUNING.spreadMin, tailEmbers.spreadX ?? engine.rocketTailEmbers.spreadX);
735
+ const emberSpreadY = Math.max(ROCKET_TAIL_EMBER_TUNING.spreadMin, tailEmbers.spreadY ?? engine.rocketTailEmbers.spreadY);
736
+ const emberSize = Math.max(ROCKET_TAIL_EMBER_TUNING.sizeMin, tailEmbers.size ?? engine.rocketTailEmbers.size);
737
+ const emberLifeBase = ROCKET_TAIL_EMBER_TUNING.lifeBaseDivisor / emberFade;
738
+ const previousX = rocket.prevX;
739
+ const previousY = rocket.prevY;
740
+ const travelX = rocket.x - previousX;
741
+ const travelY = rocket.y - previousY;
742
+ const travelDistance = Math.hypot(travelX, travelY);
743
+ const trailBlend = clamp(travelDistance / ROCKET_TAIL_EMBER_TUNING.trailBlendDistance, ROCKET_TAIL_EMBER_TUNING.trailBlendMin, 1);
744
+ const requestedCount = spawnCount == null
745
+ ? Math.max(ROCKET_TAIL_EMBER_TUNING.requestedCountMin, Math.min(emberCount, Math.ceil(travelDistance * (ROCKET_TAIL_EMBER_TUNING.requestedCountFactorBase + (emberCount * ROCKET_TAIL_EMBER_TUNING.requestedCountFactorPerEmber)))))
746
+ : Math.floor(spawnCount);
747
+ const emberSpawnCount = Math.max(ROCKET_TAIL_EMBER_TUNING.requestedCountMin, requestedCount);
748
+ const emberCloudLength = Math.max(
749
+ ROCKET_TAIL_EMBER_TUNING.cloudLengthMin,
750
+ (emberSpreadY * ROCKET_TAIL_EMBER_TUNING.cloudLengthSpreadYFactor) + (emberSpreadX * ROCKET_TAIL_EMBER_TUNING.cloudLengthSpreadXFactor) + (baseSpeed * ROCKET_TAIL_EMBER_TUNING.cloudLengthSpeedFactor) + (emberSize * ROCKET_TAIL_EMBER_TUNING.cloudLengthSizeFactor),
751
+ );
752
+ const emberCloudWidth = Math.max(
753
+ ROCKET_TAIL_EMBER_TUNING.cloudWidthMin,
754
+ (emberSpreadX * ROCKET_TAIL_EMBER_TUNING.cloudWidthSpreadXFactor) + (emberSize * ROCKET_TAIL_EMBER_TUNING.cloudWidthSizeFactor),
755
+ );
756
+
757
+ for (let index = 0; index < emberSpawnCount; index += 1) {
758
+ const progress = randomRange(ROCKET_TAIL_EMBER_TUNING.progressMin, ROCKET_TAIL_EMBER_TUNING.progressMax);
759
+ const pathX = previousX + (travelX * progress);
760
+ const pathY = previousY + (travelY * progress);
761
+ const cloudDepth = Math.pow(randomRange(ROCKET_TAIL_EMBER_TUNING.depthPowMin, ROCKET_TAIL_EMBER_TUNING.depthPowMax), ROCKET_TAIL_EMBER_TUNING.depthPowExponent) * emberCloudLength;
762
+ const cloudLateral = randomRange(-emberCloudWidth, emberCloudWidth);
763
+ const cloudJitter = randomRange(-emberCloudWidth * ROCKET_TAIL_EMBER_TUNING.cloudJitterFactor, emberCloudWidth * ROCKET_TAIL_EMBER_TUNING.cloudJitterFactor);
764
+ const cloudOffset = randomRange(-emberCloudWidth * ROCKET_TAIL_EMBER_TUNING.cloudOffsetFactor, emberCloudWidth * ROCKET_TAIL_EMBER_TUNING.cloudOffsetFactor) * (ROCKET_TAIL_EMBER_TUNING.cloudOffsetBase + (ROCKET_TAIL_EMBER_TUNING.cloudOffsetTrailBlendFactor * trailBlend));
765
+ const trailPush = Math.max(ROCKET_TAIL_EMBER_TUNING.trailPushMin, cloudDepth + cloudJitter);
766
+ const lateralOffset = cloudLateral + cloudOffset;
767
+ const jitterX = randomRange(-ROCKET_TAIL_EMBER_TUNING.positionJitterFactor, ROCKET_TAIL_EMBER_TUNING.positionJitterFactor) * emberSize;
768
+ const jitterY = randomRange(-ROCKET_TAIL_EMBER_TUNING.positionJitterFactor, ROCKET_TAIL_EMBER_TUNING.positionJitterFactor) * emberSize;
769
+ const life = clamp(emberLifeBase * randomRange(ROCKET_TAIL_EMBER_TUNING.lifeRandomMin, ROCKET_TAIL_EMBER_TUNING.lifeRandomMax), ROCKET_TAIL_EMBER_TUNING.lifeMin, ROCKET_TAIL_EMBER_TUNING.lifeMax);
770
+ const spawnSpeed = randomRange(ROCKET_TAIL_EMBER_TUNING.speedRandomMin, ROCKET_TAIL_EMBER_TUNING.speedRandomMax) * baseSpeed;
771
+ const distanceScale = ROCKET_TAIL_EMBER_TUNING.distanceScaleBase + (trailBlend * ROCKET_TAIL_EMBER_TUNING.distanceScaleTrailBlendFactor);
772
+
773
+ const particle = engine.emberPool.length > 0 ? engine.emberPool.pop() : {};
774
+
775
+ particle.type = 'rocketEmber';
776
+ particle.x = pathX - (directionX * trailPush) + (lateralX * lateralOffset) + jitterX;
777
+ particle.y = pathY - (directionY * trailPush) + (lateralY * lateralOffset) + jitterY;
778
+ particle.vx = (-directionX * spawnSpeed * distanceScale) + (lateralX * lateralOffset * ROCKET_TAIL_EMBER_TUNING.lateralVelocityFactor);
779
+ particle.vy = (-directionY * spawnSpeed * distanceScale) + (lateralY * lateralOffset * ROCKET_TAIL_EMBER_TUNING.lateralVelocityFactor);
780
+ particle.gravity = ROCKET_TAIL_EMBER_TUNING.gravityBase + (baseSpeed * ROCKET_TAIL_EMBER_TUNING.gravitySpeedFactor);
781
+ particle.life = life;
782
+ particle.maxLife = life;
783
+ particle.radius = emberSize * randomRange(ROCKET_TAIL_EMBER_TUNING.radiusRandomMin, ROCKET_TAIL_EMBER_TUNING.radiusRandomMax);
784
+ particle.brightness = tailEmberBrightness;
785
+ particle.brightnessScale = tailEmberBrightnessScale;
786
+ particle.drag = ROCKET_TAIL_EMBER_TUNING.drag;
787
+ particle.simple = rocket.simple;
788
+
789
+ engine.particles.push(particle);
790
+ }
791
+ }