falling-animation 0.1.1 → 0.1.3
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/dist/falling-animation.cjs +565 -203
- package/dist/falling-animation.cjs.map +1 -1
- package/dist/falling-animation.esm.js +565 -204
- package/dist/falling-animation.esm.js.map +1 -1
- package/dist/falling-animation.umd.js +565 -203
- package/dist/falling-animation.umd.js.map +1 -1
- package/dist/falling-animation.umd.min.js +2 -2
- package/dist/falling-animation.umd.min.js.map +1 -1
- package/dist/types/CanvasRenderer.d.ts +74 -0
- package/dist/types/FallingAnimation.d.ts +6 -6
- package/dist/types/Fireworks.d.ts +38 -5
- package/dist/types/Particle.d.ts +5 -17
- package/dist/types/index.d.ts +2 -1
- package/dist/types/types.d.ts +4 -2
- package/dist/types/utils.d.ts +5 -0
- package/package.json +1 -1
|
@@ -45,10 +45,21 @@ let idCounter = 0;
|
|
|
45
45
|
function generateId() {
|
|
46
46
|
return ++idCounter;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Check if code is running in browser environment
|
|
50
|
+
*/
|
|
51
|
+
function isBrowser() {
|
|
52
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
53
|
+
}
|
|
48
54
|
/**
|
|
49
55
|
* Resolve container from element or selector
|
|
56
|
+
* Throws an error if called in non-browser environment
|
|
50
57
|
*/
|
|
51
58
|
function resolveContainer(container) {
|
|
59
|
+
if (!isBrowser()) {
|
|
60
|
+
throw new Error('[falling-animation] This library requires a browser environment. ' +
|
|
61
|
+
'If using SSR (Next.js, Nuxt, etc.), make sure to only initialize on the client side.');
|
|
62
|
+
}
|
|
52
63
|
if (!container) {
|
|
53
64
|
return document.body;
|
|
54
65
|
}
|
|
@@ -232,7 +243,7 @@ function getAnimation(name) {
|
|
|
232
243
|
}
|
|
233
244
|
|
|
234
245
|
/**
|
|
235
|
-
* Particle class - manages individual falling objects
|
|
246
|
+
* Particle class - manages individual falling objects (Canvas-based, no DOM)
|
|
236
247
|
*/
|
|
237
248
|
/**
|
|
238
249
|
* Speed multiplier to convert user-friendly speed units to px/ms
|
|
@@ -242,22 +253,23 @@ function getAnimation(name) {
|
|
|
242
253
|
*/
|
|
243
254
|
const SPEED_MULTIPLIER = 0.04; // 40 px/s per unit, divided by 1000 for ms
|
|
244
255
|
class Particle {
|
|
245
|
-
constructor(options) {
|
|
246
|
-
this.options = options;
|
|
256
|
+
constructor(options, containerWidth, containerHeight) {
|
|
247
257
|
this.id = generateId();
|
|
248
258
|
this.age = 0;
|
|
249
259
|
this.phase = Math.random() * Math.PI * 2;
|
|
250
260
|
this.data = {};
|
|
251
|
-
//
|
|
252
|
-
this.containerWidth =
|
|
253
|
-
this.containerHeight =
|
|
261
|
+
// Store container dimensions
|
|
262
|
+
this.containerWidth = containerWidth;
|
|
263
|
+
this.containerHeight = containerHeight;
|
|
254
264
|
// Pick random object
|
|
255
265
|
const object = weightedRandomPick(options.objects);
|
|
266
|
+
// Store content and type for rendering
|
|
267
|
+
this.content = object.src || object.content || '❄️';
|
|
268
|
+
this.objectType = object.type;
|
|
256
269
|
// Initialize position at top with random x
|
|
257
270
|
this.x = randomRange(-50, this.containerWidth + 50);
|
|
258
271
|
this.y = -50;
|
|
259
272
|
// Initialize velocity (apply speed multiplier for intuitive units)
|
|
260
|
-
// speed=1 → ~40px/s (1cm/s), speed=0.1 → ~4px/s (1mm/s)
|
|
261
273
|
this.vy = randomFromRange(options.speed) * SPEED_MULTIPLIER;
|
|
262
274
|
this.vx = options.wind * SPEED_MULTIPLIER * randomRange(0.5, 1.5);
|
|
263
275
|
// Initialize size and opacity
|
|
@@ -270,8 +282,6 @@ class Particle {
|
|
|
270
282
|
this.animation = randomPick(options.animation);
|
|
271
283
|
// Initialize animation-specific data
|
|
272
284
|
this.initAnimationData();
|
|
273
|
-
// Create DOM element
|
|
274
|
-
this.element = this.createElement(object);
|
|
275
285
|
}
|
|
276
286
|
/**
|
|
277
287
|
* Initialize animation-specific data
|
|
@@ -283,54 +293,6 @@ class Particle {
|
|
|
283
293
|
this.data.wobbleY = randomRange(0.005, 0.015);
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
|
-
/**
|
|
287
|
-
* Create DOM element for the particle
|
|
288
|
-
*/
|
|
289
|
-
createElement(object) {
|
|
290
|
-
var _a, _b, _c, _d;
|
|
291
|
-
const element = document.createElement('div');
|
|
292
|
-
element.className = 'falling-particle';
|
|
293
|
-
element.setAttribute('data-particle-id', String(this.id));
|
|
294
|
-
// Set content based on type
|
|
295
|
-
switch (object.type) {
|
|
296
|
-
case 'emoji':
|
|
297
|
-
element.textContent = (_a = object.content) !== null && _a !== void 0 ? _a : '❄️';
|
|
298
|
-
element.style.fontSize = `${this.size}px`;
|
|
299
|
-
element.style.lineHeight = '1';
|
|
300
|
-
break;
|
|
301
|
-
case 'image':
|
|
302
|
-
const img = document.createElement('img');
|
|
303
|
-
img.src = (_c = (_b = object.src) !== null && _b !== void 0 ? _b : object.content) !== null && _c !== void 0 ? _c : '';
|
|
304
|
-
img.alt = '';
|
|
305
|
-
img.style.width = `${this.size}px`;
|
|
306
|
-
img.style.height = `${this.size}px`;
|
|
307
|
-
img.style.objectFit = 'contain';
|
|
308
|
-
img.draggable = false;
|
|
309
|
-
element.appendChild(img);
|
|
310
|
-
break;
|
|
311
|
-
case 'html':
|
|
312
|
-
element.innerHTML = (_d = object.content) !== null && _d !== void 0 ? _d : '';
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
315
|
-
// Apply base styles
|
|
316
|
-
Object.assign(element.style, {
|
|
317
|
-
position: 'absolute',
|
|
318
|
-
pointerEvents: 'none',
|
|
319
|
-
userSelect: 'none',
|
|
320
|
-
willChange: 'transform, opacity',
|
|
321
|
-
opacity: String(this.opacity),
|
|
322
|
-
left: '0',
|
|
323
|
-
top: '0',
|
|
324
|
-
transform: this.getTransform()
|
|
325
|
-
});
|
|
326
|
-
return element;
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Get CSS transform string
|
|
330
|
-
*/
|
|
331
|
-
getTransform() {
|
|
332
|
-
return `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
|
|
333
|
-
}
|
|
334
296
|
/**
|
|
335
297
|
* Update particle state
|
|
336
298
|
*/
|
|
@@ -339,8 +301,6 @@ class Particle {
|
|
|
339
301
|
// Apply animation
|
|
340
302
|
const animationFn = getAnimation(this.animation);
|
|
341
303
|
animationFn(this, deltaTime, elapsed);
|
|
342
|
-
// Update DOM
|
|
343
|
-
this.element.style.transform = this.getTransform();
|
|
344
304
|
}
|
|
345
305
|
/**
|
|
346
306
|
* Check if particle is out of bounds
|
|
@@ -357,16 +317,200 @@ class Particle {
|
|
|
357
317
|
this.containerWidth = width;
|
|
358
318
|
this.containerHeight = height;
|
|
359
319
|
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* CanvasRenderer - Handles canvas-based rendering for particle effects
|
|
324
|
+
* Provides high-performance drawing of emojis and images
|
|
325
|
+
*/
|
|
326
|
+
class CanvasRenderer {
|
|
327
|
+
constructor(container, zIndex = 9999) {
|
|
328
|
+
this.spriteCache = new Map();
|
|
329
|
+
this.imageCache = new Map();
|
|
330
|
+
this.width = 0;
|
|
331
|
+
this.height = 0;
|
|
332
|
+
if (!isBrowser()) {
|
|
333
|
+
throw new Error('[falling-animation] CanvasRenderer requires a browser environment.');
|
|
334
|
+
}
|
|
335
|
+
// Create canvas element
|
|
336
|
+
this.canvas = document.createElement('canvas');
|
|
337
|
+
this.canvas.className = 'falling-animation-canvas';
|
|
338
|
+
// Get 2D context
|
|
339
|
+
const ctx = this.canvas.getContext('2d');
|
|
340
|
+
if (!ctx) {
|
|
341
|
+
throw new Error('[falling-animation] Could not get 2D context from canvas.');
|
|
342
|
+
}
|
|
343
|
+
this.ctx = ctx;
|
|
344
|
+
// Set canvas styles
|
|
345
|
+
const isBody = container === document.body;
|
|
346
|
+
Object.assign(this.canvas.style, {
|
|
347
|
+
position: isBody ? 'fixed' : 'absolute',
|
|
348
|
+
top: '0',
|
|
349
|
+
left: '0',
|
|
350
|
+
width: '100%',
|
|
351
|
+
height: '100%',
|
|
352
|
+
pointerEvents: 'none',
|
|
353
|
+
zIndex: String(zIndex)
|
|
354
|
+
});
|
|
355
|
+
// Ensure container has position for absolute canvas
|
|
356
|
+
if (!isBody) {
|
|
357
|
+
const containerPosition = getComputedStyle(container).position;
|
|
358
|
+
if (containerPosition === 'static') {
|
|
359
|
+
container.style.position = 'relative';
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Append to container
|
|
363
|
+
container.appendChild(this.canvas);
|
|
364
|
+
// Set initial size
|
|
365
|
+
this.resize();
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Resize canvas to match container
|
|
369
|
+
*/
|
|
370
|
+
resize() {
|
|
371
|
+
var _a;
|
|
372
|
+
const rect = (_a = this.canvas.parentElement) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect();
|
|
373
|
+
if (rect) {
|
|
374
|
+
const dpr = window.devicePixelRatio || 1;
|
|
375
|
+
this.width = rect.width;
|
|
376
|
+
this.height = rect.height;
|
|
377
|
+
this.canvas.width = this.width * dpr;
|
|
378
|
+
this.canvas.height = this.height * dpr;
|
|
379
|
+
this.ctx.scale(dpr, dpr);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Get container dimensions
|
|
384
|
+
*/
|
|
385
|
+
getSize() {
|
|
386
|
+
return { width: this.width, height: this.height };
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Clear the entire canvas
|
|
390
|
+
*/
|
|
391
|
+
clear() {
|
|
392
|
+
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Pre-cache an emoji as a sprite for fast drawing (HiDPI aware)
|
|
396
|
+
*/
|
|
397
|
+
cacheEmoji(content, size) {
|
|
398
|
+
const dpr = window.devicePixelRatio || 1;
|
|
399
|
+
const key = `emoji:${content}:${size}:${dpr}`;
|
|
400
|
+
if (this.spriteCache.has(key)) {
|
|
401
|
+
return this.spriteCache.get(key);
|
|
402
|
+
}
|
|
403
|
+
// Create off-screen canvas for the emoji at HiDPI resolution
|
|
404
|
+
const spriteCanvas = document.createElement('canvas');
|
|
405
|
+
const padding = size * 0.3; // Padding for effects and emoji overflow
|
|
406
|
+
const logicalSize = size + padding * 2;
|
|
407
|
+
const physicalSize = logicalSize * dpr;
|
|
408
|
+
spriteCanvas.width = physicalSize;
|
|
409
|
+
spriteCanvas.height = physicalSize;
|
|
410
|
+
const spriteCtx = spriteCanvas.getContext('2d');
|
|
411
|
+
// Scale for HiDPI
|
|
412
|
+
spriteCtx.scale(dpr, dpr);
|
|
413
|
+
// High quality rendering
|
|
414
|
+
spriteCtx.imageSmoothingEnabled = true;
|
|
415
|
+
spriteCtx.imageSmoothingQuality = 'high';
|
|
416
|
+
// Draw emoji with proper font settings
|
|
417
|
+
const fontSize = size * 1.0; // Emoji font size
|
|
418
|
+
spriteCtx.font = `${fontSize}px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", "Android Emoji", "EmojiOne", sans-serif`;
|
|
419
|
+
spriteCtx.textAlign = 'center';
|
|
420
|
+
spriteCtx.textBaseline = 'middle';
|
|
421
|
+
spriteCtx.fillText(content, logicalSize / 2, logicalSize / 2);
|
|
422
|
+
const sprite = {
|
|
423
|
+
canvas: spriteCanvas,
|
|
424
|
+
width: logicalSize,
|
|
425
|
+
height: logicalSize
|
|
426
|
+
};
|
|
427
|
+
this.spriteCache.set(key, sprite);
|
|
428
|
+
return sprite;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Pre-load an image
|
|
432
|
+
*/
|
|
433
|
+
preloadImage(src) {
|
|
434
|
+
if (this.imageCache.has(src)) {
|
|
435
|
+
return Promise.resolve(this.imageCache.get(src));
|
|
436
|
+
}
|
|
437
|
+
return new Promise((resolve, reject) => {
|
|
438
|
+
const img = new Image();
|
|
439
|
+
img.crossOrigin = 'anonymous';
|
|
440
|
+
img.onload = () => {
|
|
441
|
+
this.imageCache.set(src, img);
|
|
442
|
+
resolve(img);
|
|
443
|
+
};
|
|
444
|
+
img.onerror = reject;
|
|
445
|
+
img.src = src;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Draw a single particle (emoji or image)
|
|
450
|
+
*/
|
|
451
|
+
drawParticle(particle) {
|
|
452
|
+
const { x, y, rotation, size, opacity, content, type } = particle;
|
|
453
|
+
this.ctx.save();
|
|
454
|
+
this.ctx.globalAlpha = opacity;
|
|
455
|
+
this.ctx.translate(x, y);
|
|
456
|
+
this.ctx.rotate((rotation * Math.PI) / 180);
|
|
457
|
+
if (type === 'emoji') {
|
|
458
|
+
const sprite = this.cacheEmoji(content, Math.round(size));
|
|
459
|
+
this.ctx.drawImage(sprite.canvas, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height);
|
|
460
|
+
}
|
|
461
|
+
else if (type === 'image') {
|
|
462
|
+
const img = this.imageCache.get(content);
|
|
463
|
+
if (img) {
|
|
464
|
+
this.ctx.drawImage(img, -size / 2, -size / 2, size, size);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
this.ctx.restore();
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Draw multiple particles efficiently
|
|
471
|
+
*/
|
|
472
|
+
drawParticles(particles) {
|
|
473
|
+
for (const particle of particles) {
|
|
474
|
+
this.drawParticle(particle);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Draw a firework particle (glowing circle)
|
|
479
|
+
*/
|
|
480
|
+
drawFireworkParticle(particle) {
|
|
481
|
+
const { x, y, size, opacity, color } = particle;
|
|
482
|
+
this.ctx.save();
|
|
483
|
+
this.ctx.globalAlpha = opacity;
|
|
484
|
+
// Draw glow
|
|
485
|
+
this.ctx.shadowColor = color;
|
|
486
|
+
this.ctx.shadowBlur = size * 2;
|
|
487
|
+
// Draw particle
|
|
488
|
+
this.ctx.fillStyle = color;
|
|
489
|
+
this.ctx.beginPath();
|
|
490
|
+
this.ctx.arc(x, y, size, 0, Math.PI * 2);
|
|
491
|
+
this.ctx.fill();
|
|
492
|
+
this.ctx.restore();
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Draw multiple firework particles
|
|
496
|
+
*/
|
|
497
|
+
drawFireworkParticles(particles) {
|
|
498
|
+
for (const particle of particles) {
|
|
499
|
+
this.drawFireworkParticle(particle);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
360
502
|
/**
|
|
361
|
-
*
|
|
503
|
+
* Destroy the renderer and remove canvas from DOM
|
|
362
504
|
*/
|
|
363
505
|
destroy() {
|
|
364
|
-
this.
|
|
506
|
+
this.canvas.remove();
|
|
507
|
+
this.spriteCache.clear();
|
|
508
|
+
this.imageCache.clear();
|
|
365
509
|
}
|
|
366
510
|
}
|
|
367
511
|
|
|
368
512
|
/**
|
|
369
|
-
* FallingAnimation - Main class for creating falling object animations
|
|
513
|
+
* FallingAnimation - Main class for creating falling object animations (Canvas-based)
|
|
370
514
|
*/
|
|
371
515
|
/** Default configuration values */
|
|
372
516
|
const DEFAULTS$1 = {
|
|
@@ -384,7 +528,7 @@ const DEFAULTS$1 = {
|
|
|
384
528
|
class FallingAnimation {
|
|
385
529
|
constructor(options) {
|
|
386
530
|
this.particles = [];
|
|
387
|
-
this.
|
|
531
|
+
this.renderer = null;
|
|
388
532
|
this.isRunning = false;
|
|
389
533
|
this.isPaused = false;
|
|
390
534
|
this.animationId = null;
|
|
@@ -396,7 +540,7 @@ class FallingAnimation {
|
|
|
396
540
|
* Main animation loop
|
|
397
541
|
*/
|
|
398
542
|
this.animate = (currentTime) => {
|
|
399
|
-
if (!this.isRunning)
|
|
543
|
+
if (!this.isRunning || !this.renderer)
|
|
400
544
|
return;
|
|
401
545
|
// Calculate delta time
|
|
402
546
|
if (this.lastFrameTime === 0) {
|
|
@@ -422,6 +566,18 @@ class FallingAnimation {
|
|
|
422
566
|
}
|
|
423
567
|
// Remove out-of-bounds particles
|
|
424
568
|
particlesToRemove.forEach(p => this.removeParticle(p));
|
|
569
|
+
// Clear and render all particles
|
|
570
|
+
this.renderer.clear();
|
|
571
|
+
const renderData = this.particles.map(p => ({
|
|
572
|
+
x: p.x,
|
|
573
|
+
y: p.y,
|
|
574
|
+
rotation: p.rotation,
|
|
575
|
+
size: p.size,
|
|
576
|
+
opacity: p.opacity,
|
|
577
|
+
content: p.content,
|
|
578
|
+
type: p.objectType === 'image' ? 'image' : 'emoji'
|
|
579
|
+
}));
|
|
580
|
+
this.renderer.drawParticles(renderData);
|
|
425
581
|
// Continue animation loop
|
|
426
582
|
this.animationId = requestAnimationFrame(this.animate);
|
|
427
583
|
};
|
|
@@ -431,8 +587,10 @@ class FallingAnimation {
|
|
|
431
587
|
}
|
|
432
588
|
// Resolve options with defaults
|
|
433
589
|
this.options = this.resolveOptions(options);
|
|
434
|
-
// Create
|
|
435
|
-
this.
|
|
590
|
+
// Create canvas renderer
|
|
591
|
+
this.renderer = new CanvasRenderer(this.options.container, this.options.zIndex);
|
|
592
|
+
// Preload images if any
|
|
593
|
+
this.preloadImages();
|
|
436
594
|
// Setup resize handler if responsive
|
|
437
595
|
if (this.options.responsive) {
|
|
438
596
|
this.setupResizeHandler();
|
|
@@ -442,6 +600,26 @@ class FallingAnimation {
|
|
|
442
600
|
this.start();
|
|
443
601
|
}
|
|
444
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Preload any image objects
|
|
605
|
+
*/
|
|
606
|
+
async preloadImages() {
|
|
607
|
+
if (!this.renderer)
|
|
608
|
+
return;
|
|
609
|
+
for (const obj of this.options.objects) {
|
|
610
|
+
if (obj.type === 'image') {
|
|
611
|
+
const src = obj.src || obj.content;
|
|
612
|
+
if (src) {
|
|
613
|
+
try {
|
|
614
|
+
await this.renderer.preloadImage(src);
|
|
615
|
+
}
|
|
616
|
+
catch (e) {
|
|
617
|
+
console.warn(`[falling-animation] Failed to preload image: ${src}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
445
623
|
/**
|
|
446
624
|
* Merge user options with defaults
|
|
447
625
|
*/
|
|
@@ -474,42 +652,18 @@ class FallingAnimation {
|
|
|
474
652
|
responsive: (_j = options.responsive) !== null && _j !== void 0 ? _j : DEFAULTS$1.responsive
|
|
475
653
|
};
|
|
476
654
|
}
|
|
477
|
-
/**
|
|
478
|
-
* Create wrapper element for particles
|
|
479
|
-
*/
|
|
480
|
-
createWrapper() {
|
|
481
|
-
this.wrapper = document.createElement('div');
|
|
482
|
-
this.wrapper.className = 'falling-animation-wrapper';
|
|
483
|
-
// Apply styles
|
|
484
|
-
Object.assign(this.wrapper.style, {
|
|
485
|
-
position: this.options.container === document.body ? 'fixed' : 'absolute',
|
|
486
|
-
top: '0',
|
|
487
|
-
left: '0',
|
|
488
|
-
width: '100%',
|
|
489
|
-
height: '100%',
|
|
490
|
-
overflow: 'hidden',
|
|
491
|
-
pointerEvents: 'none',
|
|
492
|
-
zIndex: String(this.options.zIndex)
|
|
493
|
-
});
|
|
494
|
-
// Ensure container has position for absolute wrapper
|
|
495
|
-
if (this.options.container !== document.body) {
|
|
496
|
-
const containerPosition = getComputedStyle(this.options.container).position;
|
|
497
|
-
if (containerPosition === 'static') {
|
|
498
|
-
this.options.container.style.position = 'relative';
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
this.options.container.appendChild(this.wrapper);
|
|
502
|
-
}
|
|
503
655
|
/**
|
|
504
656
|
* Setup resize handler for responsive behavior
|
|
505
657
|
*/
|
|
506
658
|
setupResizeHandler() {
|
|
507
659
|
this.resizeHandler = throttle(() => {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
660
|
+
if (this.renderer) {
|
|
661
|
+
this.renderer.resize();
|
|
662
|
+
const { width, height } = this.renderer.getSize();
|
|
663
|
+
this.particles.forEach(particle => {
|
|
664
|
+
particle.updateContainerSize(width, height);
|
|
665
|
+
});
|
|
666
|
+
}
|
|
513
667
|
}, 200);
|
|
514
668
|
window.addEventListener('resize', this.resizeHandler);
|
|
515
669
|
}
|
|
@@ -520,11 +674,11 @@ class FallingAnimation {
|
|
|
520
674
|
if (this.particles.length >= this.options.maxParticles) {
|
|
521
675
|
return;
|
|
522
676
|
}
|
|
523
|
-
|
|
677
|
+
if (!this.renderer)
|
|
678
|
+
return;
|
|
679
|
+
const { width, height } = this.renderer.getSize();
|
|
680
|
+
const particle = new Particle(this.options, width, height);
|
|
524
681
|
this.particles.push(particle);
|
|
525
|
-
if (this.wrapper) {
|
|
526
|
-
this.wrapper.appendChild(particle.element);
|
|
527
|
-
}
|
|
528
682
|
}
|
|
529
683
|
/**
|
|
530
684
|
* Remove a particle
|
|
@@ -533,7 +687,6 @@ class FallingAnimation {
|
|
|
533
687
|
const index = this.particles.indexOf(particle);
|
|
534
688
|
if (index > -1) {
|
|
535
689
|
this.particles.splice(index, 1);
|
|
536
|
-
particle.destroy();
|
|
537
690
|
}
|
|
538
691
|
}
|
|
539
692
|
/**
|
|
@@ -559,8 +712,11 @@ class FallingAnimation {
|
|
|
559
712
|
this.animationId = null;
|
|
560
713
|
}
|
|
561
714
|
// Clear all particles
|
|
562
|
-
this.particles.forEach(p => p.destroy());
|
|
563
715
|
this.particles = [];
|
|
716
|
+
// Clear canvas
|
|
717
|
+
if (this.renderer) {
|
|
718
|
+
this.renderer.clear();
|
|
719
|
+
}
|
|
564
720
|
}
|
|
565
721
|
/**
|
|
566
722
|
* Pause the animation (keeps particles in place)
|
|
@@ -616,6 +772,7 @@ class FallingAnimation {
|
|
|
616
772
|
}
|
|
617
773
|
if (newOptions.objects) {
|
|
618
774
|
this.options.objects = newOptions.objects;
|
|
775
|
+
this.preloadImages(); // Preload any new images
|
|
619
776
|
}
|
|
620
777
|
}
|
|
621
778
|
/**
|
|
@@ -641,10 +798,10 @@ class FallingAnimation {
|
|
|
641
798
|
*/
|
|
642
799
|
destroy() {
|
|
643
800
|
this.stop();
|
|
644
|
-
//
|
|
645
|
-
if (this.
|
|
646
|
-
this.
|
|
647
|
-
this.
|
|
801
|
+
// Destroy renderer
|
|
802
|
+
if (this.renderer) {
|
|
803
|
+
this.renderer.destroy();
|
|
804
|
+
this.renderer = null;
|
|
648
805
|
}
|
|
649
806
|
// Remove resize handler
|
|
650
807
|
if (this.resizeHandler) {
|
|
@@ -655,14 +812,14 @@ class FallingAnimation {
|
|
|
655
812
|
}
|
|
656
813
|
|
|
657
814
|
/**
|
|
658
|
-
* Fireworks Animation - Creates realistic firework effects
|
|
815
|
+
* Fireworks Animation - Creates realistic firework effects (Canvas-based)
|
|
659
816
|
*
|
|
660
817
|
* Features:
|
|
661
818
|
* - Rockets shooting up from bottom
|
|
662
819
|
* - Explosions spreading particles in all directions (360°)
|
|
663
820
|
* - Gravity effect on particles
|
|
664
821
|
* - Fade out over time
|
|
665
|
-
* -
|
|
822
|
+
* - Glow effects
|
|
666
823
|
*/
|
|
667
824
|
/** Default colors for fireworks */
|
|
668
825
|
const DEFAULT_COLORS = [
|
|
@@ -677,7 +834,6 @@ const DEFAULT_COLORS = [
|
|
|
677
834
|
'#ff69b4', // Pink
|
|
678
835
|
'#ffd700', // Gold
|
|
679
836
|
];
|
|
680
|
-
/** Default configuration */
|
|
681
837
|
const DEFAULTS = {
|
|
682
838
|
colors: DEFAULT_COLORS,
|
|
683
839
|
launchRate: 1,
|
|
@@ -688,14 +844,15 @@ const DEFAULTS = {
|
|
|
688
844
|
particleLifetime: { min: 1000, max: 2000 },
|
|
689
845
|
gravity: 0.1,
|
|
690
846
|
trail: true,
|
|
847
|
+
explosionPattern: 'circular',
|
|
691
848
|
autoStart: true,
|
|
692
849
|
zIndex: 9999
|
|
693
850
|
};
|
|
694
851
|
class Fireworks {
|
|
695
852
|
constructor(options = {}) {
|
|
696
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
853
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
697
854
|
this.particles = [];
|
|
698
|
-
this.
|
|
855
|
+
this.renderer = null;
|
|
699
856
|
this.isRunning = false;
|
|
700
857
|
this.animationId = null;
|
|
701
858
|
this.lastLaunchTime = 0;
|
|
@@ -703,7 +860,7 @@ class Fireworks {
|
|
|
703
860
|
this.resizeHandler = null;
|
|
704
861
|
this.animate = (currentTime) => {
|
|
705
862
|
var _a;
|
|
706
|
-
if (!this.isRunning)
|
|
863
|
+
if (!this.isRunning || !this.renderer)
|
|
707
864
|
return;
|
|
708
865
|
if (this.lastFrameTime === 0) {
|
|
709
866
|
this.lastFrameTime = currentTime;
|
|
@@ -722,49 +879,69 @@ class Fireworks {
|
|
|
722
879
|
for (const particle of this.particles) {
|
|
723
880
|
particle.age += deltaTime;
|
|
724
881
|
if (particle.phase === 'rocket') {
|
|
725
|
-
// Update rocket
|
|
726
882
|
particle.x += particle.vx * deltaTime * 0.1;
|
|
727
883
|
particle.y += particle.vy * deltaTime * 0.1;
|
|
728
|
-
particle.vy += 0.01 * deltaTime;
|
|
729
|
-
// Check if ready to explode (reached target height or max age)
|
|
884
|
+
particle.vy += 0.01 * deltaTime;
|
|
730
885
|
const targetY = (_a = particle.targetY) !== null && _a !== void 0 ? _a : 0;
|
|
731
886
|
if (particle.y <= targetY || particle.age >= particle.maxAge || particle.vy >= 0) {
|
|
732
887
|
particlesToExplode.push(particle);
|
|
733
888
|
}
|
|
734
|
-
// Trail effect - dim the rocket slightly
|
|
735
889
|
particle.opacity = Math.max(0.5, 1 - (particle.age / particle.maxAge) * 0.3);
|
|
736
890
|
}
|
|
737
891
|
else {
|
|
738
|
-
// Update explosion particle
|
|
739
892
|
particle.x += particle.vx * deltaTime * 0.1;
|
|
740
893
|
particle.y += particle.vy * deltaTime * 0.1;
|
|
741
|
-
particle.vy += particle.gravity * deltaTime * 0.01;
|
|
742
|
-
// Fade out
|
|
894
|
+
particle.vy += particle.gravity * deltaTime * 0.01;
|
|
743
895
|
const lifeProgress = particle.age / particle.maxAge;
|
|
744
896
|
particle.opacity = Math.max(0, 1 - lifeProgress);
|
|
745
|
-
//
|
|
746
|
-
particle.
|
|
747
|
-
particle.
|
|
748
|
-
|
|
897
|
+
// Higher drag for small particles (embers effect)
|
|
898
|
+
const drag = particle.size < 2 ? 0.96 : 0.99;
|
|
899
|
+
particle.vx *= drag;
|
|
900
|
+
particle.vy *= drag;
|
|
901
|
+
// Double pattern secondary explosion: trigger at 50% lifetime for stage 1 particles
|
|
902
|
+
if (particle.stage === 1 && lifeProgress >= 0.5 && particle.targetY !== -1) {
|
|
903
|
+
// Mark as already triggered secondary explosion
|
|
904
|
+
particle.targetY = -1;
|
|
905
|
+
particlesToExplode.push(particle);
|
|
906
|
+
}
|
|
749
907
|
if (particle.age >= particle.maxAge) {
|
|
750
908
|
particlesToRemove.push(particle);
|
|
751
909
|
}
|
|
752
910
|
}
|
|
753
|
-
// Update DOM
|
|
754
|
-
particle.element.style.transform = `translate3d(${particle.x}px, ${particle.y}px, 0)`;
|
|
755
|
-
particle.element.style.opacity = String(particle.opacity);
|
|
756
911
|
}
|
|
757
912
|
// Handle explosions
|
|
758
913
|
for (const rocket of particlesToExplode) {
|
|
759
|
-
|
|
914
|
+
if (rocket.stage === 1) {
|
|
915
|
+
// Stage 1 particle: trigger secondary explosion with smaller particles
|
|
916
|
+
this.explodeSecondary(rocket);
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
// Rocket: normal explosion
|
|
920
|
+
this.explode(rocket);
|
|
921
|
+
}
|
|
760
922
|
particlesToRemove.push(rocket);
|
|
761
923
|
}
|
|
762
924
|
// Remove dead particles
|
|
763
925
|
for (const particle of particlesToRemove) {
|
|
764
926
|
this.removeParticle(particle);
|
|
765
927
|
}
|
|
928
|
+
// Render all particles
|
|
929
|
+
this.renderer.clear();
|
|
930
|
+
const renderData = this.particles.map(p => ({
|
|
931
|
+
x: p.x,
|
|
932
|
+
y: p.y,
|
|
933
|
+
size: p.size,
|
|
934
|
+
opacity: p.opacity,
|
|
935
|
+
color: p.color
|
|
936
|
+
}));
|
|
937
|
+
this.renderer.drawFireworkParticles(renderData);
|
|
766
938
|
this.animationId = requestAnimationFrame(this.animate);
|
|
767
939
|
};
|
|
940
|
+
// Check for browser environment
|
|
941
|
+
if (!isBrowser()) {
|
|
942
|
+
throw new Error('[falling-animation] Fireworks requires a browser environment. ' +
|
|
943
|
+
'If using SSR (Next.js, Nuxt, etc.), make sure to only initialize on the client side.');
|
|
944
|
+
}
|
|
768
945
|
// Resolve container
|
|
769
946
|
let container;
|
|
770
947
|
if (!options.container) {
|
|
@@ -787,130 +964,313 @@ class Fireworks {
|
|
|
787
964
|
particleLifetime: (_g = options.particleLifetime) !== null && _g !== void 0 ? _g : DEFAULTS.particleLifetime,
|
|
788
965
|
gravity: (_h = options.gravity) !== null && _h !== void 0 ? _h : DEFAULTS.gravity,
|
|
789
966
|
trail: (_j = options.trail) !== null && _j !== void 0 ? _j : DEFAULTS.trail,
|
|
790
|
-
|
|
791
|
-
|
|
967
|
+
explosionPattern: (_k = options.explosionPattern) !== null && _k !== void 0 ? _k : DEFAULTS.explosionPattern,
|
|
968
|
+
autoStart: (_l = options.autoStart) !== null && _l !== void 0 ? _l : DEFAULTS.autoStart,
|
|
969
|
+
zIndex: (_m = options.zIndex) !== null && _m !== void 0 ? _m : DEFAULTS.zIndex
|
|
792
970
|
};
|
|
793
|
-
|
|
971
|
+
// Create canvas renderer
|
|
972
|
+
this.renderer = new CanvasRenderer(container, this.options.zIndex);
|
|
794
973
|
this.setupResizeHandler();
|
|
795
974
|
if (this.options.autoStart) {
|
|
796
975
|
this.start();
|
|
797
976
|
}
|
|
798
977
|
}
|
|
799
|
-
createWrapper() {
|
|
800
|
-
this.wrapper = document.createElement('div');
|
|
801
|
-
this.wrapper.className = 'fireworks-wrapper';
|
|
802
|
-
Object.assign(this.wrapper.style, {
|
|
803
|
-
position: this.options.container === document.body ? 'fixed' : 'absolute',
|
|
804
|
-
top: '0',
|
|
805
|
-
left: '0',
|
|
806
|
-
width: '100%',
|
|
807
|
-
height: '100%',
|
|
808
|
-
overflow: 'hidden',
|
|
809
|
-
pointerEvents: 'none',
|
|
810
|
-
zIndex: String(this.options.zIndex)
|
|
811
|
-
});
|
|
812
|
-
if (this.options.container !== document.body) {
|
|
813
|
-
const containerPosition = getComputedStyle(this.options.container).position;
|
|
814
|
-
if (containerPosition === 'static') {
|
|
815
|
-
this.options.container.style.position = 'relative';
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
this.options.container.appendChild(this.wrapper);
|
|
819
|
-
}
|
|
820
978
|
setupResizeHandler() {
|
|
821
979
|
this.resizeHandler = throttle(() => {
|
|
822
|
-
|
|
980
|
+
if (this.renderer) {
|
|
981
|
+
this.renderer.resize();
|
|
982
|
+
}
|
|
823
983
|
}, 200);
|
|
824
984
|
window.addEventListener('resize', this.resizeHandler);
|
|
825
985
|
}
|
|
826
986
|
getContainerSize() {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
};
|
|
831
|
-
}
|
|
832
|
-
createParticleElement(particle) {
|
|
833
|
-
const element = document.createElement('div');
|
|
834
|
-
element.className = 'firework-particle';
|
|
835
|
-
Object.assign(element.style, {
|
|
836
|
-
position: 'absolute',
|
|
837
|
-
left: '0',
|
|
838
|
-
top: '0',
|
|
839
|
-
width: `${particle.size}px`,
|
|
840
|
-
height: `${particle.size}px`,
|
|
841
|
-
backgroundColor: particle.color,
|
|
842
|
-
borderRadius: '50%',
|
|
843
|
-
boxShadow: `0 0 ${particle.size * 2}px ${particle.color}`,
|
|
844
|
-
opacity: String(particle.opacity),
|
|
845
|
-
pointerEvents: 'none',
|
|
846
|
-
willChange: 'transform, opacity',
|
|
847
|
-
transform: `translate3d(${particle.x}px, ${particle.y}px, 0)`
|
|
848
|
-
});
|
|
849
|
-
return element;
|
|
987
|
+
if (this.renderer) {
|
|
988
|
+
return this.renderer.getSize();
|
|
989
|
+
}
|
|
990
|
+
return { width: 0, height: 0 };
|
|
850
991
|
}
|
|
851
992
|
launchRocket() {
|
|
852
993
|
const { width, height } = this.getContainerSize();
|
|
994
|
+
if (width === 0 || height === 0)
|
|
995
|
+
return;
|
|
853
996
|
const color = this.options.colors[Math.floor(Math.random() * this.options.colors.length)];
|
|
854
997
|
// Random explosion height: 10% to 90% from top of screen
|
|
855
998
|
const targetY = randomRange(height * 0.1, height * 0.9);
|
|
856
999
|
const distanceToTravel = height - targetY;
|
|
857
1000
|
// Calculate time based on distance and speed
|
|
858
1001
|
const rocketSpeed = randomFromRange(this.options.rocketSpeed);
|
|
859
|
-
const travelTime = distanceToTravel / (rocketSpeed * 0.1);
|
|
1002
|
+
const travelTime = distanceToTravel / (rocketSpeed * 0.1);
|
|
860
1003
|
const particle = {
|
|
861
1004
|
id: generateId(),
|
|
862
|
-
element: null,
|
|
863
1005
|
x: randomRange(width * 0.1, width * 0.9),
|
|
864
|
-
y: height + 10,
|
|
1006
|
+
y: height + 10,
|
|
865
1007
|
vx: randomRange(-0.5, 0.5),
|
|
866
1008
|
vy: -rocketSpeed,
|
|
867
1009
|
size: 4,
|
|
868
1010
|
opacity: 1,
|
|
869
1011
|
color,
|
|
870
1012
|
age: 0,
|
|
871
|
-
maxAge: Math.min(travelTime, 3000),
|
|
1013
|
+
maxAge: Math.min(travelTime, 3000),
|
|
872
1014
|
phase: 'rocket',
|
|
873
1015
|
gravity: 0,
|
|
874
|
-
targetY
|
|
1016
|
+
targetY,
|
|
1017
|
+
stage: 0
|
|
875
1018
|
};
|
|
876
|
-
particle.element = this.createParticleElement(particle);
|
|
877
1019
|
this.particles.push(particle);
|
|
878
|
-
if (this.wrapper) {
|
|
879
|
-
this.wrapper.appendChild(particle.element);
|
|
880
|
-
}
|
|
881
1020
|
}
|
|
882
1021
|
explode(rocket) {
|
|
883
1022
|
const particleCount = this.options.particlesPerExplosion;
|
|
884
1023
|
const color = rocket.color;
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1024
|
+
// Get pattern (resolve 'random' or array to single pattern)
|
|
1025
|
+
let pattern;
|
|
1026
|
+
const patternOption = this.options.explosionPattern;
|
|
1027
|
+
if (Array.isArray(patternOption)) {
|
|
1028
|
+
// Pick random from array
|
|
1029
|
+
pattern = patternOption[Math.floor(Math.random() * patternOption.length)];
|
|
1030
|
+
}
|
|
1031
|
+
else if (patternOption === 'random') {
|
|
1032
|
+
const patterns = ['circular', 'ring', 'heart', 'star', 'willow', 'palm', 'chrysanthemum', 'embers', 'double'];
|
|
1033
|
+
pattern = patterns[Math.floor(Math.random() * patterns.length)];
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
pattern = patternOption;
|
|
1037
|
+
}
|
|
1038
|
+
// Generate particles based on pattern
|
|
1039
|
+
const particles = this.generatePatternParticles(pattern, rocket, particleCount, color);
|
|
1040
|
+
for (const particle of particles) {
|
|
1041
|
+
this.particles.push(particle);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
generatePatternParticles(pattern, rocket, count, color) {
|
|
1045
|
+
switch (pattern) {
|
|
1046
|
+
case 'ring':
|
|
1047
|
+
return this.createRingExplosion(rocket, count, color);
|
|
1048
|
+
case 'heart':
|
|
1049
|
+
return this.createHeartExplosion(rocket, count, color);
|
|
1050
|
+
case 'star':
|
|
1051
|
+
return this.createStarExplosion(rocket, count, color);
|
|
1052
|
+
case 'willow':
|
|
1053
|
+
return this.createWillowExplosion(rocket, count, color);
|
|
1054
|
+
case 'palm':
|
|
1055
|
+
return this.createPalmExplosion(rocket, count, color);
|
|
1056
|
+
case 'chrysanthemum':
|
|
1057
|
+
return this.createChrysanthemumExplosion(rocket, count, color);
|
|
1058
|
+
case 'embers':
|
|
1059
|
+
return this.createEmbersExplosion(rocket, count, color);
|
|
1060
|
+
case 'double':
|
|
1061
|
+
return this.createDoubleExplosion(rocket, count, color);
|
|
1062
|
+
case 'circular':
|
|
1063
|
+
default:
|
|
1064
|
+
return this.createCircularExplosion(rocket, count, color);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
/** Standard circular explosion - even spread */
|
|
1068
|
+
createCircularExplosion(rocket, count, color) {
|
|
1069
|
+
const particles = [];
|
|
1070
|
+
for (let i = 0; i < count; i++) {
|
|
1071
|
+
const angle = (i / count) * Math.PI * 2 + randomRange(-0.1, 0.1);
|
|
889
1072
|
const speed = randomFromRange(this.options.explosionSpeed);
|
|
1073
|
+
particles.push(this.createExplosionParticle(rocket, color, angle, speed));
|
|
1074
|
+
}
|
|
1075
|
+
return particles;
|
|
1076
|
+
}
|
|
1077
|
+
/** Ring/donut shape - particles at similar radius */
|
|
1078
|
+
createRingExplosion(rocket, count, color) {
|
|
1079
|
+
const particles = [];
|
|
1080
|
+
const baseSpeed = (this.options.explosionSpeed.min + this.options.explosionSpeed.max) / 2;
|
|
1081
|
+
for (let i = 0; i < count; i++) {
|
|
1082
|
+
const angle = (i / count) * Math.PI * 2;
|
|
1083
|
+
const speed = baseSpeed + randomRange(-0.5, 0.5);
|
|
1084
|
+
particles.push(this.createExplosionParticle(rocket, color, angle, speed, 0.05)); // Low gravity
|
|
1085
|
+
}
|
|
1086
|
+
return particles;
|
|
1087
|
+
}
|
|
1088
|
+
/** Heart shape explosion */
|
|
1089
|
+
createHeartExplosion(rocket, count, color) {
|
|
1090
|
+
const particles = [];
|
|
1091
|
+
for (let i = 0; i < count; i++) {
|
|
1092
|
+
const t = (i / count) * Math.PI * 2;
|
|
1093
|
+
// Heart parametric equation
|
|
1094
|
+
const heartX = 16 * Math.pow(Math.sin(t), 3);
|
|
1095
|
+
const heartY = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
|
|
1096
|
+
const angle = Math.atan2(heartY, heartX);
|
|
1097
|
+
const speed = Math.sqrt(heartX * heartX + heartY * heartY) / 16 * randomFromRange(this.options.explosionSpeed);
|
|
1098
|
+
particles.push(this.createExplosionParticle(rocket, color, angle, speed * 0.8, 0.08));
|
|
1099
|
+
}
|
|
1100
|
+
return particles;
|
|
1101
|
+
}
|
|
1102
|
+
/** Star burst - concentrated beams */
|
|
1103
|
+
createStarExplosion(rocket, count, color) {
|
|
1104
|
+
const particles = [];
|
|
1105
|
+
const points = 5; // 5-pointed star
|
|
1106
|
+
for (let i = 0; i < count; i++) {
|
|
1107
|
+
const starAngle = (Math.floor(i / (count / points)) / points) * Math.PI * 2;
|
|
1108
|
+
const spreadAngle = starAngle + randomRange(-0.15, 0.15);
|
|
1109
|
+
const speed = randomFromRange(this.options.explosionSpeed) * (0.8 + Math.random() * 0.4);
|
|
1110
|
+
particles.push(this.createExplosionParticle(rocket, color, spreadAngle, speed));
|
|
1111
|
+
}
|
|
1112
|
+
return particles;
|
|
1113
|
+
}
|
|
1114
|
+
/** Willow - heavy gravity, long trails */
|
|
1115
|
+
createWillowExplosion(rocket, count, color) {
|
|
1116
|
+
const particles = [];
|
|
1117
|
+
for (let i = 0; i < count; i++) {
|
|
1118
|
+
const angle = (i / count) * Math.PI * 2 + randomRange(-0.1, 0.1);
|
|
1119
|
+
const speed = randomFromRange(this.options.explosionSpeed) * 0.6;
|
|
1120
|
+
const p = this.createExplosionParticle(rocket, '#ffd700', angle, speed, 0.25); // Gold, high gravity
|
|
1121
|
+
p.maxAge *= 1.5; // Longer life
|
|
1122
|
+
particles.push(p);
|
|
1123
|
+
}
|
|
1124
|
+
return particles;
|
|
1125
|
+
}
|
|
1126
|
+
/** Palm tree - upward bias */
|
|
1127
|
+
createPalmExplosion(rocket, count, color) {
|
|
1128
|
+
const particles = [];
|
|
1129
|
+
for (let i = 0; i < count; i++) {
|
|
1130
|
+
// Bias upward (angles from -60 to 240 degrees, avoiding straight down)
|
|
1131
|
+
const angle = randomRange(-Math.PI * 0.8, Math.PI * 0.8) - Math.PI / 2;
|
|
1132
|
+
const speed = randomFromRange(this.options.explosionSpeed) * (0.7 + Math.random() * 0.6);
|
|
1133
|
+
const p = this.createExplosionParticle(rocket, color, angle, speed, 0.18);
|
|
1134
|
+
p.maxAge *= 1.2;
|
|
1135
|
+
particles.push(p);
|
|
1136
|
+
}
|
|
1137
|
+
return particles;
|
|
1138
|
+
}
|
|
1139
|
+
/** Chrysanthemum - dense spherical burst */
|
|
1140
|
+
createChrysanthemumExplosion(rocket, count, color) {
|
|
1141
|
+
const particles = [];
|
|
1142
|
+
const layers = 3;
|
|
1143
|
+
for (let layer = 0; layer < layers; layer++) {
|
|
1144
|
+
const layerCount = Math.floor(count / layers);
|
|
1145
|
+
const baseSpeed = this.options.explosionSpeed.min +
|
|
1146
|
+
(this.options.explosionSpeed.max - this.options.explosionSpeed.min) * (layer / layers);
|
|
1147
|
+
for (let i = 0; i < layerCount; i++) {
|
|
1148
|
+
const angle = (i / layerCount) * Math.PI * 2 + (layer * 0.3);
|
|
1149
|
+
const speed = baseSpeed + randomRange(-0.5, 0.5);
|
|
1150
|
+
particles.push(this.createExplosionParticle(rocket, color, angle, speed, 0.08));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return particles;
|
|
1154
|
+
}
|
|
1155
|
+
/** Embers - Slow-falling micro particles (Tàn Lửa Trôi Nhẹ)
|
|
1156
|
+
* Very small particles (0.5-1.5px) with low gravity and high drag
|
|
1157
|
+
*/
|
|
1158
|
+
createEmbersExplosion(rocket, count, color) {
|
|
1159
|
+
const particles = [];
|
|
1160
|
+
// More particles for embers effect (1.5x normal count)
|
|
1161
|
+
const emberCount = Math.floor(count * 1.5);
|
|
1162
|
+
for (let i = 0; i < emberCount; i++) {
|
|
1163
|
+
const angle = (i / emberCount) * Math.PI * 2 + randomRange(-0.3, 0.3);
|
|
1164
|
+
// Very slow initial speed
|
|
1165
|
+
const speed = randomFromRange(this.options.explosionSpeed) * 0.3;
|
|
1166
|
+
const particle = {
|
|
1167
|
+
id: generateId(),
|
|
1168
|
+
x: rocket.x + randomRange(-5, 5), // Slight position randomness
|
|
1169
|
+
y: rocket.y + randomRange(-5, 5),
|
|
1170
|
+
vx: Math.cos(angle) * speed,
|
|
1171
|
+
vy: Math.sin(angle) * speed,
|
|
1172
|
+
// Very small particles (0.5 - 1.5 px)
|
|
1173
|
+
size: randomRange(0.5, 1.5),
|
|
1174
|
+
opacity: 1,
|
|
1175
|
+
// Warm ember colors
|
|
1176
|
+
color: this.getEmberColor(color),
|
|
1177
|
+
age: 0,
|
|
1178
|
+
// Long lifetime for slow fade
|
|
1179
|
+
maxAge: randomFromRange(this.options.particleLifetime) * 2,
|
|
1180
|
+
phase: 'explosion',
|
|
1181
|
+
// Very low gravity - particles float
|
|
1182
|
+
gravity: 0.02,
|
|
1183
|
+
stage: rocket.stage + 1
|
|
1184
|
+
};
|
|
1185
|
+
particles.push(particle);
|
|
1186
|
+
}
|
|
1187
|
+
return particles;
|
|
1188
|
+
}
|
|
1189
|
+
/** Double explosion - first explosion creates particles that explode again
|
|
1190
|
+
* Secondary particles are 2-3x smaller than primary
|
|
1191
|
+
*/
|
|
1192
|
+
createDoubleExplosion(rocket, count, color) {
|
|
1193
|
+
const particles = [];
|
|
1194
|
+
// First explosion: create fewer, larger particles that will explode again
|
|
1195
|
+
const primaryCount = Math.floor(count * 0.6);
|
|
1196
|
+
for (let i = 0; i < primaryCount; i++) {
|
|
1197
|
+
const angle = (i / primaryCount) * Math.PI * 2 + randomRange(-0.1, 0.1);
|
|
1198
|
+
const speed = randomFromRange(this.options.explosionSpeed) * 0.8;
|
|
890
1199
|
const particle = {
|
|
891
1200
|
id: generateId(),
|
|
892
|
-
element: null,
|
|
893
1201
|
x: rocket.x,
|
|
894
1202
|
y: rocket.y,
|
|
895
1203
|
vx: Math.cos(angle) * speed,
|
|
896
1204
|
vy: Math.sin(angle) * speed,
|
|
1205
|
+
// Normal size for primary particles
|
|
897
1206
|
size: randomFromRange(this.options.particleSize),
|
|
898
1207
|
opacity: 1,
|
|
899
1208
|
color: this.getVariantColor(color),
|
|
900
1209
|
age: 0,
|
|
901
1210
|
maxAge: randomFromRange(this.options.particleLifetime),
|
|
902
1211
|
phase: 'explosion',
|
|
903
|
-
gravity: this.options.gravity
|
|
1212
|
+
gravity: this.options.gravity,
|
|
1213
|
+
stage: 1 // Mark as stage 1 - will trigger secondary explosion
|
|
1214
|
+
};
|
|
1215
|
+
particles.push(particle);
|
|
1216
|
+
}
|
|
1217
|
+
return particles;
|
|
1218
|
+
}
|
|
1219
|
+
/** Create secondary explosion particles (called when stage 1 particles reach 50% lifetime)
|
|
1220
|
+
* These particles are 2-3x smaller than the originals
|
|
1221
|
+
*/
|
|
1222
|
+
explodeSecondary(parent) {
|
|
1223
|
+
const secondaryCount = Math.floor(this.options.particlesPerExplosion * 0.3); // 30% of normal
|
|
1224
|
+
for (let i = 0; i < secondaryCount; i++) {
|
|
1225
|
+
const angle = (i / secondaryCount) * Math.PI * 2 + randomRange(-0.2, 0.2);
|
|
1226
|
+
const speed = randomFromRange(this.options.explosionSpeed) * 0.5;
|
|
1227
|
+
const particle = {
|
|
1228
|
+
id: generateId(),
|
|
1229
|
+
x: parent.x,
|
|
1230
|
+
y: parent.y,
|
|
1231
|
+
vx: Math.cos(angle) * speed,
|
|
1232
|
+
vy: Math.sin(angle) * speed,
|
|
1233
|
+
// Secondary particles are 2-3x smaller!
|
|
1234
|
+
size: Math.max(1, randomFromRange(this.options.particleSize) / randomRange(2, 3)),
|
|
1235
|
+
opacity: 0.9,
|
|
1236
|
+
color: this.getVariantColor(parent.color),
|
|
1237
|
+
age: 0,
|
|
1238
|
+
maxAge: randomFromRange(this.options.particleLifetime) * 0.7, // Shorter lifetime
|
|
1239
|
+
phase: 'explosion',
|
|
1240
|
+
gravity: this.options.gravity * 0.5, // Less gravity
|
|
1241
|
+
stage: 2 // Stage 2 - no more explosions
|
|
904
1242
|
};
|
|
905
|
-
particle.element = this.createParticleElement(particle);
|
|
906
1243
|
this.particles.push(particle);
|
|
907
|
-
if (this.wrapper) {
|
|
908
|
-
this.wrapper.appendChild(particle.element);
|
|
909
|
-
}
|
|
910
1244
|
}
|
|
911
1245
|
}
|
|
1246
|
+
/** Get warm ember color variant */
|
|
1247
|
+
getEmberColor(baseColor) {
|
|
1248
|
+
const emberColors = ['#ffaa00', '#ff6600', '#ff4400', '#ffcc00', '#ff8800', '#ffffff'];
|
|
1249
|
+
if (Math.random() < 0.7) {
|
|
1250
|
+
// 70% chance of ember colors
|
|
1251
|
+
return emberColors[Math.floor(Math.random() * emberColors.length)];
|
|
1252
|
+
}
|
|
1253
|
+
return baseColor;
|
|
1254
|
+
}
|
|
1255
|
+
/** Helper to create a single explosion particle */
|
|
1256
|
+
createExplosionParticle(rocket, color, angle, speed, gravity) {
|
|
1257
|
+
return {
|
|
1258
|
+
id: generateId(),
|
|
1259
|
+
x: rocket.x,
|
|
1260
|
+
y: rocket.y,
|
|
1261
|
+
vx: Math.cos(angle) * speed,
|
|
1262
|
+
vy: Math.sin(angle) * speed,
|
|
1263
|
+
size: randomFromRange(this.options.particleSize),
|
|
1264
|
+
opacity: 1,
|
|
1265
|
+
color: this.getVariantColor(color),
|
|
1266
|
+
age: 0,
|
|
1267
|
+
maxAge: randomFromRange(this.options.particleLifetime),
|
|
1268
|
+
phase: 'explosion',
|
|
1269
|
+
gravity: gravity !== null && gravity !== void 0 ? gravity : this.options.gravity,
|
|
1270
|
+
stage: rocket.stage + 1
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
912
1273
|
getVariantColor(baseColor) {
|
|
913
|
-
// Sometimes return white or a lighter variant for sparkle effect
|
|
914
1274
|
if (Math.random() < 0.2) {
|
|
915
1275
|
return '#ffffff';
|
|
916
1276
|
}
|
|
@@ -923,7 +1283,6 @@ class Fireworks {
|
|
|
923
1283
|
const index = this.particles.indexOf(particle);
|
|
924
1284
|
if (index > -1) {
|
|
925
1285
|
this.particles.splice(index, 1);
|
|
926
|
-
particle.element.remove();
|
|
927
1286
|
}
|
|
928
1287
|
}
|
|
929
1288
|
/** Start the fireworks */
|
|
@@ -945,10 +1304,10 @@ class Fireworks {
|
|
|
945
1304
|
}
|
|
946
1305
|
/** Clear all particles but keep running */
|
|
947
1306
|
clear() {
|
|
948
|
-
for (const particle of this.particles) {
|
|
949
|
-
particle.element.remove();
|
|
950
|
-
}
|
|
951
1307
|
this.particles = [];
|
|
1308
|
+
if (this.renderer) {
|
|
1309
|
+
this.renderer.clear();
|
|
1310
|
+
}
|
|
952
1311
|
}
|
|
953
1312
|
/** Launch a single firework manually */
|
|
954
1313
|
launch() {
|
|
@@ -978,6 +1337,8 @@ class Fireworks {
|
|
|
978
1337
|
this.options.particleLifetime = newOptions.particleLifetime;
|
|
979
1338
|
if (newOptions.gravity !== undefined)
|
|
980
1339
|
this.options.gravity = newOptions.gravity;
|
|
1340
|
+
if (newOptions.explosionPattern)
|
|
1341
|
+
this.options.explosionPattern = newOptions.explosionPattern;
|
|
981
1342
|
}
|
|
982
1343
|
/** Get particle count */
|
|
983
1344
|
getParticleCount() {
|
|
@@ -991,9 +1352,9 @@ class Fireworks {
|
|
|
991
1352
|
destroy() {
|
|
992
1353
|
this.stop();
|
|
993
1354
|
this.clear();
|
|
994
|
-
if (this.
|
|
995
|
-
this.
|
|
996
|
-
this.
|
|
1355
|
+
if (this.renderer) {
|
|
1356
|
+
this.renderer.destroy();
|
|
1357
|
+
this.renderer = null;
|
|
997
1358
|
}
|
|
998
1359
|
if (this.resizeHandler) {
|
|
999
1360
|
window.removeEventListener('resize', this.resizeHandler);
|
|
@@ -1016,4 +1377,5 @@ exports.Fireworks = Fireworks;
|
|
|
1016
1377
|
exports.animations = animations;
|
|
1017
1378
|
exports.default = FallingAnimation;
|
|
1018
1379
|
exports.getAnimation = getAnimation;
|
|
1380
|
+
exports.isBrowser = isBrowser;
|
|
1019
1381
|
//# sourceMappingURL=falling-animation.cjs.map
|