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

416 lines
14 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, 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 };
})();