Files

620 lines
21 KiB
JavaScript
Raw Permalink 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, drift = 0) {
this.x = x;
this.y = canvasHeight; // 从画布底部出发
this.targetY = targetY;
this.color = color;
this.type = type;
this.vx = drift;
// 根据飞行距离动态计算初始速度,保证必然到达目标高度
// 等比级数求和: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 > 12) {
this.trail.shift();
}
this.x += this.vx;
this.y += this.vy;
this.vx *= 0.992;
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, options = {}) {
this.x = x;
this.y = y;
this.color = color;
this.trail = [];
this.innerColor = options.innerColor ?? "#ffffff";
this.trailLimit = options.trailLimit ?? 8;
this.drag = options.drag ?? 0.985;
this.radiusScale = options.radiusScale ?? 1;
let speed;
if (type === "ring") {
// 环形:均匀角度,固定速度
speed = 5.8 + Math.random() * 2.2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.gravity = 0.06;
this.decay = 0.012;
this.radius = 2.2 * this.radiusScale;
} else if (type === "willow") {
// 柳叶:慢速,在空中下垂
speed = Math.random() * 3.8 + 1.4;
const a = Math.random() * Math.PI * 2;
this.vx = Math.cos(a) * speed;
this.vy = Math.sin(a) * speed - 2.4; // 初速稍微向上
this.gravity = 0.072;
this.decay = 0.0075; // 衰减慢,拖出长尾
this.radius = 1.7 * this.radiusScale;
} else {
// sphere:标准球形爆炸
speed = Math.random() * 6.8 + 2.4;
const a = Math.random() * Math.PI * 2;
this.vx = Math.cos(a) * speed;
this.vy = Math.sin(a) * speed;
this.gravity = 0.095;
this.decay = 0.0135;
this.radius = (Math.random() * 2.2 + 1.7) * this.radiusScale;
}
this.alpha = 1;
// 部分粒子有闪烁效果
this.sparkle = Math.random() > 0.45;
this.frame = 0;
}
/** 更新粒子物理状态 */
update() {
this.frame++;
// 保存轨迹历史(尾迹长度由透明度控制)
this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > this.trailLimit) {
this.trail.shift();
}
this.vy += this.gravity;
this.x += this.vx;
this.y += this.vy;
this.vx *= this.drag;
this.vy *= this.drag;
// 闪烁:每隔几帧透明度轻微抖动
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.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();
ctx.restore();
}
get alive() {
return this.alpha > 0.01;
}
}
// ─── 爆炸光晕类 ──────────────────────────────────────
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;
}
}
// ─── 预定义颜色 / 类型 ───────────────────────────────
const COLORS = [
"#ff2200",
"#ff7700",
"#ffcc00",
"#00dd44",
"#cc00ff",
"#ff0088",
"#00bbff",
"#ff4488",
"#ffaa00",
];
const TYPES = ["sphere", "willow", "ring"];
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();
}
/**
* 生成一批爆炸粒子
*
* @param {number} x 爆炸中心 x
* @param {number} y 爆炸中心 y
* @param {string} color 颜色
* @param {string} type 爆炸类型
* @param {number} density 粒子密度倍率
* @returns {Particle[]}
*/
function _burst(x, y, color, type, density = 1) {
const particles = [];
const baseCount = type === "ring" ? 120 : type === "willow" ? 170 : 145;
const count = Math.round(baseCount * density);
const accentColor = _pick(FLASH_COLORS);
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));
}
// 核心补一圈亮色星火,让爆点更饱满。
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;
particles.push(p);
}
}
// 再补一层高亮碎火,提升烟花“炸开”的亮度与体积感。
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);
}
return particles;
}
/**
* 批量把新增粒子追加到现有数组,避免频繁 concat 产生新数组。
*
* @param {Particle[]} target
* @param {Particle[]} incoming
*/
function _appendParticles(target, incoming) {
for (let i = 0; i < incoming.length; i++) {
target.push(incoming[i]);
}
}
/**
* 统一发射一枚火箭。
*
* @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) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const duration = config.duration;
const hardStopAt = duration + 2600;
const peakParticleBudget = config.peakParticleBudget ?? 1650;
let rockets = [];
let particles = [];
let halos = [];
let scheduledBursts = [];
let animId = null;
let launchCnt = 0;
const launchInterval = setInterval(() => {
if (launchCnt >= config.maxLaunches) {
clearInterval(launchInterval);
return;
}
const batchSize = config.getBatchSize(launchCnt);
for (let i = 0; i < batchSize && launchCnt < config.maxLaunches; i++) {
_launchRocket(
rockets,
w,
h,
config.colors,
config.getLaunchX,
config.getTargetY,
);
launchCnt++;
}
}, config.launchEvery);
// 开场礼炮先把气氛撑起来,避免一开始太空。
if (typeof config.openingVolley === "function") {
setTimeout(() => {
config.openingVolley(rockets, w, h);
}, 120);
}
const startTime = performance.now();
function animate(now) {
_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];
_appendParticles(
particles,
_burst(burst.x, burst.y, burst.color, burst.type, burst.density),
);
halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius));
scheduledBursts.splice(i, 1);
}
}
for (let i = rockets.length - 1; i >= 0; i--) {
const rocket = rockets[i];
if (rocket.done) {
_appendParticles(
particles,
_burst(
rocket.x,
rocket.y,
rocket.color,
rocket.type,
config.particleDensity,
),
);
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",
density: config.secondaryDensity,
haloRadius: config.secondaryHaloRadius,
});
}
rockets.splice(i, 1);
} else {
rocket.update();
rocket.draw(ctx);
}
}
particles = particles.filter((particle) => particle.alive);
particles.forEach((particle) => {
particle.update();
particle.draw(ctx);
});
const elapsed = now - startTime;
const shouldContinue = elapsed < duration
|| rockets.length > 0
|| particles.length > 0
|| scheduledBursts.length > 0;
if (shouldContinue && elapsed < hardStopAt) {
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 start(canvas, onEnd) {
_runShow(canvas, onEnd, {
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,
));
});
},
});
}
/**
* 启动婚礼加倍烟花特效(双侧轮流发射,粒子增倍,持续更久)
*
* @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", // 其他
];
_runShow(canvas, onEnd, {
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,
));
});
},
});
}
return { start, startDouble };
})();