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 +78 -0
- package/index.d.ts +32 -3
- package/package.json +2 -2
- package/src/burstEvents.js +22 -0
- package/src/burstSequence.js +67 -0
- package/src/burstSpawn.js +37 -10
- package/src/presets.js +11 -2
- package/src/renderer.js +3 -0
- package/src/rocketLaunch.js +39 -13
- package/src/runtime.js +23 -4
- package/src/runtimeLifecycle.js +2 -0
- package/src/update.js +3 -0
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?:
|
|
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?:
|
|
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?:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/rocketLaunch.js
CHANGED
|
@@ -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 =
|
|
523
|
-
|
|
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,
|
|
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,
|
|
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);
|
package/src/runtimeLifecycle.js
CHANGED
|
@@ -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
|
|