Files
chatroom/public/js/effects/fireworks.js

305 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 文件功能:聊天室烟花特效(真实感版本)
*
* 完整模拟真实烟花流程:
* 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 {
/**
* @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;
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.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.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;
ctx.shadowColor = this.color;
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
get alive() {
return this.alpha > 0.01;
}
}
// ─── 预定义颜色 / 类型 ───────────────────────────────
const COLORS = [
"#ff2200",
"#ff7700",
"#ffcc00",
"#00dd44",
"#cc00ff",
"#ff0088",
"#00bbff",
"#ff4488",
"#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, color, type) {
const particles = [];
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;
}
/**
* 启动烟花特效
*
* @param {HTMLCanvasElement} canvas 全屏 Canvas
* @param {Function} onEnd 特效结束回调
*/
function start(canvas, onEnd) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const DURATION = 10000;
let rockets = [];
let particles = [];
let animId = null;
let launchCnt = 0;
const MAX_LAUNCHES = 12;
// 定时发射火箭
const launchInterval = setInterval(() => {
if (launchCnt >= MAX_LAUNCHES) {
clearInterval(launchInterval);
return;
}
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);
// 更新和绘制火箭
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);
});
if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate);
} else {
clearInterval(launchInterval);
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
}
}
animId = requestAnimationFrame(animate);
}
return { start };
})();