fireworks-engine 1.0.1 → 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
@@ -85,6 +85,53 @@ for both construction and start/update configuration.
85
85
 
86
86
  ## Public API
87
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
+
88
135
  Primary exports:
89
136
 
90
137
  - `FireworksEngine`
package/index.d.ts CHANGED
@@ -192,6 +192,23 @@ export interface FireworksRocketLaunchOptions extends FireworksBurstOverrideOpti
192
192
  export interface FireworksBurstEvent {
193
193
  x: number;
194
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;
195
212
  }
196
213
 
197
214
  export type FireworksBurstCallback = (event: FireworksBurstEvent) => void;
@@ -636,7 +653,7 @@ export declare class FireworksEngine {
636
653
  name: FireworksEnginePresetName | FireworksLaunchPresetName | string,
637
654
  targetX: number,
638
655
  targetY: number,
639
- options?: FireworksRocketLaunchOptions,
656
+ options?: FireworksPresetLaunchOptions,
640
657
  ): boolean;
641
658
  /**
642
659
  * Launches a rocket from a specific start point toward a target point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fireworks-engine",
3
- "version": "1.0.1",
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 && node --test test/burst-events.test.js"
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"
@@ -4,15 +4,18 @@ export function isCurrentRun(engine, generation) {
4
4
  return engine.runGeneration === generation;
5
5
  }
6
6
 
7
- export function createBurstEvent(engine, x, y) {
8
- return { generation: engine.runGeneration, x, y, notified: false };
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 };
9
11
  }
10
12
 
11
13
  export function notifyBurst(engine, burst) {
12
14
  if (burst.notified || !isCurrentRun(engine, burst.generation)) return;
13
15
  burst.notified = true;
16
+ const sequence = materializeBurstWork(engine, burst.work);
14
17
  try {
15
- engine.onBurstCallback?.({ x: burst.x, y: burst.y });
18
+ engine.onBurstCallback?.({ x: burst.x, y: burst.y, ...(sequence ? { sequence } : {}) });
16
19
  } catch {
17
20
  // Consumer code must not interrupt rendering or lifecycle cleanup.
18
21
  }
@@ -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,4 +1,5 @@
1
1
  import { createBurstEvent, isCurrentRun, notifyBurst } from './burstEvents.js';
2
+ import { BURST_WORK } from './burstSequence.js';
2
3
  import {
3
4
  BURST_FLASH_ATTACK_UNIT,
4
5
  DEFAULT_BURST_SINGLE_COLOR,
@@ -264,7 +265,7 @@ function spawnBurstExtras(engine, burst, flashOptions, cloudOptions) {
264
265
  return isCurrentRun(engine, burst.generation);
265
266
  }
266
267
 
267
- function enqueueBurst(engine, x, y, options = {}) {
268
+ function enqueueBurst(engine, x, y, options = {}, work) {
268
269
  if (!engine.running) {
269
270
  return false;
270
271
  }
@@ -274,7 +275,7 @@ function enqueueBurst(engine, x, y, options = {}) {
274
275
  resolveBurstCurrentConfig(engine),
275
276
  );
276
277
  const requests = createBurstRequests(x, y, burstOptions, options);
277
- const burst = createBurstEvent(engine, x, y);
278
+ const burst = createBurstEvent(engine, x, y, work);
278
279
  requests.forEach((request) => { request.burstEvent = burst; });
279
280
 
280
281
  if (requests.length === 0) {
@@ -611,7 +612,7 @@ export function drainBurstQueue(engine) {
611
612
  export function launchBurst(engine, x, y, options = {}) {
612
613
  const normalizedOptions = normalizeGroupedPublicOptions(options);
613
614
  if (engine.enableBurstQueueThrottling) {
614
- return enqueueBurst(engine, x, y, normalizedOptions);
615
+ return enqueueBurst(engine, x, y, normalizedOptions, options[BURST_WORK]);
615
616
  }
616
617
 
617
618
  if (!engine.running) {
@@ -622,7 +623,7 @@ export function launchBurst(engine, x, y, options = {}) {
622
623
  normalizedOptions,
623
624
  resolveBurstCurrentConfig(engine),
624
625
  );
625
- const burst = createBurstEvent(engine, x, y);
626
+ const burst = createBurstEvent(engine, x, y, options[BURST_WORK]);
626
627
  if (!spawnBurstExtras(engine, burst, normalizedOptions, normalizedOptions)) return true;
627
628
  const requests = createBurstRequests(x, y, burstOptions);
628
629
  for (const request of requests) {
@@ -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,
@@ -333,6 +334,23 @@ function launchRocketWithExplicitTarget(engine, options, explicitTarget) {
333
334
  }
334
335
 
335
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
+
336
354
  function createRocket(engine, options = {}, explicitTarget = null) {
337
355
  const normalizedOptions = normalizeGroupedPublicOptions(options);
338
356
  const {
@@ -520,16 +538,8 @@ function createRocket(engine, options = {}, explicitTarget = null) {
520
538
  burstCurrentConfig,
521
539
  )
522
540
  : null;
523
- const cascadeBurstCount = clamp(
524
- Math.floor((rocketCascadeInput.bursts ?? 0) * sizeScale),
525
- 0,
526
- ROCKET_CASCADE_LIMITS.burstCountMax,
527
- );
528
- const cascadeChildBurstCount = clamp(
529
- Math.floor((rocketCascadeInput.childBursts ?? rocketCascadeInput.bursts ?? 0) * sizeScale),
530
- 0,
531
- ROCKET_CASCADE_LIMITS.burstCountMax,
532
- );
541
+ const cascadeBurstCount = resolveCascadeCount(rocketCascadeInput.bursts, sizeScale);
542
+ const cascadeChildBurstCount = resolveCascadeCount(rocketCascadeInput.childBursts ?? rocketCascadeInput.bursts, sizeScale);
533
543
  const burstCascadeDelayMs = resolveScaledOverride(
534
544
  rocketCascadeInput.delay,
535
545
  ROCKET_CASCADE_LIMITS.defaultDelayMs,
@@ -618,6 +628,12 @@ function createRocket(engine, options = {}, explicitTarget = null) {
618
628
  simple: !!normalizedOptions.simple,
619
629
  };
620
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
+ }
621
637
  engine.rockets.push(rocket);
622
638
  return rocket;
623
639
  }
@@ -696,7 +712,10 @@ export function scheduleCascadeBursts(engine, rocket) {
696
712
  cascade,
697
713
  distance,
698
714
  );
699
- 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
+ });
700
719
  if (engine.runGeneration !== generation) return;
701
720
  }
702
721
  return;
@@ -716,7 +735,9 @@ export function scheduleCascadeBursts(engine, rocket) {
716
735
  spreadY,
717
736
  );
718
737
 
719
- launchBurst(engine, x, y, burstOptions);
738
+ launchBurst(engine, x, y, rocket.sequenceWork
739
+ ? { ...burstOptions, [BURST_WORK]: rocket.sequenceWork.children[index] }
740
+ : burstOptions);
720
741
  }, resolveScaledDelay(engine, delay * (index + 1)));
721
742
  }
722
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,
@@ -422,6 +426,9 @@ export class FireworksEngine {
422
426
  }
423
427
 
424
428
  launchPresetAt(name, targetX, targetY, options = {}) {
429
+ if (options?.trackBurstSequence === true && !supportsSequenceLaunch(this, name, options)) {
430
+ return false;
431
+ }
425
432
  const generation = this.runGeneration;
426
433
  if (shouldSkipHiddenLaunch(this)) {
427
434
  return false;
@@ -446,6 +453,10 @@ export class FireworksEngine {
446
453
 
447
454
  const launchOptions = { ...mergedOptions };
448
455
  delete launchOptions.count;
456
+ delete launchOptions.trackBurstSequence;
457
+ if (options.trackBurstSequence === true) {
458
+ launchOptions[BURST_SEQUENCE] = createBurstSequence(this);
459
+ }
449
460
 
450
461
  applyTimeScaleFromLaunchOptions(this, launchOptions);
451
462
 
@@ -460,6 +471,7 @@ export class FireworksEngine {
460
471
  }
461
472
  }
462
473
 
474
+ if (!launched) discardBurstSequence(this, launchOptions[BURST_SEQUENCE]);
463
475
  return launched;
464
476
  }
465
477
 
@@ -611,6 +623,7 @@ export class FireworksEngine {
611
623
  this.maxBurstParticlesPerFrame = this.performance.maxBurstParticlesPerFrame;
612
624
  this.particlePressureThreshold = this.performance.particlePressureThreshold;
613
625
  this.fastRectRadiusThreshold = this.performance.fastRectRadiusThreshold;
626
+ if (!supportsSequenceConfig(this)) invalidateBurstSequences(this);
614
627
  }
615
628
 
616
629
  if (
@@ -732,6 +745,7 @@ export class FireworksEngine {
732
745
  }
733
746
 
734
747
  stop(clearCanvas = true) {
748
+ invalidateBurstSequences(this);
735
749
  this.runGeneration += 1;
736
750
  const wasRunning = this.running;
737
751
  this.running = false;