fireworks-engine 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -52,8 +52,86 @@ Always call `destroy()` when the owning UI unmounts.
52
52
  fireworks.destroy();
53
53
  ```
54
54
 
55
+ ## Burst notifications
56
+
57
+ Pass `onBurst` when creating the engine to synchronize another effect with each
58
+ logical explosion:
59
+
60
+ ```js
61
+ const fireworks = createFireworksEngine({
62
+ onBurst: ({ x, y }) => {
63
+ // Trigger an already prepared effect at these canvas coordinates.
64
+ },
65
+ });
66
+ ```
67
+
68
+ The callback runs synchronously after the first visual component is created,
69
+ once per logical burst, including rocket detonations and cascade children.
70
+ Queued bursts notify on materialization, not enqueue, and particle batches,
71
+ trails, and later twinkles do not produce additional notifications. Flash-only
72
+ and twinkle-only bursts also notify; a twinkle cloud notifies when created even
73
+ if its twinkles appear later. Completely empty bursts do not notify. This is a
74
+ component-creation signal, not a pixel visibility or audio hardware timestamp.
75
+
76
+ `onBurst` is constructor-only: `start`, launch methods, and `updateOptions` neither
77
+ replace nor reset it. Exceptions in the callback are isolated from rendering.
78
+ Calling `stop()` or `destroy()` from the callback cancels the rest of that run;
79
+ starting a replacement effect leaves the new run intact. Ordinary additive
80
+ launches remain part of the current run. Call `destroy()` when the owner unmounts.
81
+
82
+ TypeScript exports `FireworksBurstEvent`, `FireworksBurstCallback`, and
83
+ `FireworksEngineCreationOptions`; existing `FireworksEngineOptions` still work
84
+ for both construction and start/update configuration.
85
+
55
86
  ## Public API
56
87
 
88
+ ### Tracked victory boundaries
89
+
90
+ For the supported game victory configuration, opt in per launch:
91
+
92
+ ```js
93
+ const fireworks = createFireworksEngine({
94
+ onBurst: ({ x, y, sequence }) => {
95
+ // sequence is available on tracked victory bursts only.
96
+ // It describes this explosion now, never a delayed completion signal.
97
+ if (sequence?.isFirst || sequence?.isLast) {
98
+ // Trigger a prepared sound here.
99
+ }
100
+ },
101
+ });
102
+ fireworks.launchPresetAt('victory-finale', 210, 90, {
103
+ trackBurstSequence: true,
104
+ sizeScale: 1.05,
105
+ timeScale: 0.95,
106
+ });
107
+ ```
108
+
109
+ `sequence` contains an opaque symbol `id`, zero-based materialization `index`,
110
+ and boolean `isFirst`/`isLast`. Each additive launch has its own identity and
111
+ boundaries. Future rocket and delayed cascade bursts are reserved before the
112
+ first explosion; particles fading after their first appearance do not count
113
+ as future bursts. A final burst is flagged during its own normal `onBurst`
114
+ callback, not when the animation later becomes idle. A one-burst sequence has
115
+ both flags; no extra callbacks are generated for cancellation or empty work.
116
+
117
+ This deliberately narrow opt-in accepts only `victory-finale`, `count` omitted
118
+ or exactly `1`, `sizeScale: 1.05`, `timeScale: 0.95`, and no other launch keys.
119
+ Effective engine visual and performance settings must match defaults (including
120
+ `sizeScale: 1`, burst components, rocket/tail settings and performance budgets;
121
+ default queue throttling is **off**). Constructor canvas/runtime references and
122
+ callbacks may be customized. Engine timeScale may differ because the launch
123
+ explicitly sets it. Other tracking configurations, including `count: 0/2` and
124
+ queued engines, return `false` before creating a canvas, scheduling work,
125
+ changing timeScale, drawing random numbers or notifying callbacks. Calls without
126
+ `trackBurstSequence: true` keep their existing behavior and `{ x, y }` payload.
127
+
128
+ Changing effective visual/performance configuration via `updateOptions` during
129
+ a sequence interrupts its tracking permanently, even if defaults are restored.
130
+ The visual effect continues normally, with subsequent callbacks lacking
131
+ `sequence`. `stop`, `destroy` and replacement starts invalidate all old tracking;
132
+ no synthetic final sound should be added. Do not mutate engine internals directly.
133
+ This is a component-creation synchronization signal, not an audio-output timestamp.
134
+
57
135
  Primary exports:
58
136
 
59
137
  - `FireworksEngine`
package/index.d.ts CHANGED
@@ -189,6 +189,35 @@ export interface FireworksRocketLaunchOptions extends FireworksBurstOverrideOpti
189
189
  rocketTailEmbers?: FireworksRocketTailEmbersOptions;
190
190
  }
191
191
 
192
+ export interface FireworksBurstEvent {
193
+ x: number;
194
+ y: number;
195
+ /** Present only for an opted-in, supported victory-finale launch. */
196
+ sequence?: {
197
+ /** Opaque identity, unique per launch within this engine. */
198
+ id: symbol;
199
+ index: number;
200
+ isFirst: boolean;
201
+ isLast: boolean;
202
+ };
203
+ }
204
+
205
+ export interface FireworksPresetLaunchOptions extends FireworksRocketLaunchOptions {
206
+ /** Only victory-finale with count=1, sizeScale=1.05, timeScale=0.95 and
207
+ * default effective visual/performance engine configuration is supported.
208
+ * Unsupported tracking requests return false before launch side effects.
209
+ * Changing that configuration during a launch interrupts its tracking.
210
+ */
211
+ trackBurstSequence?: boolean;
212
+ }
213
+
214
+ export type FireworksBurstCallback = (event: FireworksBurstEvent) => void;
215
+
216
+ /** Options accepted only when creating an engine. */
217
+ export interface FireworksEngineCreationOptions extends FireworksEngineOptions {
218
+ onBurst?: FireworksBurstCallback | null;
219
+ }
220
+
192
221
  export interface FireworksEngineOptions extends FireworksRocketLaunchOptions {
193
222
  canvas?: FireworksCanvasOptions;
194
223
  runtime?: FireworksRuntimeOptions;
@@ -506,7 +535,7 @@ export interface FireworksBurstRequest {
506
535
  export interface FireworksPresetConfig extends FireworksEngineOptions { }
507
536
 
508
537
  export declare class FireworksEngine {
509
- constructor(options?: FireworksEngineOptions);
538
+ constructor(options?: FireworksEngineCreationOptions);
510
539
 
511
540
  documentRef: Document | null | undefined;
512
541
  windowRef: Window | null | undefined;
@@ -624,7 +653,7 @@ export declare class FireworksEngine {
624
653
  name: FireworksEnginePresetName | FireworksLaunchPresetName | string,
625
654
  targetX: number,
626
655
  targetY: number,
627
- options?: FireworksRocketLaunchOptions,
656
+ options?: FireworksPresetLaunchOptions,
628
657
  ): boolean;
629
658
  /**
630
659
  * Launches a rocket from a specific start point toward a target point.
@@ -677,7 +706,7 @@ export interface FireworksRocketExplicitTarget {
677
706
  * @param options Initial engine configuration.
678
707
  * @returns A new fireworks engine instance.
679
708
  */
680
- export declare function createFireworksEngine(options?: FireworksEngineOptions): FireworksEngine;
709
+ export declare function createFireworksEngine(options?: FireworksEngineCreationOptions): FireworksEngine;
681
710
 
682
711
  /**
683
712
  * Resolves a preset name to a fully expanded engine configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fireworks-engine",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "license": "BSD-2-Clause",
5
5
  "description": "Reusable canvas fireworks runtime package for standalone integrations and demos.",
6
6
  "author": "coinexplain",
@@ -41,7 +41,7 @@
41
41
  "scripts": {
42
42
  "smoke": "node --check src/index.js && node --input-type=module -e \"const mod = await import('./src/index.js'); if (typeof mod.createFireworksEngine !== 'function' || typeof mod.resolveFireworksEnginePreset !== 'function' || typeof mod.default !== 'function') throw new Error('package smoke failed'); console.log('package smoke ok');\"",
43
43
  "typecheck": "tsc --noEmit --project tsconfig.types.json",
44
- "test": "npm run smoke && npm run typecheck"
44
+ "test": "npm run smoke && npm run typecheck && node --test test/burst-events.test.js test/burst-sequence.test.js"
45
45
  },
46
46
  "devDependencies": {
47
47
  "typescript": "^5.9.3"
@@ -0,0 +1,22 @@
1
+ // A stop invalidates every stack/timer belonging to the previous run. Additive
2
+ // launches deliberately keep the generation, including cascade children.
3
+ export function isCurrentRun(engine, generation) {
4
+ return engine.runGeneration === generation;
5
+ }
6
+
7
+ import { materializeBurstWork } from './burstSequence.js';
8
+
9
+ export function createBurstEvent(engine, x, y, work) {
10
+ return { generation: engine.runGeneration, x, y, notified: false, work };
11
+ }
12
+
13
+ export function notifyBurst(engine, burst) {
14
+ if (burst.notified || !isCurrentRun(engine, burst.generation)) return;
15
+ burst.notified = true;
16
+ const sequence = materializeBurstWork(engine, burst.work);
17
+ try {
18
+ engine.onBurstCallback?.({ x: burst.x, y: burst.y, ...(sequence ? { sequence } : {}) });
19
+ } catch {
20
+ // Consumer code must not interrupt rendering or lifecycle cleanup.
21
+ }
22
+ }
@@ -0,0 +1,67 @@
1
+ import { buildEngineConfig } from './configBuilder.js';
2
+
3
+ // Internal launch context; never normalized into public visual options.
4
+ export const BURST_SEQUENCE = Symbol('burstSequence');
5
+ export const BURST_WORK = Symbol('burstWork');
6
+ const defaults = buildEngineConfig();
7
+ const groups = ['rocket', 'rocketTailEmbers', 'burstForms', 'burstSparks',
8
+ 'burstCenterFlash', 'burstTwinkleCloud', 'performance'];
9
+ const allowed = new Set(['trackBurstSequence', 'sizeScale', 'timeScale', 'count']);
10
+ const sequencesByEngine = new WeakMap();
11
+
12
+ export function invalidateBurstSequences(engine) {
13
+ const sequences = sequencesByEngine.get(engine);
14
+ if (!sequences) return;
15
+ for (const sequence of sequences) sequence.active = false;
16
+ sequences.clear();
17
+ }
18
+
19
+ export function supportsSequenceConfig(engine) {
20
+ return engine.sizeScale === defaults.sizeScale
21
+ && engine.enableBurstQueueThrottling === defaults.toggles.enableBurstQueueThrottling
22
+ && engine.maxBurstParticlesPerFrame === defaults.performance.maxBurstParticlesPerFrame
23
+ && groups.every((key) => JSON.stringify(engine[key]) === JSON.stringify(defaults[key]));
24
+ }
25
+
26
+ export function supportsSequenceLaunch(engine, name, options) {
27
+ return name === 'victory-finale' && options.sizeScale === 1.05 && options.timeScale === 0.95
28
+ && (options.count === undefined || options.count === 1)
29
+ && Object.keys(options).every((key) => allowed.has(key))
30
+ && supportsSequenceConfig(engine);
31
+ }
32
+
33
+ export function createBurstSequence(engine) {
34
+ const sequence = { id: Symbol('victory-finale'), generation: engine.runGeneration,
35
+ pending: 0, index: 0, active: true };
36
+ if (!sequencesByEngine.has(engine)) sequencesByEngine.set(engine, new Set());
37
+ sequencesByEngine.get(engine).add(sequence);
38
+ return sequence;
39
+ }
40
+
41
+ export function discardBurstSequence(engine, sequence) {
42
+ if (!sequence) return;
43
+ sequence.active = false;
44
+ sequencesByEngine.get(engine)?.delete(sequence);
45
+ }
46
+
47
+ export function reserveBurstWork(sequence, children = []) {
48
+ sequence.pending += 1;
49
+ return { sequence, children, materialized: false };
50
+ }
51
+
52
+ export function materializeBurstWork(engine, work) {
53
+ if (!work || work.materialized) return null;
54
+ const sequence = work.sequence;
55
+ if (!sequence.active || engine.runGeneration !== sequence.generation) return null;
56
+ // Configuration changes during a launch interrupt its tracking, not its visuals.
57
+ if (!supportsSequenceConfig(engine)) {
58
+ sequence.active = false;
59
+ sequencesByEngine.get(engine)?.delete(sequence);
60
+ return null;
61
+ }
62
+ work.materialized = true;
63
+ sequence.pending -= 1;
64
+ const index = sequence.index++;
65
+ if (sequence.pending === 0) sequencesByEngine.get(engine)?.delete(sequence);
66
+ return { id: sequence.id, index, isFirst: index === 0, isLast: sequence.pending === 0 };
67
+ }
package/src/burstSpawn.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createBurstEvent, isCurrentRun, notifyBurst } from './burstEvents.js';
2
+ import { BURST_WORK } from './burstSequence.js';
1
3
  import {
2
4
  BURST_FLASH_ATTACK_UNIT,
3
5
  DEFAULT_BURST_SINGLE_COLOR,
@@ -252,7 +254,18 @@ function createBurstRequests(x, y, options, flashOptions = options) {
252
254
  return requests;
253
255
  }
254
256
 
255
- function enqueueBurst(engine, x, y, options = {}) {
257
+ function spawnBurstExtras(engine, burst, flashOptions, cloudOptions) {
258
+ if (spawnBurstCenterFlash(engine, burst.x, burst.y, flashOptions)) {
259
+ notifyBurst(engine, burst);
260
+ }
261
+ if (!isCurrentRun(engine, burst.generation)) return false;
262
+ if (spawnBurstTwinkleCloud(engine, burst.x, burst.y, cloudOptions)) {
263
+ notifyBurst(engine, burst);
264
+ }
265
+ return isCurrentRun(engine, burst.generation);
266
+ }
267
+
268
+ function enqueueBurst(engine, x, y, options = {}, work) {
256
269
  if (!engine.running) {
257
270
  return false;
258
271
  }
@@ -262,10 +275,11 @@ function enqueueBurst(engine, x, y, options = {}) {
262
275
  resolveBurstCurrentConfig(engine),
263
276
  );
264
277
  const requests = createBurstRequests(x, y, burstOptions, options);
278
+ const burst = createBurstEvent(engine, x, y, work);
279
+ requests.forEach((request) => { request.burstEvent = burst; });
265
280
 
266
281
  if (requests.length === 0) {
267
- spawnBurstCenterFlash(engine, x, y, options);
268
- spawnBurstTwinkleCloud(engine, x, y, burstOptions);
282
+ spawnBurstExtras(engine, burst, options, burstOptions);
269
283
  return true;
270
284
  }
271
285
 
@@ -554,6 +568,7 @@ function spawnBurstParticles(engine, request, startIndex, count) {
554
568
  }
555
569
 
556
570
  export function drainBurstQueue(engine) {
571
+ const generation = engine.runGeneration;
557
572
  let remainingBudget = engine.maxBurstParticlesPerFrame;
558
573
 
559
574
  while (remainingBudget > 0 && engine.burstQueue.length > 0) {
@@ -567,14 +582,22 @@ export function drainBurstQueue(engine) {
567
582
 
568
583
  const spawnCount = Math.min(remainingBudget, remainingParticles);
569
584
  if (request.spawnBurstCenterFlash && !request.flashSpawned) {
570
- spawnBurstCenterFlash(engine, request.x, request.y, request.flashOptions ?? request.options);
571
585
  request.flashSpawned = true;
586
+ if (spawnBurstCenterFlash(engine, request.x, request.y, request.flashOptions ?? request.options)) {
587
+ notifyBurst(engine, request.burstEvent);
588
+ }
589
+ if (!isCurrentRun(engine, generation)) return;
572
590
  }
573
591
  if (request.spawnBurstTwinkleCloud && !request.twinkleCloudSpawned) {
574
- spawnBurstTwinkleCloud(engine, request.x, request.y, request.options);
575
592
  request.twinkleCloudSpawned = true;
593
+ if (spawnBurstTwinkleCloud(engine, request.x, request.y, request.options)) {
594
+ notifyBurst(engine, request.burstEvent);
595
+ }
596
+ if (!isCurrentRun(engine, generation)) return;
576
597
  }
577
598
  spawnBurstParticles(engine, request, request.nextIndex, spawnCount);
599
+ notifyBurst(engine, request.burstEvent);
600
+ if (!isCurrentRun(engine, generation)) return;
578
601
  request.nextIndex += spawnCount;
579
602
  remainingBudget -= spawnCount;
580
603
 
@@ -589,7 +612,7 @@ export function drainBurstQueue(engine) {
589
612
  export function launchBurst(engine, x, y, options = {}) {
590
613
  const normalizedOptions = normalizeGroupedPublicOptions(options);
591
614
  if (engine.enableBurstQueueThrottling) {
592
- return enqueueBurst(engine, x, y, normalizedOptions);
615
+ return enqueueBurst(engine, x, y, normalizedOptions, options[BURST_WORK]);
593
616
  }
594
617
 
595
618
  if (!engine.running) {
@@ -600,12 +623,14 @@ export function launchBurst(engine, x, y, options = {}) {
600
623
  normalizedOptions,
601
624
  resolveBurstCurrentConfig(engine),
602
625
  );
603
- spawnBurstCenterFlash(engine, x, y, normalizedOptions);
604
- spawnBurstTwinkleCloud(engine, x, y, normalizedOptions);
626
+ const burst = createBurstEvent(engine, x, y, options[BURST_WORK]);
627
+ if (!spawnBurstExtras(engine, burst, normalizedOptions, normalizedOptions)) return true;
605
628
  const requests = createBurstRequests(x, y, burstOptions);
606
- requests.forEach((request) => {
629
+ for (const request of requests) {
607
630
  spawnBurstParticles(engine, request, 0, request.totalCount);
608
- });
631
+ notifyBurst(engine, burst);
632
+ if (!isCurrentRun(engine, burst.generation)) return true;
633
+ }
609
634
  return true;
610
635
  }
611
636
 
@@ -669,6 +694,7 @@ function spawnBurstCenterFlash(engine, x, y, options = {}) {
669
694
  color,
670
695
  innerStop,
671
696
  });
697
+ return true;
672
698
  }
673
699
 
674
700
  function spawnBurstTwinkleCloud(engine, x, y, options = {}) {
@@ -761,4 +787,5 @@ function spawnBurstTwinkleCloud(engine, x, y, options = {}) {
761
787
  spawnSpread,
762
788
  twinkles,
763
789
  });
790
+ return true;
764
791
  }
package/src/presets.js CHANGED
@@ -346,6 +346,7 @@ function runAchievementUnlock(engine, targetX, targetY, options) {
346
346
  }
347
347
 
348
348
  function runSnowfall(engine, _targetX, _targetY, options) {
349
+ const generation = engine.runGeneration;
349
350
  const flakesPerWave = 6;
350
351
  const waveDelayMs = 1500;
351
352
 
@@ -355,6 +356,7 @@ function runSnowfall(engine, _targetX, _targetY, options) {
355
356
  const x = randomRange(0, stageWidth);
356
357
  const y = -10 - randomRange(0, 40);
357
358
  engine.burst(x, y, options);
359
+ if (engine.runGeneration !== generation) return;
358
360
  }
359
361
 
360
362
  scheduleTimeout(engine, () => {
@@ -372,6 +374,7 @@ function runSnowfall(engine, _targetX, _targetY, options) {
372
374
  }
373
375
 
374
376
  function runLeafFall(engine, _targetX, _targetY, options) {
377
+ const generation = engine.runGeneration;
375
378
  const leavesPerWave = 5;
376
379
  const waveDelayMs = 1200;
377
380
 
@@ -381,6 +384,7 @@ function runLeafFall(engine, _targetX, _targetY, options) {
381
384
  const x = randomRange(0, stageWidth);
382
385
  const y = -16 - randomRange(0, 60);
383
386
  engine.burst(x, y, options);
387
+ if (engine.runGeneration !== generation) return;
384
388
  }
385
389
 
386
390
  scheduleTimeout(engine, () => {
@@ -398,6 +402,7 @@ function runLeafFall(engine, _targetX, _targetY, options) {
398
402
  }
399
403
 
400
404
  function runAmbientSparkle(engine, targetX, targetY, options) {
405
+ const generation = engine.runGeneration;
401
406
  const pulseDelayMs = 8000;
402
407
 
403
408
  const launchPulse = () => {
@@ -405,6 +410,7 @@ function runAmbientSparkle(engine, targetX, targetY, options) {
405
410
  const centerY = Number.isFinite(targetY) ? targetY : engine.height * 0.5;
406
411
 
407
412
  engine.launchRocketAt(centerX, centerY, options);
413
+ if (engine.runGeneration !== generation) return;
408
414
  engine.launchRocketAt(
409
415
  centerX,
410
416
  centerY,
@@ -415,6 +421,7 @@ function runAmbientSparkle(engine, targetX, targetY, options) {
415
421
  }),
416
422
  );
417
423
 
424
+ if (engine.runGeneration !== generation) return;
418
425
  scheduleTimeout(engine, () => {
419
426
  if (!engine.running) {
420
427
  return;
@@ -430,6 +437,7 @@ function runAmbientSparkle(engine, targetX, targetY, options) {
430
437
  }
431
438
 
432
439
  function runConfettiPop(engine, _targetX, _targetY, options) {
440
+ const generation = engine.runGeneration;
433
441
  const stageWidth = engine.width;
434
442
  const stageHeight = engine.height;
435
443
  const cannons = [
@@ -437,7 +445,7 @@ function runConfettiPop(engine, _targetX, _targetY, options) {
437
445
  { x: 1.01, y: 0.5, directionDeg: 135 },
438
446
  ];
439
447
 
440
- cannons.forEach(({ x, y, directionDeg }) => {
448
+ for (const { x, y, directionDeg } of cannons) {
441
449
  engine.confetti(
442
450
  stageWidth * x,
443
451
  stageHeight * y,
@@ -447,7 +455,8 @@ function runConfettiPop(engine, _targetX, _targetY, options) {
447
455
  },
448
456
  }),
449
457
  );
450
- });
458
+ if (engine.runGeneration !== generation) return true;
459
+ }
451
460
 
452
461
  return true;
453
462
  }
package/src/renderer.js CHANGED
@@ -800,6 +800,7 @@ function drawFormParticle(engine, particle) {
800
800
  }
801
801
 
802
802
  export function loop(engine, now) {
803
+ const generation = engine.runGeneration;
803
804
  if (!engine.running || !engine.ctx) {
804
805
  return;
805
806
  }
@@ -817,6 +818,7 @@ export function loop(engine, now) {
817
818
 
818
819
  ctx.clearRect(0, 0, engine.width, engine.height);
819
820
  updateRockets(engine, dt);
821
+ if (engine.runGeneration !== generation) return;
820
822
  updateParticles(engine, dt);
821
823
  updateFlashes(engine, dt);
822
824
  updateTwinkleClouds(engine, dt);
@@ -854,6 +856,7 @@ export function loop(engine, now) {
854
856
  }
855
857
 
856
858
  drainBurstQueue(engine);
859
+ if (engine.runGeneration !== generation) return;
857
860
  clearRenderCachesWhenIdle(engine);
858
861
 
859
862
  const requestAnimationFrameFn = getRequestAnimationFrame(engine);
@@ -20,6 +20,7 @@ import {
20
20
  normalizeHexColor,
21
21
  } from './colorUtils.js';
22
22
  import { buildBurstOptions } from './burstConfigBuilder.js';
23
+ import { BURST_SEQUENCE, BURST_WORK, reserveBurstWork } from './burstSequence.js';
23
24
  import {
24
25
  launchBurst,
25
26
  resolveBurstBaseColor,
@@ -317,6 +318,7 @@ function launchRocketWithExplicitTarget(engine, options, explicitTarget) {
317
318
  return false;
318
319
  }
319
320
 
321
+ const generation = engine.runGeneration;
320
322
  const wasRunning = engine.running;
321
323
  if (!startLoop(engine)) {
322
324
  return false;
@@ -324,7 +326,7 @@ function launchRocketWithExplicitTarget(engine, options, explicitTarget) {
324
326
 
325
327
  createRocket(engine, options, explicitTarget);
326
328
 
327
- if (!wasRunning) {
329
+ if (engine.runGeneration === generation && !wasRunning) {
328
330
  engine.finishWhenDone();
329
331
  }
330
332
 
@@ -332,6 +334,23 @@ function launchRocketWithExplicitTarget(engine, options, explicitTarget) {
332
334
  }
333
335
 
334
336
 
337
+ function resolveCascadeCount(count, sizeScale) {
338
+ return clamp(Math.floor((count ?? 0) * sizeScale), 0, ROCKET_CASCADE_LIMITS.burstCountMax);
339
+ }
340
+
341
+ // Only the validated victory recipe reaches this boundary: rocket children,
342
+ // followed by their finite direct-burst cascades. Reserve before any callback.
343
+ function reserveVictoryWork(sequence, rocket) {
344
+ const childOptions = resolveDefaultCascadeChildLaunchOptions(rocket, rocket.cascade);
345
+ const normalizedChild = normalizeGroupedPublicOptions(childOptions);
346
+ const leafCount = resolveCascadeCount(normalizedChild.rocket.cascade.bursts, normalizedChild.sizeScale);
347
+ const children = Array.from({ length: rocket.cascade.bursts }, () => {
348
+ const leaves = Array.from({ length: leafCount }, () => reserveBurstWork(sequence));
349
+ return reserveBurstWork(sequence, leaves);
350
+ });
351
+ return reserveBurstWork(sequence, children);
352
+ }
353
+
335
354
  function createRocket(engine, options = {}, explicitTarget = null) {
336
355
  const normalizedOptions = normalizeGroupedPublicOptions(options);
337
356
  const {
@@ -519,16 +538,8 @@ function createRocket(engine, options = {}, explicitTarget = null) {
519
538
  burstCurrentConfig,
520
539
  )
521
540
  : 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
- );
541
+ const cascadeBurstCount = resolveCascadeCount(rocketCascadeInput.bursts, sizeScale);
542
+ const cascadeChildBurstCount = resolveCascadeCount(rocketCascadeInput.childBursts ?? rocketCascadeInput.bursts, sizeScale);
532
543
  const burstCascadeDelayMs = resolveScaledOverride(
533
544
  rocketCascadeInput.delay,
534
545
  ROCKET_CASCADE_LIMITS.defaultDelayMs,
@@ -617,15 +628,23 @@ function createRocket(engine, options = {}, explicitTarget = null) {
617
628
  simple: !!normalizedOptions.simple,
618
629
  };
619
630
 
631
+ const work = options[BURST_WORK]
632
+ ?? (options[BURST_SEQUENCE] ? reserveVictoryWork(options[BURST_SEQUENCE], rocket) : null);
633
+ if (work) {
634
+ rocket.sequenceWork = work;
635
+ rocket.burst = { ...rocket.burst, [BURST_WORK]: work };
636
+ }
620
637
  engine.rockets.push(rocket);
621
638
  return rocket;
622
639
  }
623
640
 
624
641
  export function launchRocket(engine, options = {}) {
642
+ const generation = engine.runGeneration;
625
643
  const count = options.count ?? 1;
626
644
 
627
645
  for (let index = 0; index < count; index += 1) {
628
646
  createRocket(engine, options);
647
+ if (engine.runGeneration !== generation) return;
629
648
  }
630
649
  }
631
650
 
@@ -659,6 +678,7 @@ export function launchRocketFromTo(engine, startX, startY, targetX, targetY, opt
659
678
  }
660
679
 
661
680
  export function scheduleCascadeBursts(engine, rocket) {
681
+ const generation = engine.runGeneration;
662
682
  const cascade = rocket.cascade ?? {};
663
683
  const cascadeMode = resolveRocketCascadeMode(cascade.mode);
664
684
  const cascadeBurstCount = cascade.bursts ?? 0;
@@ -692,7 +712,11 @@ export function scheduleCascadeBursts(engine, rocket) {
692
712
  cascade,
693
713
  distance,
694
714
  );
695
- launchRocketFromTo(engine, originX, originY, x, y, childLaunchOptions);
715
+ launchRocketFromTo(engine, originX, originY, x, y, {
716
+ ...childLaunchOptions,
717
+ ...(rocket.sequenceWork ? { [BURST_WORK]: rocket.sequenceWork.children[index] } : {}),
718
+ });
719
+ if (engine.runGeneration !== generation) return;
696
720
  }
697
721
  return;
698
722
  }
@@ -711,7 +735,9 @@ export function scheduleCascadeBursts(engine, rocket) {
711
735
  spreadY,
712
736
  );
713
737
 
714
- launchBurst(engine, x, y, burstOptions);
738
+ launchBurst(engine, x, y, rocket.sequenceWork
739
+ ? { ...burstOptions, [BURST_WORK]: rocket.sequenceWork.children[index] }
740
+ : burstOptions);
715
741
  }, resolveScaledDelay(engine, delay * (index + 1)));
716
742
  }
717
743
  }
package/src/runtime.js CHANGED
@@ -4,6 +4,10 @@ import {
4
4
  normalizeGroupedPublicOptions,
5
5
  } from './valueNormalizers.js';
6
6
  import { buildEngineConfig } from './configBuilder.js';
7
+ import {
8
+ BURST_SEQUENCE, createBurstSequence, discardBurstSequence, invalidateBurstSequences,
9
+ supportsSequenceConfig, supportsSequenceLaunch,
10
+ } from './burstSequence.js';
7
11
  import {
8
12
  resolveFireworksEnginePreset,
9
13
  resolveFireworksLaunchPreset,
@@ -285,6 +289,8 @@ function mergePresetLaunchOptions(presetOptions = {}, launchOverrides = {}) {
285
289
 
286
290
  export class FireworksEngine {
287
291
  constructor(options = {}) {
292
+ this.runGeneration = 0;
293
+ this.onBurstCallback = typeof options?.onBurst === 'function' ? options.onBurst : null;
288
294
  const normalizedOptions = normalizeGroupedPublicOptions(options);
289
295
  this.explicitLaunchOptions = extractExplicitLaunchOptions(options);
290
296
  const engineConfig = buildEngineConfig(normalizedOptions);
@@ -420,6 +426,10 @@ export class FireworksEngine {
420
426
  }
421
427
 
422
428
  launchPresetAt(name, targetX, targetY, options = {}) {
429
+ if (options?.trackBurstSequence === true && !supportsSequenceLaunch(this, name, options)) {
430
+ return false;
431
+ }
432
+ const generation = this.runGeneration;
423
433
  if (shouldSkipHiddenLaunch(this)) {
424
434
  return false;
425
435
  }
@@ -443,6 +453,10 @@ export class FireworksEngine {
443
453
 
444
454
  const launchOptions = { ...mergedOptions };
445
455
  delete launchOptions.count;
456
+ delete launchOptions.trackBurstSequence;
457
+ if (options.trackBurstSequence === true) {
458
+ launchOptions[BURST_SEQUENCE] = createBurstSequence(this);
459
+ }
446
460
 
447
461
  applyTimeScaleFromLaunchOptions(this, launchOptions);
448
462
 
@@ -452,11 +466,12 @@ export class FireworksEngine {
452
466
  ? launchPreset.run(this, targetX, targetY, launchOptions)
453
467
  : false;
454
468
  launched = launched || nextLaunched;
455
- if (!nextLaunched) {
469
+ if (this.runGeneration !== generation || !nextLaunched) {
456
470
  break;
457
471
  }
458
472
  }
459
473
 
474
+ if (!launched) discardBurstSequence(this, launchOptions[BURST_SEQUENCE]);
460
475
  return launched;
461
476
  }
462
477
 
@@ -485,7 +500,7 @@ export class FireworksEngine {
485
500
  for (let index = 0; index < count; index += 1) {
486
501
  const nextLaunched = launchRocketAt(this, targetX, targetY, launchOptions);
487
502
  launched = launched || nextLaunched;
488
- if (!nextLaunched) {
503
+ if (this.runGeneration !== generation || !nextLaunched) {
489
504
  break;
490
505
  }
491
506
  }
@@ -608,6 +623,7 @@ export class FireworksEngine {
608
623
  this.maxBurstParticlesPerFrame = this.performance.maxBurstParticlesPerFrame;
609
624
  this.particlePressureThreshold = this.performance.particlePressureThreshold;
610
625
  this.fastRectRadiusThreshold = this.performance.fastRectRadiusThreshold;
626
+ if (!supportsSequenceConfig(this)) invalidateBurstSequences(this);
611
627
  }
612
628
 
613
629
  if (
@@ -623,8 +639,9 @@ export class FireworksEngine {
623
639
  // Note: Renderer logic is decoupled; use engine directly in renderer for performance.
624
640
 
625
641
  finishWhenDone() {
642
+ const generation = this.runGeneration;
626
643
  const check = () => {
627
- if (!this.running) {
644
+ if (this.runGeneration !== generation || !this.running) {
628
645
  return;
629
646
  }
630
647
 
@@ -639,7 +656,7 @@ export class FireworksEngine {
639
656
  this.finishLifecycleNotified = true;
640
657
  invokeLifecycleHook(this.activeLifecycleHooks.onFinish, this);
641
658
  }
642
- this.stop(false);
659
+ if (this.runGeneration === generation) this.stop(false);
643
660
  return;
644
661
  }
645
662
 
@@ -728,6 +745,8 @@ export class FireworksEngine {
728
745
  }
729
746
 
730
747
  stop(clearCanvas = true) {
748
+ invalidateBurstSequences(this);
749
+ this.runGeneration += 1;
731
750
  const wasRunning = this.running;
732
751
  this.running = false;
733
752
  lifecycle.clearPendingStart(this);
@@ -160,10 +160,12 @@ export function removeResizeObserver(engine) {
160
160
  }
161
161
 
162
162
  export function scheduleTimeout(engine, callback, delay = 0) {
163
+ const generation = engine.runGeneration;
163
164
  const schedule = typeof engine.setTimeoutRef === 'function' ? engine.setTimeoutRef : setTimeout;
164
165
 
165
166
  let timeoutId = null;
166
167
  const runWhenVisible = () => {
168
+ if (engine.runGeneration !== generation) return;
167
169
  if (timeoutId !== null) {
168
170
  engine.timeoutIds.delete(timeoutId);
169
171
  }
package/src/update.js CHANGED
@@ -90,6 +90,7 @@ function randomBetween(min, max) {
90
90
  }
91
91
 
92
92
  export function updateRockets(engine, dt) {
93
+ const generation = engine.runGeneration;
93
94
  const rockets = engine.rockets;
94
95
  let writeIndex = 0;
95
96
 
@@ -124,7 +125,9 @@ export function updateRockets(engine, dt) {
124
125
  rocket.x = burstX;
125
126
  rocket.y = burstY;
126
127
  launchBurst(engine, burstX, burstY, rocket.burst);
128
+ if (engine.runGeneration !== generation) return;
127
129
  scheduleCascadeBursts(engine, rocket);
130
+ if (engine.runGeneration !== generation) return;
128
131
  continue;
129
132
  }
130
133