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

416 lines
14 KiB
JavaScript
Raw Normal View History

/**
* 文件功能:聊天室烟花特效(真实感版本)
*
* 完整模拟真实烟花流程:
* 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) {
this.x = x;
this.y = canvasHeight; // 从画布底部出发
this.targetY = targetY;
this.color = color;
this.type = type;
// 根据飞行距离动态计算初始速度,保证必然到达目标高度
// 等比级数求和: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 > 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)];
rockets.push(new Rocket(x, ty, color, type, h));
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);
}
/**
* 启动婚礼加倍烟花特效(双侧轮流发射,粒子增倍,持续更久)
*
* @param {HTMLCanvasElement} canvas 全屏 Canvas
* @param {Function} onEnd 特效结束回调
*/
function startDouble(canvas, onEnd) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const DURATION = 12000; // 比普通多 2 秒
let rockets = [];
let particles = [];
let animId = null;
let launchCnt = 0;
const MAX_LAUNCHES = 24; // 双倍火箭数
// 婚礼专属浪漫色组(增加金色/粉色)
const WEDDING_COLORS = [
"#ff2266",
"#ff66aa",
"#ff99cc", // 粉红系
"#ffcc00",
"#ffdd44",
"#fff066", // 金黄系
"#cc44ff",
"#ff44cc",
"#aa00ff", // 紫色系
"#ff4400",
"#ff8800",
"#00ddff", // 其他
];
// 定时从左右两侧交替发射
const launchInterval = setInterval(() => {
if (launchCnt >= MAX_LAUNCHES) {
clearInterval(launchInterval);
return;
}
// 左右交替:偶数从左侧1/3,奇数从右侧2/3
const isLeft = launchCnt % 2 === 0;
const x = isLeft
? w * (0.05 + Math.random() * 0.4)
: w * (0.55 + Math.random() * 0.4);
const ty = h * (0.05 + Math.random() * 0.4);
const color =
WEDDING_COLORS[
Math.floor(Math.random() * WEDDING_COLORS.length)
];
const type = TYPES[Math.floor(Math.random() * TYPES.length)];
rockets.push(new Rocket(x, ty, color, type, h));
launchCnt++;
}, 400); // 发射间隔缩短到 400ms,密度加倍
// 额外:开场同时发射3枚双侧礼炮
setTimeout(() => {
[0.15, 0.5, 0.85].forEach((xRatio) => {
const color =
WEDDING_COLORS[
Math.floor(Math.random() * WEDDING_COLORS.length)
];
rockets.push(
new Rocket(w * xRatio, h * 0.1, color, "sphere", h),
);
});
}, 100);
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) {
// 婚礼爆炸:粒子数×1.5(在 _burst 基础上额外补充50粒)
const burst = _burst(r.x, r.y, r.color, r.type);
// 额外补充粒子(心形/大颗)
for (let j = 0; j < 50; j++) {
const p = new Particle(r.x, r.y, r.color, "sphere", 0);
p.radius = Math.random() * 3 + 1;
burst.push(p);
}
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, startDouble };
})();