falling-animation 0.1.2 → 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 +548 -203
- package/dist/falling-animation.cjs.map +1 -1
- package/dist/falling-animation.esm.js +548 -203
- package/dist/falling-animation.esm.js.map +1 -1
- package/dist/falling-animation.umd.js +548 -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 +1 -1
- package/dist/types/types.d.ts +4 -2
- package/package.json +1 -1
|
@@ -243,7 +243,7 @@ function getAnimation(name) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
/**
|
|
246
|
-
* Particle class - manages individual falling objects
|
|
246
|
+
* Particle class - manages individual falling objects (Canvas-based, no DOM)
|
|
247
247
|
*/
|
|
248
248
|
/**
|
|
249
249
|
* Speed multiplier to convert user-friendly speed units to px/ms
|
|
@@ -253,22 +253,23 @@ function getAnimation(name) {
|
|
|
253
253
|
*/
|
|
254
254
|
const SPEED_MULTIPLIER = 0.04; // 40 px/s per unit, divided by 1000 for ms
|
|
255
255
|
class Particle {
|
|
256
|
-
constructor(options) {
|
|
257
|
-
this.options = options;
|
|
256
|
+
constructor(options, containerWidth, containerHeight) {
|
|
258
257
|
this.id = generateId();
|
|
259
258
|
this.age = 0;
|
|
260
259
|
this.phase = Math.random() * Math.PI * 2;
|
|
261
260
|
this.data = {};
|
|
262
|
-
//
|
|
263
|
-
this.containerWidth =
|
|
264
|
-
this.containerHeight =
|
|
261
|
+
// Store container dimensions
|
|
262
|
+
this.containerWidth = containerWidth;
|
|
263
|
+
this.containerHeight = containerHeight;
|
|
265
264
|
// Pick random object
|
|
266
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;
|
|
267
269
|
// Initialize position at top with random x
|
|
268
270
|
this.x = randomRange(-50, this.containerWidth + 50);
|
|
269
271
|
this.y = -50;
|
|
270
272
|
// Initialize velocity (apply speed multiplier for intuitive units)
|
|
271
|
-
// speed=1 → ~40px/s (1cm/s), speed=0.1 → ~4px/s (1mm/s)
|
|
272
273
|
this.vy = randomFromRange(options.speed) * SPEED_MULTIPLIER;
|
|
273
274
|
this.vx = options.wind * SPEED_MULTIPLIER * randomRange(0.5, 1.5);
|
|
274
275
|
// Initialize size and opacity
|
|
@@ -281,8 +282,6 @@ class Particle {
|
|
|
281
282
|
this.animation = randomPick(options.animation);
|
|
282
283
|
// Initialize animation-specific data
|
|
283
284
|
this.initAnimationData();
|
|
284
|
-
// Create DOM element
|
|
285
|
-
this.element = this.createElement(object);
|
|
286
285
|
}
|
|
287
286
|
/**
|
|
288
287
|
* Initialize animation-specific data
|
|
@@ -294,54 +293,6 @@ class Particle {
|
|
|
294
293
|
this.data.wobbleY = randomRange(0.005, 0.015);
|
|
295
294
|
}
|
|
296
295
|
}
|
|
297
|
-
/**
|
|
298
|
-
* Create DOM element for the particle
|
|
299
|
-
*/
|
|
300
|
-
createElement(object) {
|
|
301
|
-
var _a, _b, _c, _d;
|
|
302
|
-
const element = document.createElement('div');
|
|
303
|
-
element.className = 'falling-particle';
|
|
304
|
-
element.setAttribute('data-particle-id', String(this.id));
|
|
305
|
-
// Set content based on type
|
|
306
|
-
switch (object.type) {
|
|
307
|
-
case 'emoji':
|
|
308
|
-
element.textContent = (_a = object.content) !== null && _a !== void 0 ? _a : '❄️';
|
|
309
|
-
element.style.fontSize = `${this.size}px`;
|
|
310
|
-
element.style.lineHeight = '1';
|
|
311
|
-
break;
|
|
312
|
-
case 'image':
|
|
313
|
-
const img = document.createElement('img');
|
|
314
|
-
img.src = (_c = (_b = object.src) !== null && _b !== void 0 ? _b : object.content) !== null && _c !== void 0 ? _c : '';
|
|
315
|
-
img.alt = '';
|
|
316
|
-
img.style.width = `${this.size}px`;
|
|
317
|
-
img.style.height = `${this.size}px`;
|
|
318
|
-
img.style.objectFit = 'contain';
|
|
319
|
-
img.draggable = false;
|
|
320
|
-
element.appendChild(img);
|
|
321
|
-
break;
|
|
322
|
-
case 'html':
|
|
323
|
-
element.innerHTML = (_d = object.content) !== null && _d !== void 0 ? _d : '';
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
// Apply base styles
|
|
327
|
-
Object.assign(element.style, {
|
|
328
|
-
position: 'absolute',
|
|
329
|
-
pointerEvents: 'none',
|
|
330
|
-
userSelect: 'none',
|
|
331
|
-
willChange: 'transform, opacity',
|
|
332
|
-
opacity: String(this.opacity),
|
|
333
|
-
left: '0',
|
|
334
|
-
top: '0',
|
|
335
|
-
transform: this.getTransform()
|
|
336
|
-
});
|
|
337
|
-
return element;
|
|
338
|
-
}
|
|
339
|
-
/**
|
|
340
|
-
* Get CSS transform string
|
|
341
|
-
*/
|
|
342
|
-
getTransform() {
|
|
343
|
-
return `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
|
|
344
|
-
}
|
|
345
296
|
/**
|
|
346
297
|
* Update particle state
|
|
347
298
|
*/
|
|
@@ -350,8 +301,6 @@ class Particle {
|
|
|
350
301
|
// Apply animation
|
|
351
302
|
const animationFn = getAnimation(this.animation);
|
|
352
303
|
animationFn(this, deltaTime, elapsed);
|
|
353
|
-
// Update DOM
|
|
354
|
-
this.element.style.transform = this.getTransform();
|
|
355
304
|
}
|
|
356
305
|
/**
|
|
357
306
|
* Check if particle is out of bounds
|
|
@@ -368,16 +317,200 @@ class Particle {
|
|
|
368
317
|
this.containerWidth = width;
|
|
369
318
|
this.containerHeight = height;
|
|
370
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
|
+
}
|
|
371
469
|
/**
|
|
372
|
-
*
|
|
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
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Destroy the renderer and remove canvas from DOM
|
|
373
504
|
*/
|
|
374
505
|
destroy() {
|
|
375
|
-
this.
|
|
506
|
+
this.canvas.remove();
|
|
507
|
+
this.spriteCache.clear();
|
|
508
|
+
this.imageCache.clear();
|
|
376
509
|
}
|
|
377
510
|
}
|
|
378
511
|
|
|
379
512
|
/**
|
|
380
|
-
* FallingAnimation - Main class for creating falling object animations
|
|
513
|
+
* FallingAnimation - Main class for creating falling object animations (Canvas-based)
|
|
381
514
|
*/
|
|
382
515
|
/** Default configuration values */
|
|
383
516
|
const DEFAULTS$1 = {
|
|
@@ -395,7 +528,7 @@ const DEFAULTS$1 = {
|
|
|
395
528
|
class FallingAnimation {
|
|
396
529
|
constructor(options) {
|
|
397
530
|
this.particles = [];
|
|
398
|
-
this.
|
|
531
|
+
this.renderer = null;
|
|
399
532
|
this.isRunning = false;
|
|
400
533
|
this.isPaused = false;
|
|
401
534
|
this.animationId = null;
|
|
@@ -407,7 +540,7 @@ class FallingAnimation {
|
|
|
407
540
|
* Main animation loop
|
|
408
541
|
*/
|
|
409
542
|
this.animate = (currentTime) => {
|
|
410
|
-
if (!this.isRunning)
|
|
543
|
+
if (!this.isRunning || !this.renderer)
|
|
411
544
|
return;
|
|
412
545
|
// Calculate delta time
|
|
413
546
|
if (this.lastFrameTime === 0) {
|
|
@@ -433,6 +566,18 @@ class FallingAnimation {
|
|
|
433
566
|
}
|
|
434
567
|
// Remove out-of-bounds particles
|
|
435
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);
|
|
436
581
|
// Continue animation loop
|
|
437
582
|
this.animationId = requestAnimationFrame(this.animate);
|
|
438
583
|
};
|
|
@@ -442,8 +587,10 @@ class FallingAnimation {
|
|
|
442
587
|
}
|
|
443
588
|
// Resolve options with defaults
|
|
444
589
|
this.options = this.resolveOptions(options);
|
|
445
|
-
// Create
|
|
446
|
-
this.
|
|
590
|
+
// Create canvas renderer
|
|
591
|
+
this.renderer = new CanvasRenderer(this.options.container, this.options.zIndex);
|
|
592
|
+
// Preload images if any
|
|
593
|
+
this.preloadImages();
|
|
447
594
|
// Setup resize handler if responsive
|
|
448
595
|
if (this.options.responsive) {
|
|
449
596
|
this.setupResizeHandler();
|
|
@@ -453,6 +600,26 @@ class FallingAnimation {
|
|
|
453
600
|
this.start();
|
|
454
601
|
}
|
|
455
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
|
+
}
|
|
456
623
|
/**
|
|
457
624
|
* Merge user options with defaults
|
|
458
625
|
*/
|
|
@@ -485,42 +652,18 @@ class FallingAnimation {
|
|
|
485
652
|
responsive: (_j = options.responsive) !== null && _j !== void 0 ? _j : DEFAULTS$1.responsive
|
|
486
653
|
};
|
|
487
654
|
}
|
|
488
|
-
/**
|
|
489
|
-
* Create wrapper element for particles
|
|
490
|
-
*/
|
|
491
|
-
createWrapper() {
|
|
492
|
-
this.wrapper = document.createElement('div');
|
|
493
|
-
this.wrapper.className = 'falling-animation-wrapper';
|
|
494
|
-
// Apply styles
|
|
495
|
-
Object.assign(this.wrapper.style, {
|
|
496
|
-
position: this.options.container === document.body ? 'fixed' : 'absolute',
|
|
497
|
-
top: '0',
|
|
498
|
-
left: '0',
|
|
499
|
-
width: '100%',
|
|
500
|
-
height: '100%',
|
|
501
|
-
overflow: 'hidden',
|
|
502
|
-
pointerEvents: 'none',
|
|
503
|
-
zIndex: String(this.options.zIndex)
|
|
504
|
-
});
|
|
505
|
-
// Ensure container has position for absolute wrapper
|
|
506
|
-
if (this.options.container !== document.body) {
|
|
507
|
-
const containerPosition = getComputedStyle(this.options.container).position;
|
|
508
|
-
if (containerPosition === 'static') {
|
|
509
|
-
this.options.container.style.position = 'relative';
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
this.options.container.appendChild(this.wrapper);
|
|
513
|
-
}
|
|
514
655
|
/**
|
|
515
656
|
* Setup resize handler for responsive behavior
|
|
516
657
|
*/
|
|
517
658
|
setupResizeHandler() {
|
|
518
659
|
this.resizeHandler = throttle(() => {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
}
|
|
524
667
|
}, 200);
|
|
525
668
|
window.addEventListener('resize', this.resizeHandler);
|
|
526
669
|
}
|
|
@@ -531,11 +674,11 @@ class FallingAnimation {
|
|
|
531
674
|
if (this.particles.length >= this.options.maxParticles) {
|
|
532
675
|
return;
|
|
533
676
|
}
|
|
534
|
-
|
|
677
|
+
if (!this.renderer)
|
|
678
|
+
return;
|
|
679
|
+
const { width, height } = this.renderer.getSize();
|
|
680
|
+
const particle = new Particle(this.options, width, height);
|
|
535
681
|
this.particles.push(particle);
|
|
536
|
-
if (this.wrapper) {
|
|
537
|
-
this.wrapper.appendChild(particle.element);
|
|
538
|
-
}
|
|
539
682
|
}
|
|
540
683
|
/**
|
|
541
684
|
* Remove a particle
|
|
@@ -544,7 +687,6 @@ class FallingAnimation {
|
|
|
544
687
|
const index = this.particles.indexOf(particle);
|
|
545
688
|
if (index > -1) {
|
|
546
689
|
this.particles.splice(index, 1);
|
|
547
|
-
particle.destroy();
|
|
548
690
|
}
|
|
549
691
|
}
|
|
550
692
|
/**
|
|
@@ -570,8 +712,11 @@ class FallingAnimation {
|
|
|
570
712
|
this.animationId = null;
|
|
571
713
|
}
|
|
572
714
|
// Clear all particles
|
|
573
|
-
this.particles.forEach(p => p.destroy());
|
|
574
715
|
this.particles = [];
|
|
716
|
+
// Clear canvas
|
|
717
|
+
if (this.renderer) {
|
|
718
|
+
this.renderer.clear();
|
|
719
|
+
}
|
|
575
720
|
}
|
|
576
721
|
/**
|
|
577
722
|
* Pause the animation (keeps particles in place)
|
|
@@ -627,6 +772,7 @@ class FallingAnimation {
|
|
|
627
772
|
}
|
|
628
773
|
if (newOptions.objects) {
|
|
629
774
|
this.options.objects = newOptions.objects;
|
|
775
|
+
this.preloadImages(); // Preload any new images
|
|
630
776
|
}
|
|
631
777
|
}
|
|
632
778
|
/**
|
|
@@ -652,10 +798,10 @@ class FallingAnimation {
|
|
|
652
798
|
*/
|
|
653
799
|
destroy() {
|
|
654
800
|
this.stop();
|
|
655
|
-
//
|
|
656
|
-
if (this.
|
|
657
|
-
this.
|
|
658
|
-
this.
|
|
801
|
+
// Destroy renderer
|
|
802
|
+
if (this.renderer) {
|
|
803
|
+
this.renderer.destroy();
|
|
804
|
+
this.renderer = null;
|
|
659
805
|
}
|
|
660
806
|
// Remove resize handler
|
|
661
807
|
if (this.resizeHandler) {
|
|
@@ -666,14 +812,14 @@ class FallingAnimation {
|
|
|
666
812
|
}
|
|
667
813
|
|
|
668
814
|
/**
|
|
669
|
-
* Fireworks Animation - Creates realistic firework effects
|
|
815
|
+
* Fireworks Animation - Creates realistic firework effects (Canvas-based)
|
|
670
816
|
*
|
|
671
817
|
* Features:
|
|
672
818
|
* - Rockets shooting up from bottom
|
|
673
819
|
* - Explosions spreading particles in all directions (360°)
|
|
674
820
|
* - Gravity effect on particles
|
|
675
821
|
* - Fade out over time
|
|
676
|
-
* -
|
|
822
|
+
* - Glow effects
|
|
677
823
|
*/
|
|
678
824
|
/** Default colors for fireworks */
|
|
679
825
|
const DEFAULT_COLORS = [
|
|
@@ -688,7 +834,6 @@ const DEFAULT_COLORS = [
|
|
|
688
834
|
'#ff69b4', // Pink
|
|
689
835
|
'#ffd700', // Gold
|
|
690
836
|
];
|
|
691
|
-
/** Default configuration */
|
|
692
837
|
const DEFAULTS = {
|
|
693
838
|
colors: DEFAULT_COLORS,
|
|
694
839
|
launchRate: 1,
|
|
@@ -699,14 +844,15 @@ const DEFAULTS = {
|
|
|
699
844
|
particleLifetime: { min: 1000, max: 2000 },
|
|
700
845
|
gravity: 0.1,
|
|
701
846
|
trail: true,
|
|
847
|
+
explosionPattern: 'circular',
|
|
702
848
|
autoStart: true,
|
|
703
849
|
zIndex: 9999
|
|
704
850
|
};
|
|
705
851
|
class Fireworks {
|
|
706
852
|
constructor(options = {}) {
|
|
707
|
-
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;
|
|
708
854
|
this.particles = [];
|
|
709
|
-
this.
|
|
855
|
+
this.renderer = null;
|
|
710
856
|
this.isRunning = false;
|
|
711
857
|
this.animationId = null;
|
|
712
858
|
this.lastLaunchTime = 0;
|
|
@@ -714,7 +860,7 @@ class Fireworks {
|
|
|
714
860
|
this.resizeHandler = null;
|
|
715
861
|
this.animate = (currentTime) => {
|
|
716
862
|
var _a;
|
|
717
|
-
if (!this.isRunning)
|
|
863
|
+
if (!this.isRunning || !this.renderer)
|
|
718
864
|
return;
|
|
719
865
|
if (this.lastFrameTime === 0) {
|
|
720
866
|
this.lastFrameTime = currentTime;
|
|
@@ -733,47 +879,62 @@ class Fireworks {
|
|
|
733
879
|
for (const particle of this.particles) {
|
|
734
880
|
particle.age += deltaTime;
|
|
735
881
|
if (particle.phase === 'rocket') {
|
|
736
|
-
// Update rocket
|
|
737
882
|
particle.x += particle.vx * deltaTime * 0.1;
|
|
738
883
|
particle.y += particle.vy * deltaTime * 0.1;
|
|
739
|
-
particle.vy += 0.01 * deltaTime;
|
|
740
|
-
// Check if ready to explode (reached target height or max age)
|
|
884
|
+
particle.vy += 0.01 * deltaTime;
|
|
741
885
|
const targetY = (_a = particle.targetY) !== null && _a !== void 0 ? _a : 0;
|
|
742
886
|
if (particle.y <= targetY || particle.age >= particle.maxAge || particle.vy >= 0) {
|
|
743
887
|
particlesToExplode.push(particle);
|
|
744
888
|
}
|
|
745
|
-
// Trail effect - dim the rocket slightly
|
|
746
889
|
particle.opacity = Math.max(0.5, 1 - (particle.age / particle.maxAge) * 0.3);
|
|
747
890
|
}
|
|
748
891
|
else {
|
|
749
|
-
// Update explosion particle
|
|
750
892
|
particle.x += particle.vx * deltaTime * 0.1;
|
|
751
893
|
particle.y += particle.vy * deltaTime * 0.1;
|
|
752
|
-
particle.vy += particle.gravity * deltaTime * 0.01;
|
|
753
|
-
// Fade out
|
|
894
|
+
particle.vy += particle.gravity * deltaTime * 0.01;
|
|
754
895
|
const lifeProgress = particle.age / particle.maxAge;
|
|
755
896
|
particle.opacity = Math.max(0, 1 - lifeProgress);
|
|
756
|
-
//
|
|
757
|
-
particle.
|
|
758
|
-
particle.
|
|
759
|
-
|
|
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
|
+
}
|
|
760
907
|
if (particle.age >= particle.maxAge) {
|
|
761
908
|
particlesToRemove.push(particle);
|
|
762
909
|
}
|
|
763
910
|
}
|
|
764
|
-
// Update DOM
|
|
765
|
-
particle.element.style.transform = `translate3d(${particle.x}px, ${particle.y}px, 0)`;
|
|
766
|
-
particle.element.style.opacity = String(particle.opacity);
|
|
767
911
|
}
|
|
768
912
|
// Handle explosions
|
|
769
913
|
for (const rocket of particlesToExplode) {
|
|
770
|
-
|
|
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
|
+
}
|
|
771
922
|
particlesToRemove.push(rocket);
|
|
772
923
|
}
|
|
773
924
|
// Remove dead particles
|
|
774
925
|
for (const particle of particlesToRemove) {
|
|
775
926
|
this.removeParticle(particle);
|
|
776
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);
|
|
777
938
|
this.animationId = requestAnimationFrame(this.animate);
|
|
778
939
|
};
|
|
779
940
|
// Check for browser environment
|
|
@@ -803,130 +964,313 @@ class Fireworks {
|
|
|
803
964
|
particleLifetime: (_g = options.particleLifetime) !== null && _g !== void 0 ? _g : DEFAULTS.particleLifetime,
|
|
804
965
|
gravity: (_h = options.gravity) !== null && _h !== void 0 ? _h : DEFAULTS.gravity,
|
|
805
966
|
trail: (_j = options.trail) !== null && _j !== void 0 ? _j : DEFAULTS.trail,
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
808
970
|
};
|
|
809
|
-
|
|
971
|
+
// Create canvas renderer
|
|
972
|
+
this.renderer = new CanvasRenderer(container, this.options.zIndex);
|
|
810
973
|
this.setupResizeHandler();
|
|
811
974
|
if (this.options.autoStart) {
|
|
812
975
|
this.start();
|
|
813
976
|
}
|
|
814
977
|
}
|
|
815
|
-
createWrapper() {
|
|
816
|
-
this.wrapper = document.createElement('div');
|
|
817
|
-
this.wrapper.className = 'fireworks-wrapper';
|
|
818
|
-
Object.assign(this.wrapper.style, {
|
|
819
|
-
position: this.options.container === document.body ? 'fixed' : 'absolute',
|
|
820
|
-
top: '0',
|
|
821
|
-
left: '0',
|
|
822
|
-
width: '100%',
|
|
823
|
-
height: '100%',
|
|
824
|
-
overflow: 'hidden',
|
|
825
|
-
pointerEvents: 'none',
|
|
826
|
-
zIndex: String(this.options.zIndex)
|
|
827
|
-
});
|
|
828
|
-
if (this.options.container !== document.body) {
|
|
829
|
-
const containerPosition = getComputedStyle(this.options.container).position;
|
|
830
|
-
if (containerPosition === 'static') {
|
|
831
|
-
this.options.container.style.position = 'relative';
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
this.options.container.appendChild(this.wrapper);
|
|
835
|
-
}
|
|
836
978
|
setupResizeHandler() {
|
|
837
979
|
this.resizeHandler = throttle(() => {
|
|
838
|
-
|
|
980
|
+
if (this.renderer) {
|
|
981
|
+
this.renderer.resize();
|
|
982
|
+
}
|
|
839
983
|
}, 200);
|
|
840
984
|
window.addEventListener('resize', this.resizeHandler);
|
|
841
985
|
}
|
|
842
986
|
getContainerSize() {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
createParticleElement(particle) {
|
|
849
|
-
const element = document.createElement('div');
|
|
850
|
-
element.className = 'firework-particle';
|
|
851
|
-
Object.assign(element.style, {
|
|
852
|
-
position: 'absolute',
|
|
853
|
-
left: '0',
|
|
854
|
-
top: '0',
|
|
855
|
-
width: `${particle.size}px`,
|
|
856
|
-
height: `${particle.size}px`,
|
|
857
|
-
backgroundColor: particle.color,
|
|
858
|
-
borderRadius: '50%',
|
|
859
|
-
boxShadow: `0 0 ${particle.size * 2}px ${particle.color}`,
|
|
860
|
-
opacity: String(particle.opacity),
|
|
861
|
-
pointerEvents: 'none',
|
|
862
|
-
willChange: 'transform, opacity',
|
|
863
|
-
transform: `translate3d(${particle.x}px, ${particle.y}px, 0)`
|
|
864
|
-
});
|
|
865
|
-
return element;
|
|
987
|
+
if (this.renderer) {
|
|
988
|
+
return this.renderer.getSize();
|
|
989
|
+
}
|
|
990
|
+
return { width: 0, height: 0 };
|
|
866
991
|
}
|
|
867
992
|
launchRocket() {
|
|
868
993
|
const { width, height } = this.getContainerSize();
|
|
994
|
+
if (width === 0 || height === 0)
|
|
995
|
+
return;
|
|
869
996
|
const color = this.options.colors[Math.floor(Math.random() * this.options.colors.length)];
|
|
870
997
|
// Random explosion height: 10% to 90% from top of screen
|
|
871
998
|
const targetY = randomRange(height * 0.1, height * 0.9);
|
|
872
999
|
const distanceToTravel = height - targetY;
|
|
873
1000
|
// Calculate time based on distance and speed
|
|
874
1001
|
const rocketSpeed = randomFromRange(this.options.rocketSpeed);
|
|
875
|
-
const travelTime = distanceToTravel / (rocketSpeed * 0.1);
|
|
1002
|
+
const travelTime = distanceToTravel / (rocketSpeed * 0.1);
|
|
876
1003
|
const particle = {
|
|
877
1004
|
id: generateId(),
|
|
878
|
-
element: null,
|
|
879
1005
|
x: randomRange(width * 0.1, width * 0.9),
|
|
880
|
-
y: height + 10,
|
|
1006
|
+
y: height + 10,
|
|
881
1007
|
vx: randomRange(-0.5, 0.5),
|
|
882
1008
|
vy: -rocketSpeed,
|
|
883
1009
|
size: 4,
|
|
884
1010
|
opacity: 1,
|
|
885
1011
|
color,
|
|
886
1012
|
age: 0,
|
|
887
|
-
maxAge: Math.min(travelTime, 3000),
|
|
1013
|
+
maxAge: Math.min(travelTime, 3000),
|
|
888
1014
|
phase: 'rocket',
|
|
889
1015
|
gravity: 0,
|
|
890
|
-
targetY
|
|
1016
|
+
targetY,
|
|
1017
|
+
stage: 0
|
|
891
1018
|
};
|
|
892
|
-
particle.element = this.createParticleElement(particle);
|
|
893
1019
|
this.particles.push(particle);
|
|
894
|
-
if (this.wrapper) {
|
|
895
|
-
this.wrapper.appendChild(particle.element);
|
|
896
|
-
}
|
|
897
1020
|
}
|
|
898
1021
|
explode(rocket) {
|
|
899
1022
|
const particleCount = this.options.particlesPerExplosion;
|
|
900
1023
|
const color = rocket.color;
|
|
901
|
-
//
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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);
|
|
905
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;
|
|
906
1199
|
const particle = {
|
|
907
1200
|
id: generateId(),
|
|
908
|
-
element: null,
|
|
909
1201
|
x: rocket.x,
|
|
910
1202
|
y: rocket.y,
|
|
911
1203
|
vx: Math.cos(angle) * speed,
|
|
912
1204
|
vy: Math.sin(angle) * speed,
|
|
1205
|
+
// Normal size for primary particles
|
|
913
1206
|
size: randomFromRange(this.options.particleSize),
|
|
914
1207
|
opacity: 1,
|
|
915
1208
|
color: this.getVariantColor(color),
|
|
916
1209
|
age: 0,
|
|
917
1210
|
maxAge: randomFromRange(this.options.particleLifetime),
|
|
918
1211
|
phase: 'explosion',
|
|
919
|
-
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
|
|
920
1242
|
};
|
|
921
|
-
particle.element = this.createParticleElement(particle);
|
|
922
1243
|
this.particles.push(particle);
|
|
923
|
-
if (this.wrapper) {
|
|
924
|
-
this.wrapper.appendChild(particle.element);
|
|
925
|
-
}
|
|
926
1244
|
}
|
|
927
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
|
+
}
|
|
928
1273
|
getVariantColor(baseColor) {
|
|
929
|
-
// Sometimes return white or a lighter variant for sparkle effect
|
|
930
1274
|
if (Math.random() < 0.2) {
|
|
931
1275
|
return '#ffffff';
|
|
932
1276
|
}
|
|
@@ -939,7 +1283,6 @@ class Fireworks {
|
|
|
939
1283
|
const index = this.particles.indexOf(particle);
|
|
940
1284
|
if (index > -1) {
|
|
941
1285
|
this.particles.splice(index, 1);
|
|
942
|
-
particle.element.remove();
|
|
943
1286
|
}
|
|
944
1287
|
}
|
|
945
1288
|
/** Start the fireworks */
|
|
@@ -961,10 +1304,10 @@ class Fireworks {
|
|
|
961
1304
|
}
|
|
962
1305
|
/** Clear all particles but keep running */
|
|
963
1306
|
clear() {
|
|
964
|
-
for (const particle of this.particles) {
|
|
965
|
-
particle.element.remove();
|
|
966
|
-
}
|
|
967
1307
|
this.particles = [];
|
|
1308
|
+
if (this.renderer) {
|
|
1309
|
+
this.renderer.clear();
|
|
1310
|
+
}
|
|
968
1311
|
}
|
|
969
1312
|
/** Launch a single firework manually */
|
|
970
1313
|
launch() {
|
|
@@ -994,6 +1337,8 @@ class Fireworks {
|
|
|
994
1337
|
this.options.particleLifetime = newOptions.particleLifetime;
|
|
995
1338
|
if (newOptions.gravity !== undefined)
|
|
996
1339
|
this.options.gravity = newOptions.gravity;
|
|
1340
|
+
if (newOptions.explosionPattern)
|
|
1341
|
+
this.options.explosionPattern = newOptions.explosionPattern;
|
|
997
1342
|
}
|
|
998
1343
|
/** Get particle count */
|
|
999
1344
|
getParticleCount() {
|
|
@@ -1007,9 +1352,9 @@ class Fireworks {
|
|
|
1007
1352
|
destroy() {
|
|
1008
1353
|
this.stop();
|
|
1009
1354
|
this.clear();
|
|
1010
|
-
if (this.
|
|
1011
|
-
this.
|
|
1012
|
-
this.
|
|
1355
|
+
if (this.renderer) {
|
|
1356
|
+
this.renderer.destroy();
|
|
1357
|
+
this.renderer = null;
|
|
1013
1358
|
}
|
|
1014
1359
|
if (this.resizeHandler) {
|
|
1015
1360
|
window.removeEventListener('resize', this.resizeHandler);
|