/** * 文件功能:聊天室烟花特效(真实感版本) * * 完整模拟真实烟花流程: * 1. 火箭从底部带着尾迹飞向目标高度 * 2. 到达位置后爆炸,产生 3 种形态:球形/柳叶/星形 * 3. 每颗粒子带历史轨迹尾巴,随重力下坠并消散 * * 全程透明 Canvas,不遮挡聊天背景。 */ const FireworksEffect = (() => { // ─── 火箭类 ────────────────────────────────────────── class Rocket { /** * @param {number} x 发射 x 位置 * @param {number} targetY 爆炸目标高度(距顶部比例) * @param {string} color 爆炸颜色 * @param {string} type 爆炸类型:sphere / willow / ring */ constructor(x, targetY, color, type, canvasHeight, drift = 0) { this.x = x; this.y = canvasHeight; // 从画布底部出发 this.targetY = targetY; this.color = color; this.type = type; this.vx = drift; // 根据飞行距离动态计算初始速度,保证必然到达目标高度 // 等比级数求和:total = vy / (1 - 0.98) = vy × 50 // 加 10%~20% 余量,使火箭略微超过目标再触发爆炸(更真实) const dist = canvasHeight - targetY; this.vy = -(dist / 50) * (1.1 + Math.random() * 0.15); this.trail = []; // 尾迹历史坐标 this.exploded = false; this.done = false; } /** 更新火箭位置,到达目标高度后标记为已爆炸 */ update() { this.trail.push({ x: this.x, y: this.y }); if (this.trail.length > 12) { this.trail.shift(); } this.x += this.vx; this.y += this.vy; this.vx *= 0.992; this.vy *= 0.98; // 轻微减速(仿真阻力) if (this.y <= this.targetY) { this.exploded = true; this.done = true; } } /** 绘制火箭(白色头部 + 橙色尾迹) */ draw(ctx) { // 尾迹(由新到旧逐渐变淡) for (let i = 0; i < this.trail.length; i++) { const a = (i / this.trail.length) * 0.6; ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = "#ffaa44"; ctx.shadowColor = "#ff6600"; ctx.shadowBlur = 6; ctx.beginPath(); ctx.arc(this.trail[i].x, this.trail[i].y, 2, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // 火箭头部(亮白色光点) ctx.save(); ctx.fillStyle = "#ffffff"; ctx.shadowColor = "#ffcc88"; ctx.shadowBlur = 10; ctx.beginPath(); ctx.arc(this.x, this.y, 3, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } // ─── 爆炸粒子类 ────────────────────────────────────── class Particle { /** * @param {number} x 爆炸中心 x * @param {number} y 爆炸中心 y * @param {string} color 粒子颜色 * @param {string} type 爆炸类型 * @param {number} angle 发射角度(ring 类型用) */ constructor(x, y, color, type, angle, options = {}) { this.x = x; this.y = y; this.color = color; this.trail = []; this.innerColor = options.innerColor ?? "#ffffff"; this.trailLimit = options.trailLimit ?? 8; this.drag = options.drag ?? 0.985; this.radiusScale = options.radiusScale ?? 1; let speed; if (type === "ring") { // 环形:均匀角度,固定速度 speed = 5.8 + Math.random() * 2.2; this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed; this.gravity = 0.06; this.decay = 0.012; this.radius = 2.2 * this.radiusScale; } else if (type === "willow") { // 柳叶:慢速,在空中下垂 speed = Math.random() * 3.8 + 1.4; const a = Math.random() * Math.PI * 2; this.vx = Math.cos(a) * speed; this.vy = Math.sin(a) * speed - 2.4; // 初速稍微向上 this.gravity = 0.072; this.decay = 0.0075; // 衰减慢,拖出长尾 this.radius = 1.7 * this.radiusScale; } else { // sphere:标准球形爆炸 speed = Math.random() * 6.8 + 2.4; const a = Math.random() * Math.PI * 2; this.vx = Math.cos(a) * speed; this.vy = Math.sin(a) * speed; this.gravity = 0.095; this.decay = 0.0135; this.radius = (Math.random() * 2.2 + 1.7) * this.radiusScale; } this.alpha = 1; // 部分粒子有闪烁效果 this.sparkle = Math.random() > 0.45; this.frame = 0; } /** 更新粒子物理状态 */ update() { this.frame++; // 保存轨迹历史(尾迹长度由透明度控制) this.trail.push({ x: this.x, y: this.y }); if (this.trail.length > this.trailLimit) { this.trail.shift(); } this.vy += this.gravity; this.x += this.vx; this.y += this.vy; this.vx *= this.drag; this.vy *= this.drag; // 闪烁:每隔几帧透明度轻微抖动 if (this.sparkle && this.frame % 4 === 0) { this.alpha = Math.max( 0, this.alpha - this.decay * (0.5 + Math.random()), ); } else { this.alpha -= this.decay; } } /** 绘制粒子及其运动轨迹尾迹 */ draw(ctx) { // 绘制尾迹 for (let i = 0; i < this.trail.length; i++) { const a = (i / this.trail.length) * this.alpha * 0.45; ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc( this.trail[i].x, this.trail[i].y, this.radius * 0.55, 0, Math.PI * 2, ); ctx.fill(); ctx.restore(); } // 绘制粒子主体(带发光) ctx.save(); ctx.globalAlpha = Math.max(0, this.alpha); ctx.fillStyle = this.color; ctx.shadowColor = this.color; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fill(); // 用白色内核强化“炸点”质感,避免颜色过闷。 ctx.fillStyle = this.innerColor; ctx.globalAlpha = Math.max(0, this.alpha * 0.35); ctx.beginPath(); ctx.arc(this.x, this.y, this.radius * 0.4, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } get alive() { return this.alpha > 0.01; } } // ─── 爆炸光晕类 ────────────────────────────────────── class Halo { /** * @param {number} x 爆炸中心 x * @param {number} y 爆炸中心 y * @param {string} color 爆炸主色 * @param {number} radius 最大光晕半径 */ constructor(x, y, color, radius) { this.x = x; this.y = y; this.color = color; this.radius = radius * 0.32; this.maxRadius = radius; this.alpha = 0.34; } /** 更新光晕扩散与淡出 */ update() { this.radius += (this.maxRadius - this.radius) * 0.16 + 1.8; this.alpha *= 0.88; } /** 绘制爆炸余辉,让烟花更有层次和氛围 */ draw(ctx) { // 使用发光圆替代每帧渐变重建,尽量保留余辉质感同时降低爆炸高峰的绘制成本。 ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.globalAlpha = this.alpha; ctx.fillStyle = this.color; ctx.shadowColor = this.color; ctx.shadowBlur = this.radius * 0.45; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius * 0.62, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } get alive() { return this.alpha > 0.02; } } // ─── 预定义颜色 / 类型 ─────────────────────────────── const COLORS = [ "#ff2200", "#ff7700", "#ffcc00", "#00dd44", "#cc00ff", "#ff0088", "#00bbff", "#ff4488", "#ffaa00", ]; const TYPES = ["sphere", "willow", "ring"]; const FLASH_COLORS = ["#ffffff", "#ffe3a3", "#ffd4f0", "#cfe9ff"]; /** * 随机取数组中的一个元素 * * @param {Array} items 候选数组 * @returns {*} */ function _pick(items) { return items[Math.floor(Math.random() * items.length)]; } /** * 为画布做渐隐,而不是硬清屏。 * * 这里使用 destination-out 只擦除旧像素,不会给聊天室背景额外盖一层黑幕。 * * @param {CanvasRenderingContext2D} ctx * @param {number} w * @param {number} h */ function _fadeFrame(ctx, w, h) { ctx.save(); ctx.globalCompositeOperation = "destination-out"; ctx.fillStyle = "rgba(0, 0, 0, 0.24)"; ctx.fillRect(0, 0, w, h); ctx.restore(); } /** * 生成一批爆炸粒子 * * @param {number} x 爆炸中心 x * @param {number} y 爆炸中心 y * @param {string} color 颜色 * @param {string} type 爆炸类型 * @param {number} density 粒子密度倍率 * @returns {Particle[]} */ function _burst(x, y, color, type, density = 1) { const particles = []; const baseCount = type === "ring" ? 120 : type === "willow" ? 170 : 145; const count = Math.round(baseCount * density); const accentColor = _pick(FLASH_COLORS); if (type === "ring") { // 环形:均匀分布 for (let i = 0; i < count; i++) { const angle = ((Math.PI * 2) / count) * i; particles.push(new Particle(x, y, color, type, angle)); } } else { for (let i = 0; i < count; i++) { particles.push(new Particle(x, y, color, type, 0)); } // 核心补一圈亮色星火,让爆点更饱满。 for (let i = 0; i < 18; i++) { const p = new Particle(x, y, accentColor, "sphere", 0, { trailLimit: 4, drag: 0.978, radiusScale: 0.7, }); p.decay *= 1.6; p.radius *= 0.7; particles.push(p); } } // 再补一层高亮碎火,提升烟花“炸开”的亮度与体积感。 const sparkleCount = Math.max(10, Math.round(count * 0.12)); for (let i = 0; i < sparkleCount; i++) { const sparkle = new Particle(x, y, accentColor, "sphere", 0, { trailLimit: 3, drag: 0.972, radiusScale: 0.58, innerColor: "#ffffff", }); sparkle.decay *= 1.9; sparkle.gravity *= 0.8; particles.push(sparkle); } return particles; } /** * 批量把新增粒子追加到现有数组,避免频繁 concat 产生新数组。 * * @param {Particle[]} target * @param {Particle[]} incoming */ function _appendParticles(target, incoming) { for (let i = 0; i < incoming.length; i++) { target.push(incoming[i]); } } /** * 统一发射一枚火箭。 * * @param {Rocket[]} rockets * @param {number} w * @param {number} h * @param {string[]} colors * @param {Function} getX * @param {Function} getTargetY */ function _launchRocket(rockets, w, h, colors, getX, getTargetY) { const x = getX(w); const ty = getTargetY(h); const color = _pick(colors); const type = _pick(TYPES); const drift = (Math.random() - 0.5) * 0.9; rockets.push(new Rocket(x, ty, color, type, h, drift)); } /** * 通用烟花演出引擎。 * * @param {HTMLCanvasElement} canvas * @param {Function} onEnd * @param {object} config */ function _runShow(canvas, onEnd, config) { const ctx = canvas.getContext("2d"); const w = canvas.width; const h = canvas.height; const duration = config.duration; const hardStopAt = duration + 2600; const peakParticleBudget = config.peakParticleBudget ?? 1650; let rockets = []; let particles = []; let halos = []; let scheduledBursts = []; let animId = null; let launchCnt = 0; const launchInterval = setInterval(() => { if (launchCnt >= config.maxLaunches) { clearInterval(launchInterval); return; } const batchSize = config.getBatchSize(launchCnt); for (let i = 0; i < batchSize && launchCnt < config.maxLaunches; i++) { _launchRocket( rockets, w, h, config.colors, config.getLaunchX, config.getTargetY, ); launchCnt++; } }, config.launchEvery); // 开场礼炮先把气氛撑起来,避免一开始太空。 if (typeof config.openingVolley === "function") { setTimeout(() => { config.openingVolley(rockets, w, h); }, 120); } const startTime = performance.now(); function animate(now) { _fadeFrame(ctx, w, h); halos = halos.filter((halo) => halo.alive); halos.forEach((halo) => { halo.update(); halo.draw(ctx); }); for (let i = scheduledBursts.length - 1; i >= 0; i--) { if (scheduledBursts[i].triggerAt <= now) { const burst = scheduledBursts[i]; _appendParticles( particles, _burst(burst.x, burst.y, burst.color, burst.type, burst.density), ); halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius)); scheduledBursts.splice(i, 1); } } for (let i = rockets.length - 1; i >= 0; i--) { const rocket = rockets[i]; if (rocket.done) { _appendParticles( particles, _burst( rocket.x, rocket.y, rocket.color, rocket.type, config.particleDensity, ), ); halos.push(new Halo(rocket.x, rocket.y, rocket.color, config.primaryHaloRadius)); // 粒子高峰时先保主爆炸观感,压掉一部分二次爆裂来避免卡顿。 if ( particles.length < peakParticleBudget && Math.random() < config.secondaryBurstChance ) { scheduledBursts.push({ triggerAt: now + 90 + Math.random() * 140, x: rocket.x + (Math.random() - 0.5) * 34, y: rocket.y + (Math.random() - 0.5) * 26, color: _pick(config.colors), type: Math.random() > 0.5 ? "sphere" : "ring", density: config.secondaryDensity, haloRadius: config.secondaryHaloRadius, }); } rockets.splice(i, 1); } else { rocket.update(); rocket.draw(ctx); } } particles = particles.filter((particle) => particle.alive); particles.forEach((particle) => { particle.update(); particle.draw(ctx); }); const elapsed = now - startTime; const shouldContinue = elapsed < duration || rockets.length > 0 || particles.length > 0 || scheduledBursts.length > 0; if (shouldContinue && elapsed < hardStopAt) { animId = requestAnimationFrame(animate); } else { clearInterval(launchInterval); cancelAnimationFrame(animId); ctx.clearRect(0, 0, w, h); onEnd(); } } animId = requestAnimationFrame(animate); } /** * 启动烟花特效(普通版) * * @param {HTMLCanvasElement} canvas 全屏 Canvas * @param {Function} onEnd 特效结束回调 */ function start(canvas, onEnd) { _runShow(canvas, onEnd, { duration: 10500, launchEvery: 340, maxLaunches: 24, particleDensity: 1.08, peakParticleBudget: 1500, secondaryDensity: 0.42, primaryHaloRadius: 150, secondaryHaloRadius: 84, secondaryBurstChance: 0.54, colors: COLORS, getBatchSize(launchCnt) { return launchCnt % 5 === 0 ? 2 : 1; }, getLaunchX(width) { return width * (0.1 + Math.random() * 0.8); }, getTargetY(height) { return height * (0.08 + Math.random() * 0.42); }, openingVolley(rockets, width, height) { [0.18, 0.5, 0.82].forEach((ratio) => { rockets.push(new Rocket( width * ratio, height * (0.12 + Math.random() * 0.12), _pick(COLORS), "sphere", height, (Math.random() - 0.5) * 0.6, )); }); }, }); } /** * 启动婚礼加倍烟花特效(双侧轮流发射,粒子增倍,持续更久) * * @param {HTMLCanvasElement} canvas 全屏 Canvas * @param {Function} onEnd 特效结束回调 */ function startDouble(canvas, onEnd) { // 婚礼专属浪漫色组(增加金色/粉色) const WEDDING_COLORS = [ "#ff2266", "#ff66aa", "#ff99cc", // 粉红系 "#ffcc00", "#ffdd44", "#fff066", // 金黄系 "#cc44ff", "#ff44cc", "#aa00ff", // 紫色系 "#ff4400", "#ff8800", "#00ddff", // 其他 ]; _runShow(canvas, onEnd, { duration: 12400, launchEvery: 280, maxLaunches: 34, particleDensity: 1.3, peakParticleBudget: 1850, secondaryDensity: 0.56, primaryHaloRadius: 176, secondaryHaloRadius: 96, secondaryBurstChance: 0.72, colors: WEDDING_COLORS, getBatchSize(launchCnt) { return launchCnt % 4 === 0 ? 2 : 1; }, getLaunchX(width) { const fromLeft = Math.random() > 0.5; return fromLeft ? width * (0.04 + Math.random() * 0.38) : width * (0.58 + Math.random() * 0.38); }, getTargetY(height) { return height * (0.05 + Math.random() * 0.38); }, openingVolley(rockets, width, height) { [0.12, 0.32, 0.5, 0.68, 0.88].forEach((ratio, index) => { rockets.push(new Rocket( width * ratio, height * (index % 2 === 0 ? 0.1 : 0.16), _pick(WEDDING_COLORS), index % 2 === 0 ? "sphere" : "ring", height, (Math.random() - 0.5) * 0.7, )); }); }, }); } return { start, startDouble }; })();