fireworks-engine 1.0.0 → 1.0.1

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,6 +52,37 @@ 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
 
57
88
  Primary exports:
package/index.d.ts CHANGED
@@ -189,6 +189,18 @@ export interface FireworksRocketLaunchOptions extends FireworksBurstOverrideOpti
189
189
  rocketTailEmbers?: FireworksRocketTailEmbersOptions;
190
190
  }
191
191
 
192
+ export interface FireworksBurstEvent {
193
+ x: number;
194
+ y: number;
195
+ }
196
+
197
+ export type FireworksBurstCallback = (event: FireworksBurstEvent) => void;
198
+
199
+ /** Options accepted only when creating an engine. */
200
+ export interface FireworksEngineCreationOptions extends FireworksEngineOptions {
201
+ onBurst?: FireworksBurstCallback | null;
202
+ }
203
+
192
204
  export interface FireworksEngineOptions extends FireworksRocketLaunchOptions {
193
205
  canvas?: FireworksCanvasOptions;
194
206
  runtime?: FireworksRuntimeOptions;
@@ -506,7 +518,7 @@ export interface FireworksBurstRequest {
506
518
  export interface FireworksPresetConfig extends FireworksEngineOptions { }
507
519
 
508
520
  export declare class FireworksEngine {
509
- constructor(options?: FireworksEngineOptions);
521
+ constructor(options?: FireworksEngineCreationOptions);
510
522
 
511
523
  documentRef: Document | null | undefined;
512
524
  windowRef: Window | null | undefined;
@@ -677,7 +689,7 @@ export interface FireworksRocketExplicitTarget {
677
689
  * @param options Initial engine configuration.
678
690
  * @returns A new fireworks engine instance.
679
691
  */
680
- export declare function createFireworksEngine(options?: FireworksEngineOptions): FireworksEngine;
692
+ export declare function createFireworksEngine(options?: FireworksEngineCreationOptions): FireworksEngine;
681
693
 
682
694
  /**
683
695
  * 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.1",
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"
45
45
  },
46
46
  "devDependencies": {
47
47
  "typescript": "^5.9.3"
@@ -0,0 +1,19 @@
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
+ export function createBurstEvent(engine, x, y) {
8
+ return { generation: engine.runGeneration, x, y, notified: false };
9
+ }
10
+
11
+ export function notifyBurst(engine, burst) {
12
+ if (burst.notified || !isCurrentRun(engine, burst.generation)) return;
13
+ burst.notified = true;
14
+ try {
15
+ engine.onBurstCallback?.({ x: burst.x, y: burst.y });
16
+ } catch {
17
+ // Consumer code must not interrupt rendering or lifecycle cleanup.
18
+ }
19
+ }
package/src/burstSpawn.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createBurstEvent, isCurrentRun, notifyBurst } from './burstEvents.js';
1
2
  import {
2
3
  BURST_FLASH_ATTACK_UNIT,
3
4
  DEFAULT_BURST_SINGLE_COLOR,
@@ -252,6 +253,17 @@ function createBurstRequests(x, y, options, flashOptions = options) {
252
253
  return requests;
253
254
  }
254
255
 
256
+ function spawnBurstExtras(engine, burst, flashOptions, cloudOptions) {
257
+ if (spawnBurstCenterFlash(engine, burst.x, burst.y, flashOptions)) {
258
+ notifyBurst(engine, burst);
259
+ }
260
+ if (!isCurrentRun(engine, burst.generation)) return false;
261
+ if (spawnBurstTwinkleCloud(engine, burst.x, burst.y, cloudOptions)) {
262
+ notifyBurst(engine, burst);
263
+ }
264
+ return isCurrentRun(engine, burst.generation);
265
+ }
266
+
255
267
  function enqueueBurst(engine, x, y, options = {}) {
256
268
  if (!engine.running) {
257
269
  return false;
@@ -262,10 +274,11 @@ function enqueueBurst(engine, x, y, options = {}) {
262
274
  resolveBurstCurrentConfig(engine),
263
275
  );
264
276
  const requests = createBurstRequests(x, y, burstOptions, options);
277
+ const burst = createBurstEvent(engine, x, y);
278
+ requests.forEach((request) => { request.burstEvent = burst; });
265
279
 
266
280
  if (requests.length === 0) {
267
- spawnBurstCenterFlash(engine, x, y, options);
268
- spawnBurstTwinkleCloud(engine, x, y, burstOptions);
281
+ spawnBurstExtras(engine, burst, options, burstOptions);
269
282
  return true;
270
283
  }
271
284
 
@@ -554,6 +567,7 @@ function spawnBurstParticles(engine, request, startIndex, count) {
554
567
  }
555
568
 
556
569
  export function drainBurstQueue(engine) {
570
+ const generation = engine.runGeneration;
557
571
  let remainingBudget = engine.maxBurstParticlesPerFrame;
558
572
 
559
573
  while (remainingBudget > 0 && engine.burstQueue.length > 0) {
@@ -567,14 +581,22 @@ export function drainBurstQueue(engine) {
567
581
 
568
582
  const spawnCount = Math.min(remainingBudget, remainingParticles);
569
583
  if (request.spawnBurstCenterFlash && !request.flashSpawned) {
570
- spawnBurstCenterFlash(engine, request.x, request.y, request.flashOptions ?? request.options);
571
584
  request.flashSpawned = true;
585
+ if (spawnBurstCenterFlash(engine, request.x, request.y, request.flashOptions ?? request.options)) {
586
+ notifyBurst(engine, request.burstEvent);
587
+ }
588
+ if (!isCurrentRun(engine, generation)) return;
572
589
  }
573
590
  if (request.spawnBurstTwinkleCloud && !request.twinkleCloudSpawned) {
574
- spawnBurstTwinkleCloud(engine, request.x, request.y, request.options);
575
591
  request.twinkleCloudSpawned = true;
592
+ if (spawnBurstTwinkleCloud(engine, request.x, request.y, request.options)) {
593
+ notifyBurst(engine, request.burstEvent);
594
+ }
595
+ if (!isCurrentRun(engine, generation)) return;
576
596
  }
577
597
  spawnBurstParticles(engine, request, request.nextIndex, spawnCount);
598
+ notifyBurst(engine, request.burstEvent);
599
+ if (!isCurrentRun(engine, generation)) return;
578
600
  request.nextIndex += spawnCount;
579
601
  remainingBudget -= spawnCount;
580
602
 
@@ -600,12 +622,14 @@ export function launchBurst(engine, x, y, options = {}) {
600
622
  normalizedOptions,
601
623
  resolveBurstCurrentConfig(engine),
602
624
  );
603
- spawnBurstCenterFlash(engine, x, y, normalizedOptions);
604
- spawnBurstTwinkleCloud(engine, x, y, normalizedOptions);
625
+ const burst = createBurstEvent(engine, x, y);
626
+ if (!spawnBurstExtras(engine, burst, normalizedOptions, normalizedOptions)) return true;
605
627
  const requests = createBurstRequests(x, y, burstOptions);
606
- requests.forEach((request) => {
628
+ for (const request of requests) {
607
629
  spawnBurstParticles(engine, request, 0, request.totalCount);
608
- });
630
+ notifyBurst(engine, burst);
631
+ if (!isCurrentRun(engine, burst.generation)) return true;
632
+ }
609
633
  return true;
610
634
  }
611
635
 
@@ -669,6 +693,7 @@ function spawnBurstCenterFlash(engine, x, y, options = {}) {
669
693
  color,
670
694
  innerStop,
671
695
  });
696
+ return true;
672
697
  }
673
698
 
674
699
  function spawnBurstTwinkleCloud(engine, x, y, options = {}) {
@@ -761,4 +786,5 @@ function spawnBurstTwinkleCloud(engine, x, y, options = {}) {
761
786
  spawnSpread,
762
787
  twinkles,
763
788
  });
789
+ return true;
764
790
  }
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);
@@ -317,6 +317,7 @@ function launchRocketWithExplicitTarget(engine, options, explicitTarget) {
317
317
  return false;
318
318
  }
319
319
 
320
+ const generation = engine.runGeneration;
320
321
  const wasRunning = engine.running;
321
322
  if (!startLoop(engine)) {
322
323
  return false;
@@ -324,7 +325,7 @@ function launchRocketWithExplicitTarget(engine, options, explicitTarget) {
324
325
 
325
326
  createRocket(engine, options, explicitTarget);
326
327
 
327
- if (!wasRunning) {
328
+ if (engine.runGeneration === generation && !wasRunning) {
328
329
  engine.finishWhenDone();
329
330
  }
330
331
 
@@ -622,10 +623,12 @@ function createRocket(engine, options = {}, explicitTarget = null) {
622
623
  }
623
624
 
624
625
  export function launchRocket(engine, options = {}) {
626
+ const generation = engine.runGeneration;
625
627
  const count = options.count ?? 1;
626
628
 
627
629
  for (let index = 0; index < count; index += 1) {
628
630
  createRocket(engine, options);
631
+ if (engine.runGeneration !== generation) return;
629
632
  }
630
633
  }
631
634
 
@@ -659,6 +662,7 @@ export function launchRocketFromTo(engine, startX, startY, targetX, targetY, opt
659
662
  }
660
663
 
661
664
  export function scheduleCascadeBursts(engine, rocket) {
665
+ const generation = engine.runGeneration;
662
666
  const cascade = rocket.cascade ?? {};
663
667
  const cascadeMode = resolveRocketCascadeMode(cascade.mode);
664
668
  const cascadeBurstCount = cascade.bursts ?? 0;
@@ -693,6 +697,7 @@ export function scheduleCascadeBursts(engine, rocket) {
693
697
  distance,
694
698
  );
695
699
  launchRocketFromTo(engine, originX, originY, x, y, childLaunchOptions);
700
+ if (engine.runGeneration !== generation) return;
696
701
  }
697
702
  return;
698
703
  }
package/src/runtime.js CHANGED
@@ -285,6 +285,8 @@ function mergePresetLaunchOptions(presetOptions = {}, launchOverrides = {}) {
285
285
 
286
286
  export class FireworksEngine {
287
287
  constructor(options = {}) {
288
+ this.runGeneration = 0;
289
+ this.onBurstCallback = typeof options?.onBurst === 'function' ? options.onBurst : null;
288
290
  const normalizedOptions = normalizeGroupedPublicOptions(options);
289
291
  this.explicitLaunchOptions = extractExplicitLaunchOptions(options);
290
292
  const engineConfig = buildEngineConfig(normalizedOptions);
@@ -420,6 +422,7 @@ export class FireworksEngine {
420
422
  }
421
423
 
422
424
  launchPresetAt(name, targetX, targetY, options = {}) {
425
+ const generation = this.runGeneration;
423
426
  if (shouldSkipHiddenLaunch(this)) {
424
427
  return false;
425
428
  }
@@ -452,7 +455,7 @@ export class FireworksEngine {
452
455
  ? launchPreset.run(this, targetX, targetY, launchOptions)
453
456
  : false;
454
457
  launched = launched || nextLaunched;
455
- if (!nextLaunched) {
458
+ if (this.runGeneration !== generation || !nextLaunched) {
456
459
  break;
457
460
  }
458
461
  }
@@ -485,7 +488,7 @@ export class FireworksEngine {
485
488
  for (let index = 0; index < count; index += 1) {
486
489
  const nextLaunched = launchRocketAt(this, targetX, targetY, launchOptions);
487
490
  launched = launched || nextLaunched;
488
- if (!nextLaunched) {
491
+ if (this.runGeneration !== generation || !nextLaunched) {
489
492
  break;
490
493
  }
491
494
  }
@@ -623,8 +626,9 @@ export class FireworksEngine {
623
626
  // Note: Renderer logic is decoupled; use engine directly in renderer for performance.
624
627
 
625
628
  finishWhenDone() {
629
+ const generation = this.runGeneration;
626
630
  const check = () => {
627
- if (!this.running) {
631
+ if (this.runGeneration !== generation || !this.running) {
628
632
  return;
629
633
  }
630
634
 
@@ -639,7 +643,7 @@ export class FireworksEngine {
639
643
  this.finishLifecycleNotified = true;
640
644
  invokeLifecycleHook(this.activeLifecycleHooks.onFinish, this);
641
645
  }
642
- this.stop(false);
646
+ if (this.runGeneration === generation) this.stop(false);
643
647
  return;
644
648
  }
645
649
 
@@ -728,6 +732,7 @@ export class FireworksEngine {
728
732
  }
729
733
 
730
734
  stop(clearCanvas = true) {
735
+ this.runGeneration += 1;
731
736
  const wasRunning = this.running;
732
737
  this.running = false;
733
738
  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