2026-02-27 14:14:35 +08:00
|
|
|
|
/**
|
2026-02-27 14:26:50 +08:00
|
|
|
|
* 文件功能:聊天室烟花特效(真实感版本)
|
2026-02-27 14:14:35 +08:00
|
|
|
|
*
|
2026-02-27 14:26:50 +08:00
|
|
|
|
* 完整模拟真实烟花流程:
|
|
|
|
|
|
* 1. 火箭从底部带着尾迹飞向目标高度
|
|
|
|
|
|
* 2. 到达位置后爆炸,产生 3 种形态:球形/柳叶/星形
|
|
|
|
|
|
* 3. 每颗粒子带历史轨迹尾巴,随重力下坠并消散
|
|
|
|
|
|
*
|
|
|
|
|
|
* 全程透明 Canvas,不遮挡聊天背景。
|
2026-02-27 14:14:35 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const FireworksEffect = (() => {
|
2026-02-27 14:26:50 +08:00
|
|
|
|
// ─── 火箭类 ──────────────────────────────────────────
|
|
|
|
|
|
class Rocket {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {number} x 发射 x 位置
|
|
|
|
|
|
* @param {number} targetY 爆炸目标高度(距顶部比例)
|
|
|
|
|
|
* @param {string} color 爆炸颜色
|
|
|
|
|
|
* @param {string} type 爆炸类型:sphere / willow / ring
|
|
|
|
|
|
*/
|
2026-04-21 17:13:14 +08:00
|
|
|
|
constructor(x, targetY, color, type, canvasHeight, drift = 0) {
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.x = x;
|
2026-02-27 15:38:21 +08:00
|
|
|
|
this.y = canvasHeight; // 从画布底部出发
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.targetY = targetY;
|
|
|
|
|
|
this.color = color;
|
|
|
|
|
|
this.type = type;
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.vx = drift;
|
2026-02-27 15:38:21 +08:00
|
|
|
|
|
|
|
|
|
|
// 根据飞行距离动态计算初始速度,保证必然到达目标高度
|
|
|
|
|
|
// 等比级数求和:total = vy / (1 - 0.98) = vy × 50
|
|
|
|
|
|
// 加 10%~20% 余量,使火箭略微超过目标再触发爆炸(更真实)
|
|
|
|
|
|
const dist = canvasHeight - targetY;
|
|
|
|
|
|
this.vy = -(dist / 50) * (1.1 + Math.random() * 0.15);
|
|
|
|
|
|
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.trail = []; // 尾迹历史坐标
|
|
|
|
|
|
this.exploded = false;
|
|
|
|
|
|
this.done = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 更新火箭位置,到达目标高度后标记为已爆炸 */
|
|
|
|
|
|
update() {
|
|
|
|
|
|
this.trail.push({ x: this.x, y: this.y });
|
2026-04-21 17:13:14 +08:00
|
|
|
|
if (this.trail.length > 12) {
|
|
|
|
|
|
this.trail.shift();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.x += this.vx;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.y += this.vy;
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.vx *= 0.992;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 爆炸粒子类 ──────────────────────────────────────
|
2026-02-27 14:14:35 +08:00
|
|
|
|
class Particle {
|
2026-02-27 14:26:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @param {number} x 爆炸中心 x
|
|
|
|
|
|
* @param {number} y 爆炸中心 y
|
|
|
|
|
|
* @param {string} color 粒子颜色
|
|
|
|
|
|
* @param {string} type 爆炸类型
|
|
|
|
|
|
* @param {number} angle 发射角度(ring 类型用)
|
|
|
|
|
|
*/
|
2026-04-21 17:13:14 +08:00
|
|
|
|
constructor(x, y, color, type, angle, options = {}) {
|
2026-02-27 14:14:35 +08:00
|
|
|
|
this.x = x;
|
|
|
|
|
|
this.y = y;
|
|
|
|
|
|
this.color = color;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.trail = [];
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.innerColor = options.innerColor ?? "#ffffff";
|
|
|
|
|
|
this.trailLimit = options.trailLimit ?? 8;
|
|
|
|
|
|
this.drag = options.drag ?? 0.985;
|
|
|
|
|
|
this.radiusScale = options.radiusScale ?? 1;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
|
|
|
|
|
|
let speed;
|
|
|
|
|
|
if (type === "ring") {
|
|
|
|
|
|
// 环形:均匀角度,固定速度
|
2026-04-21 17:13:14 +08:00
|
|
|
|
speed = 5.8 + Math.random() * 2.2;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.vx = Math.cos(angle) * speed;
|
|
|
|
|
|
this.vy = Math.sin(angle) * speed;
|
|
|
|
|
|
this.gravity = 0.06;
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.decay = 0.012;
|
|
|
|
|
|
this.radius = 2.2 * this.radiusScale;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
} else if (type === "willow") {
|
|
|
|
|
|
// 柳叶:慢速,在空中下垂
|
2026-04-21 17:13:14 +08:00
|
|
|
|
speed = Math.random() * 3.8 + 1.4;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
const a = Math.random() * Math.PI * 2;
|
|
|
|
|
|
this.vx = Math.cos(a) * speed;
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.vy = Math.sin(a) * speed - 2.4; // 初速稍微向上
|
|
|
|
|
|
this.gravity = 0.072;
|
|
|
|
|
|
this.decay = 0.0075; // 衰减慢,拖出长尾
|
|
|
|
|
|
this.radius = 1.7 * this.radiusScale;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// sphere:标准球形爆炸
|
2026-04-21 17:13:14 +08:00
|
|
|
|
speed = Math.random() * 6.8 + 2.4;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
const a = Math.random() * Math.PI * 2;
|
|
|
|
|
|
this.vx = Math.cos(a) * speed;
|
|
|
|
|
|
this.vy = Math.sin(a) * speed;
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.gravity = 0.095;
|
|
|
|
|
|
this.decay = 0.0135;
|
|
|
|
|
|
this.radius = (Math.random() * 2.2 + 1.7) * this.radiusScale;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 14:14:35 +08:00
|
|
|
|
this.alpha = 1;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
// 部分粒子有闪烁效果
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.sparkle = Math.random() > 0.45;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.frame = 0;
|
2026-02-27 14:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 14:26:50 +08:00
|
|
|
|
/** 更新粒子物理状态 */
|
2026-02-27 14:14:35 +08:00
|
|
|
|
update() {
|
2026-02-27 14:26:50 +08:00
|
|
|
|
this.frame++;
|
|
|
|
|
|
// 保存轨迹历史(尾迹长度由透明度控制)
|
|
|
|
|
|
this.trail.push({ x: this.x, y: this.y });
|
2026-04-21 17:13:14 +08:00
|
|
|
|
if (this.trail.length > this.trailLimit) {
|
|
|
|
|
|
this.trail.shift();
|
|
|
|
|
|
}
|
2026-02-27 14:26:50 +08:00
|
|
|
|
|
2026-02-27 14:14:35 +08:00
|
|
|
|
this.vy += this.gravity;
|
|
|
|
|
|
this.x += this.vx;
|
|
|
|
|
|
this.y += this.vy;
|
2026-04-21 17:13:14 +08:00
|
|
|
|
this.vx *= this.drag;
|
|
|
|
|
|
this.vy *= this.drag;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 闪烁:每隔几帧透明度轻微抖动
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 14:26:50 +08:00
|
|
|
|
/** 绘制粒子及其运动轨迹尾迹 */
|
2026-02-27 14:14:35 +08:00
|
|
|
|
draw(ctx) {
|
2026-02-27 14:26:50 +08:00
|
|
|
|
// 绘制尾迹
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
// 绘制粒子主体(带发光)
|
2026-02-27 14:14:35 +08:00
|
|
|
|
ctx.save();
|
|
|
|
|
|
ctx.globalAlpha = Math.max(0, this.alpha);
|
|
|
|
|
|
ctx.fillStyle = this.color;
|
2026-02-27 14:22:13 +08:00
|
|
|
|
ctx.shadowColor = this.color;
|
|
|
|
|
|
ctx.shadowBlur = 8;
|
2026-02-27 14:14:35 +08:00
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
|
|
|
|
|
ctx.fill();
|
2026-04-21 17:13:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 用白色内核强化“炸点”质感,避免颜色过闷。
|
|
|
|
|
|
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();
|
2026-02-27 14:14:35 +08:00
|
|
|
|
ctx.restore();
|
|
|
|
|
|
}
|
2026-02-27 14:26:50 +08:00
|
|
|
|
|
|
|
|
|
|
get alive() {
|
|
|
|
|
|
return this.alpha > 0.01;
|
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:13:14 +08:00
|
|
|
|
// ─── 爆炸光晕类 ──────────────────────────────────────
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 14:26:50 +08:00
|
|
|
|
// ─── 预定义颜色 / 类型 ───────────────────────────────
|
2026-02-27 14:14:35 +08:00
|
|
|
|
const COLORS = [
|
2026-02-27 14:22:13 +08:00
|
|
|
|
"#ff2200",
|
|
|
|
|
|
"#ff7700",
|
|
|
|
|
|
"#ffcc00",
|
2026-02-27 14:26:50 +08:00
|
|
|
|
"#00dd44",
|
2026-02-27 14:22:13 +08:00
|
|
|
|
"#cc00ff",
|
|
|
|
|
|
"#ff0088",
|
2026-02-27 14:26:50 +08:00
|
|
|
|
"#00bbff",
|
2026-02-27 14:22:13 +08:00
|
|
|
|
"#ff4488",
|
|
|
|
|
|
"#ffaa00",
|
2026-02-27 14:14:35 +08:00
|
|
|
|
];
|
2026-02-27 14:26:50 +08:00
|
|
|
|
const TYPES = ["sphere", "willow", "ring"];
|
2026-04-21 17:13:14 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-27 14:26:50 +08:00
|
|
|
|
* 生成一批爆炸粒子
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {number} x 爆炸中心 x
|
|
|
|
|
|
* @param {number} y 爆炸中心 y
|
|
|
|
|
|
* @param {string} color 颜色
|
|
|
|
|
|
* @param {string} type 爆炸类型
|
2026-04-21 17:13:14 +08:00
|
|
|
|
* @param {number} density 粒子密度倍率
|
2026-02-27 14:26:50 +08:00
|
|
|
|
* @returns {Particle[]}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
*/
|
2026-04-21 17:13:14 +08:00
|
|
|
|
function _burst(x, y, color, type, density = 1) {
|
2026-02-27 14:14:35 +08:00
|
|
|
|
const particles = [];
|
2026-04-21 17:13:14 +08:00
|
|
|
|
const baseCount = type === "ring" ? 120 : type === "willow" ? 170 : 145;
|
|
|
|
|
|
const count = Math.round(baseCount * density);
|
|
|
|
|
|
const accentColor = _pick(FLASH_COLORS);
|
2026-02-27 14:26:50 +08:00
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-04-21 17:13:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 核心补一圈亮色星火,让爆点更饱满。
|
|
|
|
|
|
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;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
particles.push(p);
|
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
}
|
2026-04-21 17:13:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 再补一层高亮碎火,提升烟花“炸开”的亮度与体积感。
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 14:14:35 +08:00
|
|
|
|
return particles;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-21 17:13:14 +08:00
|
|
|
|
* 批量把新增粒子追加到现有数组,避免频繁 concat 产生新数组。
|
2026-02-27 14:14:35 +08:00
|
|
|
|
*
|
2026-04-21 17:13:14 +08:00
|
|
|
|
* @param {Particle[]} target
|
|
|
|
|
|
* @param {Particle[]} incoming
|
2026-02-27 14:14:35 +08:00
|
|
|
|
*/
|
2026-04-21 17:13:14 +08:00
|
|
|
|
function _appendParticles(target, incoming) {
|
|
|
|
|
|
for (let i = 0; i < incoming.length; i++) {
|
|
|
|
|
|
target.push(incoming[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 在粒子预算内追加粒子,避免主爆炸阶段瞬间超量。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Particle[]} target
|
|
|
|
|
|
* @param {Particle[]} incoming
|
|
|
|
|
|
* @param {number} budget
|
|
|
|
|
|
*/
|
|
|
|
|
|
function _appendParticlesWithinBudget(target, incoming, budget) {
|
|
|
|
|
|
const remaining = Math.max(0, budget - target.length);
|
|
|
|
|
|
if (remaining <= 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_appendParticles(target, incoming.slice(0, remaining));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:13:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 统一发射一枚火箭。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @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) {
|
2026-02-27 14:14:35 +08:00
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
|
const w = canvas.width;
|
|
|
|
|
|
const h = canvas.height;
|
2026-04-25 02:52:30 +08:00
|
|
|
|
const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640;
|
|
|
|
|
|
const mobileScale = isMobile ? 0.72 : 1;
|
2026-04-21 17:13:14 +08:00
|
|
|
|
const duration = config.duration;
|
|
|
|
|
|
const hardStopAt = duration + 2600;
|
2026-04-25 02:52:30 +08:00
|
|
|
|
const peakParticleBudget = Math.round((config.peakParticleBudget ?? 1650) * mobileScale);
|
|
|
|
|
|
const maxLaunches = Math.max(8, Math.round(config.maxLaunches * mobileScale));
|
|
|
|
|
|
const particleDensity = config.particleDensity * mobileScale;
|
|
|
|
|
|
const secondaryDensity = config.secondaryDensity * mobileScale;
|
2026-02-27 14:14:35 +08:00
|
|
|
|
|
2026-02-27 14:26:50 +08:00
|
|
|
|
let rockets = [];
|
2026-02-27 14:14:35 +08:00
|
|
|
|
let particles = [];
|
2026-04-21 17:13:14 +08:00
|
|
|
|
let halos = [];
|
|
|
|
|
|
let scheduledBursts = [];
|
2026-02-27 14:14:35 +08:00
|
|
|
|
let animId = null;
|
2026-02-27 14:26:50 +08:00
|
|
|
|
let launchCnt = 0;
|
2026-04-25 02:52:30 +08:00
|
|
|
|
let finished = false;
|
|
|
|
|
|
const timers = [];
|
2026-02-27 14:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
const launchInterval = setInterval(() => {
|
2026-04-25 02:52:30 +08:00
|
|
|
|
if (launchCnt >= maxLaunches) {
|
2026-02-27 14:14:35 +08:00
|
|
|
|
clearInterval(launchInterval);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-21 17:13:14 +08:00
|
|
|
|
|
|
|
|
|
|
const batchSize = config.getBatchSize(launchCnt);
|
2026-04-25 02:52:30 +08:00
|
|
|
|
for (let i = 0; i < batchSize && launchCnt < maxLaunches; i++) {
|
2026-04-21 17:13:14 +08:00
|
|
|
|
_launchRocket(
|
|
|
|
|
|
rockets,
|
|
|
|
|
|
w,
|
|
|
|
|
|
h,
|
|
|
|
|
|
config.colors,
|
|
|
|
|
|
config.getLaunchX,
|
|
|
|
|
|
config.getTargetY,
|
|
|
|
|
|
);
|
|
|
|
|
|
launchCnt++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, config.launchEvery);
|
|
|
|
|
|
|
|
|
|
|
|
// 开场礼炮先把气氛撑起来,避免一开始太空。
|
|
|
|
|
|
if (typeof config.openingVolley === "function") {
|
2026-04-25 02:52:30 +08:00
|
|
|
|
timers.push(setTimeout(() => {
|
2026-04-21 17:13:14 +08:00
|
|
|
|
config.openingVolley(rockets, w, h);
|
2026-04-25 02:52:30 +08:00
|
|
|
|
}, 120));
|
2026-04-21 17:13:14 +08:00
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
const startTime = performance.now();
|
|
|
|
|
|
|
|
|
|
|
|
function animate(now) {
|
2026-04-21 17:13:14 +08:00
|
|
|
|
_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];
|
2026-04-25 02:52:30 +08:00
|
|
|
|
_appendParticlesWithinBudget(
|
2026-04-21 17:13:14 +08:00
|
|
|
|
particles,
|
|
|
|
|
|
_burst(burst.x, burst.y, burst.color, burst.type, burst.density),
|
2026-04-25 02:52:30 +08:00
|
|
|
|
peakParticleBudget,
|
2026-04-21 17:13:14 +08:00
|
|
|
|
);
|
|
|
|
|
|
halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius));
|
|
|
|
|
|
scheduledBursts.splice(i, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-27 14:14:35 +08:00
|
|
|
|
|
2026-02-27 14:26:50 +08:00
|
|
|
|
for (let i = rockets.length - 1; i >= 0; i--) {
|
2026-04-21 17:13:14 +08:00
|
|
|
|
const rocket = rockets[i];
|
|
|
|
|
|
if (rocket.done) {
|
2026-04-25 02:52:30 +08:00
|
|
|
|
_appendParticlesWithinBudget(
|
2026-04-21 17:13:14 +08:00
|
|
|
|
particles,
|
|
|
|
|
|
_burst(
|
|
|
|
|
|
rocket.x,
|
|
|
|
|
|
rocket.y,
|
|
|
|
|
|
rocket.color,
|
|
|
|
|
|
rocket.type,
|
2026-04-25 02:52:30 +08:00
|
|
|
|
particleDensity,
|
2026-04-21 17:13:14 +08:00
|
|
|
|
),
|
2026-04-25 02:52:30 +08:00
|
|
|
|
peakParticleBudget,
|
2026-04-21 17:13:14 +08:00
|
|
|
|
);
|
|
|
|
|
|
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",
|
2026-04-25 02:52:30 +08:00
|
|
|
|
density: secondaryDensity,
|
2026-04-21 17:13:14 +08:00
|
|
|
|
haloRadius: config.secondaryHaloRadius,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 14:26:50 +08:00
|
|
|
|
rockets.splice(i, 1);
|
|
|
|
|
|
} else {
|
2026-04-21 17:13:14 +08:00
|
|
|
|
rocket.update();
|
|
|
|
|
|
rocket.draw(ctx);
|
2026-02-27 14:26:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:13:14 +08:00
|
|
|
|
particles = particles.filter((particle) => particle.alive);
|
|
|
|
|
|
particles.forEach((particle) => {
|
|
|
|
|
|
particle.update();
|
|
|
|
|
|
particle.draw(ctx);
|
2026-02-27 14:14:35 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-21 17:13:14 +08:00
|
|
|
|
const elapsed = now - startTime;
|
|
|
|
|
|
const shouldContinue = elapsed < duration
|
|
|
|
|
|
|| rockets.length > 0
|
|
|
|
|
|
|| particles.length > 0
|
|
|
|
|
|
|| scheduledBursts.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldContinue && elapsed < hardStopAt) {
|
2026-02-27 14:14:35 +08:00
|
|
|
|
animId = requestAnimationFrame(animate);
|
|
|
|
|
|
} else {
|
2026-04-25 02:52:30 +08:00
|
|
|
|
finish(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
animId = requestAnimationFrame(animate);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 统一结束烟花演出,取消时不再回调管理器。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {boolean} canceled 是否为手动取消
|
|
|
|
|
|
*/
|
|
|
|
|
|
function finish(canceled) {
|
|
|
|
|
|
if (finished) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
finished = true;
|
|
|
|
|
|
clearInterval(launchInterval);
|
|
|
|
|
|
timers.forEach((timer) => clearTimeout(timer));
|
|
|
|
|
|
if (animId) {
|
2026-02-27 14:14:35 +08:00
|
|
|
|
cancelAnimationFrame(animId);
|
2026-04-25 02:52:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
rockets = [];
|
|
|
|
|
|
particles = [];
|
|
|
|
|
|
halos = [];
|
|
|
|
|
|
scheduledBursts = [];
|
|
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
|
|
|
|
|
|
|
|
if (!canceled) {
|
2026-02-27 14:14:35 +08:00
|
|
|
|
onEnd();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
|
return {
|
|
|
|
|
|
cancel() {
|
|
|
|
|
|
finish(true);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-02-27 14:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 17:13:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 启动烟花特效(普通版)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {HTMLCanvasElement} canvas 全屏 Canvas
|
|
|
|
|
|
* @param {Function} onEnd 特效结束回调
|
|
|
|
|
|
*/
|
|
|
|
|
|
function start(canvas, onEnd) {
|
2026-04-25 02:52:30 +08:00
|
|
|
|
return _runShow(canvas, onEnd, {
|
2026-04-21 17:13:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
));
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 18:35:08 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 启动婚礼加倍烟花特效(双侧轮流发射,粒子增倍,持续更久)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @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", // 其他
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-04-25 02:52:30 +08:00
|
|
|
|
return _runShow(canvas, onEnd, {
|
2026-04-21 17:13:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
));
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-03-01 18:35:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { start, startDouble };
|
2026-02-27 14:14:35 +08:00
|
|
|
|
})();
|
2026-04-25 03:02:56 +08:00
|
|
|
|
|
|
|
|
|
|
window.FireworksEffect = FireworksEffect;
|