Feat: 实现全屏特效系统(烟花/下雨/雷电),管理员一键触发全房间广播
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 文件功能:聊天室特效管理器
|
||||
*
|
||||
* 统一管理全屏 Canvas 特效的入口、防重入和资源清理。
|
||||
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning')
|
||||
*/
|
||||
|
||||
const EffectManager = (() => {
|
||||
// 当前正在播放的特效名称(防止同时播放两个特效)
|
||||
let _current = null;
|
||||
// 全屏 Canvas 元素引用
|
||||
let _canvas = null;
|
||||
|
||||
/**
|
||||
* 获取或创建全屏 Canvas 元素
|
||||
* 属性:fixed 定位,覆盖全屏,pointer-events:none 不阻止用户交互
|
||||
*/
|
||||
function _getCanvas() {
|
||||
if (_canvas && document.body.contains(_canvas)) {
|
||||
return _canvas;
|
||||
}
|
||||
const c = document.createElement("canvas");
|
||||
c.id = "effect-canvas";
|
||||
c.style.cssText = [
|
||||
"position:fixed",
|
||||
"top:0",
|
||||
"left:0",
|
||||
"width:100vw",
|
||||
"height:100vh",
|
||||
"z-index:99999",
|
||||
"pointer-events:none",
|
||||
].join(";");
|
||||
c.width = window.innerWidth;
|
||||
c.height = window.innerHeight;
|
||||
document.body.appendChild(c);
|
||||
_canvas = c;
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 特效结束后清理 Canvas,重置状态
|
||||
*/
|
||||
function _cleanup() {
|
||||
if (_canvas && document.body.contains(_canvas)) {
|
||||
document.body.removeChild(_canvas);
|
||||
}
|
||||
_canvas = null;
|
||||
_current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放指定特效
|
||||
*
|
||||
* @param {string} type 特效类型:fireworks / rain / lightning
|
||||
*/
|
||||
function play(type) {
|
||||
// 防重入:同时只允许一个特效
|
||||
if (_current) {
|
||||
console.log(
|
||||
`[EffectManager] 特效 ${_current} 正在播放,忽略 ${type}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = _getCanvas();
|
||||
_current = type;
|
||||
|
||||
switch (type) {
|
||||
case "fireworks":
|
||||
if (typeof FireworksEffect !== "undefined") {
|
||||
FireworksEffect.start(canvas, _cleanup);
|
||||
}
|
||||
break;
|
||||
case "rain":
|
||||
if (typeof RainEffect !== "undefined") {
|
||||
RainEffect.start(canvas, _cleanup);
|
||||
}
|
||||
break;
|
||||
case "lightning":
|
||||
if (typeof LightningEffect !== "undefined") {
|
||||
LightningEffect.start(canvas, _cleanup);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn(`[EffectManager] 未知特效类型:${type}`);
|
||||
_cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return { play };
|
||||
})();
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 文件功能:聊天室烟花特效
|
||||
*
|
||||
* 使用 Canvas 粒子系统在全屏播放多发烟花爆炸动画。
|
||||
* 特效总时长约 4 秒,结束后自动清理并回调。
|
||||
*/
|
||||
|
||||
const FireworksEffect = (() => {
|
||||
// 粒子类:模拟一个爆炸后的发光粒子
|
||||
class Particle {
|
||||
constructor(x, y, color) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
// 随机方向和速度
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = Math.random() * 6 + 2;
|
||||
this.vx = Math.cos(angle) * speed;
|
||||
this.vy = Math.sin(angle) * speed;
|
||||
this.alpha = 1;
|
||||
this.gravity = 0.12;
|
||||
this.decay = Math.random() * 0.012 + 0.012; // 透明度每帧衰减量
|
||||
this.radius = Math.random() * 3 + 1;
|
||||
}
|
||||
|
||||
/** 每帧更新粒子位置和状态 */
|
||||
update() {
|
||||
this.vy += this.gravity;
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.vx *= 0.98; // 空气阻力
|
||||
this.vy *= 0.98;
|
||||
this.alpha -= this.decay;
|
||||
}
|
||||
|
||||
/** 绘制粒子 */
|
||||
draw(ctx) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, this.alpha);
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 预定义烟花颜色组
|
||||
const COLORS = [
|
||||
"#ff4444",
|
||||
"#ff8800",
|
||||
"#ffdd00",
|
||||
"#44ff44",
|
||||
"#44ddff",
|
||||
"#8844ff",
|
||||
"#ff44cc",
|
||||
"#ffffff",
|
||||
"#ffaaaa",
|
||||
"#aaffaa",
|
||||
"#aaaaff",
|
||||
"#ffffaa",
|
||||
];
|
||||
|
||||
/**
|
||||
* 发射一枚烟花,返回粒子数组
|
||||
*
|
||||
* @param {number} x 爆炸中心 x
|
||||
* @param {number} y 爆炸中心 y
|
||||
* @param {number} count 粒子数量
|
||||
* @returns {Particle[]}
|
||||
*/
|
||||
function _burst(x, y, count) {
|
||||
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
const particles = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push(new Particle(x, y, color));
|
||||
}
|
||||
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 = 4500; // 总时长(ms)
|
||||
|
||||
let particles = [];
|
||||
let animId = null;
|
||||
let launchCount = 0;
|
||||
const MAX_LAUNCHES = 8; // 总共发射几枚烟花
|
||||
|
||||
// 定时发射烟花
|
||||
const launchInterval = setInterval(() => {
|
||||
if (launchCount >= MAX_LAUNCHES) {
|
||||
clearInterval(launchInterval);
|
||||
return;
|
||||
}
|
||||
const x = w * (0.15 + Math.random() * 0.7); // 避免贴近边缘
|
||||
const y = h * (0.1 + Math.random() * 0.5); // 在屏幕上半区爆炸
|
||||
const count = Math.floor(Math.random() * 40) + 60;
|
||||
particles = particles.concat(_burst(x, y, count));
|
||||
launchCount++;
|
||||
}, 500);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// 动画循环
|
||||
function animate(now) {
|
||||
// 用半透明黑色覆盖,产生运动拖尾效果
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.18)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 更新并绘制存活粒子
|
||||
particles = particles.filter((p) => p.alpha > 0.02);
|
||||
particles.forEach((p) => {
|
||||
p.update();
|
||||
p.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// 特效结束:清空 canvas 后回调
|
||||
clearInterval(launchInterval);
|
||||
cancelAnimationFrame(animId);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 文件功能:聊天室雷电特效
|
||||
*
|
||||
* 使用递归分叉算法在 Canvas 上绘制真实感闪电路径,
|
||||
* 配合全屏闪白效果模拟雷闪。总持续约 5 秒,结束后回调。
|
||||
*/
|
||||
|
||||
const LightningEffect = (() => {
|
||||
/**
|
||||
* 递归绘制闪电路径(分裂算法)
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x1 起点 x
|
||||
* @param {number} y1 起点 y
|
||||
* @param {number} x2 终点 x
|
||||
* @param {number} y2 终点 y
|
||||
* @param {number} depth 当前递归深度(控制分叉层数)
|
||||
* @param {number} width 线条宽度
|
||||
*/
|
||||
function _drawBolt(ctx, x1, y1, x2, y2, depth, width) {
|
||||
if (depth <= 0) return;
|
||||
|
||||
// 中点随机偏移(越深层偏移越小,产生流畅感)
|
||||
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * 80 * depth;
|
||||
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * 20 * depth;
|
||||
const glow = ctx.createLinearGradient(x1, y1, x2, y2);
|
||||
glow.addColorStop(0, "rgba(200, 220, 255, 0.9)");
|
||||
glow.addColorStop(1, "rgba(150, 180, 255, 0.6)");
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = glow;
|
||||
ctx.lineWidth = width;
|
||||
ctx.shadowColor = "#aaccff";
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.quadraticCurveTo(mx, my, x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// 递归绘制两段子路径
|
||||
_drawBolt(ctx, x1, y1, mx, my, depth - 1, width * 0.65);
|
||||
_drawBolt(ctx, mx, my, x2, y2, depth - 1, width * 0.65);
|
||||
|
||||
// 随机在中途分叉一条小支路(50% 概率)
|
||||
if (depth > 1 && Math.random() > 0.5) {
|
||||
const bx = mx + (Math.random() - 0.5) * 120;
|
||||
const by = my + Math.random() * 80 + 40;
|
||||
_drawBolt(ctx, mx, my, bx, by, depth - 2, width * 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一次闪电 + 闪屏效果
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
function _flash(canvas, ctx) {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// 闪屏:全屏短暂泛白
|
||||
ctx.fillStyle = "rgba(220, 235, 255, 0.55)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 绘制 1-3 条主闪电(从随机顶部位置向下延伸)
|
||||
const boltCount = Math.floor(Math.random() * 2) + 1;
|
||||
for (let i = 0; i < boltCount; i++) {
|
||||
const x1 = w * (0.2 + Math.random() * 0.6);
|
||||
const y1 = 0;
|
||||
const x2 = x1 + (Math.random() - 0.5) * 300;
|
||||
const y2 = h * (0.5 + Math.random() * 0.4);
|
||||
_drawBolt(ctx, x1, y1, x2, y2, 4, 3);
|
||||
}
|
||||
|
||||
// 50ms 后让画布逐渐消退(模拟闪电短促感)
|
||||
setTimeout(() => {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动雷电特效
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏 Canvas
|
||||
* @param {Function} onEnd 特效结束回调
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const FLASHES = 5; // 总闪电次数
|
||||
const DURATION = 5000; // 总时长(ms)
|
||||
let count = 0;
|
||||
|
||||
// 间隔不规则触发多次闪电(模拟真实雷电节奏)
|
||||
function nextFlash() {
|
||||
if (count >= FLASHES) {
|
||||
// 全部闪完,结束特效
|
||||
setTimeout(() => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
onEnd();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
_flash(canvas, ctx);
|
||||
count++;
|
||||
// 下次闪电间隔:800ms ~ 1200ms 之间随机
|
||||
const delay = 700 + Math.random() * 500;
|
||||
setTimeout(nextFlash, delay);
|
||||
}
|
||||
|
||||
// 短暂延迟后开始第一次闪电
|
||||
setTimeout(nextFlash, 300);
|
||||
|
||||
// 安全兜底:超时强制结束
|
||||
setTimeout(() => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
onEnd();
|
||||
}, DURATION + 500);
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 文件功能:聊天室下雨特效
|
||||
*
|
||||
* 使用 Canvas 绘制斜向雨线,模拟真实降雨视觉效果。
|
||||
* 特效总时长约 8 秒,结束后自动清理并回调。
|
||||
*/
|
||||
|
||||
const RainEffect = (() => {
|
||||
// 雨滴类:一条从顶部往下落的斜线
|
||||
class Drop {
|
||||
constructor(w, h) {
|
||||
this.reset(w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置/初始化雨滴位置
|
||||
*
|
||||
* @param {number} w Canvas 宽度
|
||||
* @param {number} h Canvas 高度
|
||||
*/
|
||||
reset(w, h) {
|
||||
this.x = Math.random() * w;
|
||||
this.y = Math.random() * -h; // 从屏幕上方随机位置开始
|
||||
this.len = Math.random() * 20 + 10; // 雨线长度
|
||||
this.speed = Math.random() * 8 + 6; // 下落速度
|
||||
this.angle = (Math.PI / 180) * (75 + Math.random() * 10); // 倾斜角(接近竖直偏右)
|
||||
this.alpha = Math.random() * 0.3 + 0.2; // 透明度
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
}
|
||||
|
||||
/** 每帧更新雨滴位置 */
|
||||
update() {
|
||||
this.x += Math.cos(this.angle) * this.speed * 0.3;
|
||||
this.y += Math.sin(this.angle) * this.speed;
|
||||
// 落出屏幕后重置
|
||||
if (this.y > this.h + this.len) {
|
||||
this.reset(this.w, this.h);
|
||||
}
|
||||
}
|
||||
|
||||
/** 绘制雨滴线段 */
|
||||
draw(ctx) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = `rgba(155, 200, 255, ${this.alpha})`;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.x, this.y);
|
||||
ctx.lineTo(
|
||||
this.x + Math.cos(this.angle) * this.len,
|
||||
this.y + Math.sin(this.angle) * this.len,
|
||||
);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动下雨特效
|
||||
*
|
||||
* @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 = 8000; // 总时长(ms)
|
||||
const DROP_COUNT = 180; // 雨滴数量
|
||||
|
||||
// 初始化所有雨滴,随机分布在屏幕各处(避免开始时从顶部一起落)
|
||||
const drops = Array.from({ length: DROP_COUNT }, () => {
|
||||
const d = new Drop(w, h);
|
||||
d.y = Math.random() * h; // 初始 Y 随机,不全部从顶部开始
|
||||
return d;
|
||||
});
|
||||
|
||||
let animId = null;
|
||||
const startTime = performance.now();
|
||||
|
||||
// 画"乌云"背景遮罩(让画面有阴暗感但不完全遮住聊天)
|
||||
ctx.fillStyle = "rgba(30, 40, 60, 0.18)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
function animate(now) {
|
||||
// 用极轻微的透明背景刷新(保留少量拖尾感)
|
||||
ctx.fillStyle = "rgba(30, 40, 60, 0.08)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
drops.forEach((d) => {
|
||||
d.update();
|
||||
d.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
cancelAnimationFrame(animId);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
Reference in New Issue
Block a user