diff --git a/public/js/effects/fireworks.js b/public/js/effects/fireworks.js index 8f6e2b8..4bc0bbe 100644 --- a/public/js/effects/fireworks.js +++ b/public/js/effects/fireworks.js @@ -1,41 +1,168 @@ /** - * 文件功能:聊天室烟花特效 + * 文件功能:聊天室烟花特效(真实感版本) * - * 使用 Canvas 粒子系统在全屏播放多发烟花爆炸动画。 - * 粒子加大、加发光描边,在浅色背景上也清晰可见。 - * 特效总时长约 5 秒,结束后自动清理并回调。 + * 完整模拟真实烟花流程: + * 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) { + this.x = x; + this.y = 9999; // 在 start() 里设置为 canvas.height + this.targetY = targetY; + this.color = color; + this.type = type; + this.vy = -(12 + Math.random() * 4); // 上升速度 + this.trail = []; // 尾迹历史坐标 + this.exploded = false; + this.done = false; + } + + /** 更新火箭位置,到达目标高度后标记为已爆炸 */ + update() { + this.trail.push({ x: this.x, y: this.y }); + if (this.trail.length > 10) this.trail.shift(); + this.y += this.vy; + 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 { - constructor(x, y, color) { + /** + * @param {number} x 爆炸中心 x + * @param {number} y 爆炸中心 y + * @param {string} color 粒子颜色 + * @param {string} type 爆炸类型 + * @param {number} angle 发射角度(ring 类型用) + */ + constructor(x, y, color, type, angle) { this.x = x; this.y = y; this.color = color; - // 随机方向和速度 - const angle = Math.random() * Math.PI * 2; - const speed = Math.random() * 7 + 3; - this.vx = Math.cos(angle) * speed; - this.vy = Math.sin(angle) * speed; + this.trail = []; + + let speed; + if (type === "ring") { + // 环形:均匀角度,固定速度 + speed = 5 + Math.random() * 2; + this.vx = Math.cos(angle) * speed; + this.vy = Math.sin(angle) * speed; + this.gravity = 0.06; + this.decay = 0.014; + this.radius = 2; + } else if (type === "willow") { + // 柳叶:慢速,在空中下垂 + speed = Math.random() * 3 + 1; + const a = Math.random() * Math.PI * 2; + this.vx = Math.cos(a) * speed; + this.vy = Math.sin(a) * speed - 2; // 初速稍微向上 + this.gravity = 0.07; + this.decay = 0.009; // 衰减慢,拖出长尾 + this.radius = 1.5; + } else { + // sphere:标准球形爆炸 + speed = Math.random() * 6 + 2; + const a = Math.random() * Math.PI * 2; + this.vx = Math.cos(a) * speed; + this.vy = Math.sin(a) * speed; + this.gravity = 0.1; + this.decay = 0.016; + this.radius = Math.random() * 2 + 1.5; + } + this.alpha = 1; - this.gravity = 0.12; - this.decay = Math.random() * 0.01 + 0.01; // 衰减略慢,显色更久 - this.radius = Math.random() * 4 + 2; // 增大粒子半径 + // 部分粒子有闪烁效果 + this.sparkle = Math.random() > 0.6; + this.frame = 0; } - /** 每帧更新粒子位置和状态 */ + /** 更新粒子物理状态 */ update() { + this.frame++; + // 保存轨迹历史(尾迹长度由透明度控制) + this.trail.push({ x: this.x, y: this.y }); + if (this.trail.length > 6) this.trail.shift(); + this.vy += this.gravity; this.x += this.vx; this.y += this.vy; - this.vx *= 0.98; - this.vy *= 0.98; - this.alpha -= this.decay; + this.vx *= 0.985; + this.vy *= 0.985; + + // 闪烁:每隔几帧透明度轻微抖动 + 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; @@ -46,32 +173,56 @@ const FireworksEffect = (() => { ctx.fill(); ctx.restore(); } + + get alive() { + return this.alpha > 0.01; + } } - // 预定义烟花颜色组(饱和度高,避免和浅蓝背景撞色) + // ─── 预定义颜色 / 类型 ─────────────────────────────── const COLORS = [ "#ff2200", "#ff7700", "#ffcc00", - "#00cc33", + "#00dd44", "#cc00ff", "#ff0088", - "#00aaff", + "#00bbff", "#ff4488", - "#ff6600", - "#aaff00", - "#ff2255", "#ffaa00", ]; + const TYPES = ["sphere", "willow", "ring"]; /** - * 发射一枚烟花,返回粒子数组 + * 生成一批爆炸粒子 + * + * @param {number} x 爆炸中心 x + * @param {number} y 爆炸中心 y + * @param {string} color 颜色 + * @param {string} type 爆炸类型 + * @returns {Particle[]} */ - function _burst(x, y, count) { - const color = COLORS[Math.floor(Math.random() * COLORS.length)]; + function _burst(x, y, color, type) { const particles = []; - for (let i = 0; i < count; i++) { - particles.push(new Particle(x, y, color)); + const count = type === "ring" ? 80 : type === "willow" ? 120 : 100; + + 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)); + } + // willow/sphere 中心加一颗白色闪光核心粒子 + for (let i = 0; i < 10; i++) { + const p = new Particle(x, y, "#ffffff", "sphere", 0); + p.decay *= 2; + p.radius = 1; + particles.push(p); + } } return particles; } @@ -86,35 +237,51 @@ const FireworksEffect = (() => { const ctx = canvas.getContext("2d"); const w = canvas.width; const h = canvas.height; - const DURATION = 5000; // 总时长(ms) + const DURATION = 6000; + let rockets = []; let particles = []; let animId = null; - let launchCount = 0; - const MAX_LAUNCHES = 10; // 总发射枚数(增加) + let launchCnt = 0; + const MAX_LAUNCHES = 8; - // 定时发射烟花 + // 定时发射火箭 const launchInterval = setInterval(() => { - if (launchCount >= MAX_LAUNCHES) { + if (launchCnt >= MAX_LAUNCHES) { clearInterval(launchInterval); return; } - const x = w * (0.1 + Math.random() * 0.8); - const y = h * (0.05 + Math.random() * 0.5); - const cnt = Math.floor(Math.random() * 50) + 80; // 每枚 80-130 粒子(增多) - particles = particles.concat(_burst(x, y, cnt)); - launchCount++; - }, 450); + const x = w * (0.15 + Math.random() * 0.7); + const ty = h * (0.08 + Math.random() * 0.45); + const color = COLORS[Math.floor(Math.random() * COLORS.length)]; + const type = TYPES[Math.floor(Math.random() * TYPES.length)]; + const rocket = new Rocket(x, ty, color, type); + rocket.y = h; // 从底部发射 + rockets.push(rocket); + launchCnt++; + }, 600); const startTime = performance.now(); - // 动画循环 function animate(now) { - // 清除画布(透明,不遮挡聊天背景) ctx.clearRect(0, 0, w, h); - // 更新并绘制存活粒子 - particles = particles.filter((p) => p.alpha > 0.02); + // 更新和绘制火箭 + for (let i = rockets.length - 1; i >= 0; i--) { + const r = rockets[i]; + if (r.done) { + // 火箭爆炸:生成粒子 + const burst = _burst(r.x, r.y, r.color, r.type); + particles = particles.concat(burst); + rockets.splice(i, 1); + } else { + r.update(); + r.draw(ctx); + } + } + + // 更新和绘制粒子 + particles = particles.filter((p) => p.alive); particles.forEach((p) => { p.update(); p.draw(ctx);