239 lines
7.7 KiB
JavaScript
239 lines
7.7 KiB
JavaScript
/**
|
|
* 文件功能:聊天室下雪特效
|
|
*
|
|
* 使用 Canvas 同时绘制远景小雪与近景六角雪花,
|
|
* 通过层次、大小、速度差营造更饱满的飘雪效果。
|
|
*/
|
|
|
|
const SnowEffect = (() => {
|
|
/**
|
|
* 在指定位置绘制一朵六角雪花(深色轮廓 + 白色主体)
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {number} x 中心 x
|
|
* @param {number} y 中心 y
|
|
* @param {number} r 主臂长度
|
|
* @param {number} alpha 透明度
|
|
* @param {number} rot 旋转角度(弧度)
|
|
*/
|
|
function _drawFlake(ctx, x, y, r, alpha, rot) {
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
ctx.lineCap = "round";
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rot);
|
|
|
|
// 两遍绘制:先深蓝色粗描边,再白色细线覆盖
|
|
// 这样在浅蓝、白色等背景上都清晰可辨
|
|
const passes = [
|
|
{ color: "rgba(30, 60, 140, 0.8)", lw: r * 0.22 + 2.5 }, // 深蓝粗描边
|
|
{ color: "rgba(255, 255, 255, 1.0)", lw: Math.max(1, r * 0.11) }, // 白色主体
|
|
];
|
|
|
|
passes.forEach(({ color, lw }) => {
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = lw;
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((Math.PI / 3) * i);
|
|
|
|
// 主臂
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(r, 0);
|
|
ctx.stroke();
|
|
|
|
// 斜向分叉(0.4r 和 0.65r 处各一对)
|
|
const branchLen = r * 0.35;
|
|
const branchAngle = Math.PI / 4; // 45°
|
|
|
|
[0.4, 0.65].forEach((pos) => {
|
|
const bx = r * pos;
|
|
// 上分叉
|
|
ctx.beginPath();
|
|
ctx.moveTo(bx, 0);
|
|
ctx.lineTo(
|
|
bx + Math.cos(branchAngle) * branchLen,
|
|
Math.sin(branchAngle) * branchLen,
|
|
);
|
|
ctx.stroke();
|
|
// 下分叉
|
|
ctx.beginPath();
|
|
ctx.moveTo(bx, 0);
|
|
ctx.lineTo(
|
|
bx + Math.cos(branchAngle) * branchLen,
|
|
-Math.sin(branchAngle) * branchLen,
|
|
);
|
|
ctx.stroke();
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制远景小雪点,让画面更密实,不会只看到零散大雪花。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {number} x
|
|
* @param {number} y
|
|
* @param {number} radius
|
|
* @param {number} alpha
|
|
*/
|
|
function _drawSoftSnow(ctx, x, y, radius, alpha) {
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = "rgba(255,255,255,0.95)";
|
|
ctx.shadowColor = "rgba(255,255,255,0.8)";
|
|
ctx.shadowBlur = 8;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
// 雪花粒子类
|
|
class Flake {
|
|
constructor(w, h, layer = "front") {
|
|
this.w = w;
|
|
this.h = h;
|
|
this.layer = layer;
|
|
this.reset(true);
|
|
}
|
|
|
|
/**
|
|
* 重置雪花,让前景和背景使用不同参数。
|
|
*
|
|
* @param {boolean} initial
|
|
*/
|
|
reset(initial = false) {
|
|
this.x = Math.random() * this.w;
|
|
this.y = initial ? Math.random() * this.h : -20;
|
|
if (this.layer === "back") {
|
|
this.r = Math.random() * 2 + 1.1;
|
|
this.speed = Math.random() * 0.55 + 0.28;
|
|
this.drift = Math.random() * 0.28 - 0.14;
|
|
this.alpha = Math.random() * 0.25 + 0.28;
|
|
} else {
|
|
this.r = Math.random() * 9 + 6.5;
|
|
this.speed = Math.random() * 1.05 + 0.48;
|
|
this.drift = Math.random() * 0.7 - 0.35;
|
|
this.alpha = Math.random() * 0.25 + 0.68;
|
|
}
|
|
this.rot = Math.random() * Math.PI * 2;
|
|
this.rotSpd = (Math.random() - 0.5) * (this.layer === "back" ? 0.008 : 0.018);
|
|
this.wobble = 0;
|
|
this.wobSpd = Math.random() * (this.layer === "back" ? 0.02 : 0.028) + 0.008;
|
|
}
|
|
|
|
update() {
|
|
this.wobble += this.wobSpd;
|
|
this.x += Math.sin(this.wobble) * (this.layer === "back" ? 0.28 : 0.58) + this.drift;
|
|
this.y += this.speed;
|
|
this.rot += this.rotSpd;
|
|
if (this.y > this.h + 20) {
|
|
this.reset(false);
|
|
}
|
|
}
|
|
|
|
draw(ctx) {
|
|
if (this.layer === "back") {
|
|
_drawSoftSnow(ctx, this.x, this.y, this.r, this.alpha);
|
|
return;
|
|
}
|
|
|
|
_drawFlake(ctx, this.x, this.y, this.r, this.alpha, this.rot);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 启动下雪特效
|
|
*
|
|
* @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;
|
|
const flakes = [
|
|
...Array.from(
|
|
{ length: Math.min(120, Math.max(70, Math.floor(w / 18))) },
|
|
() => new Flake(w, h, "back"),
|
|
),
|
|
...Array.from(
|
|
{ length: Math.min(64, Math.max(34, Math.floor(w / 42))) },
|
|
() => new Flake(w, h, "front"),
|
|
),
|
|
];
|
|
|
|
const breezeBands = Array.from({ length: 2 }, () => ({
|
|
x: Math.random() * w,
|
|
y: Math.random() * h,
|
|
radius: 180 + Math.random() * 140,
|
|
alpha: Math.random() * 0.05 + 0.025,
|
|
drift: Math.random() * 0.3 + 0.08,
|
|
}));
|
|
|
|
let animId = null;
|
|
const startTime = performance.now();
|
|
|
|
function animate(now) {
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// 加一层极淡的冷白雾感,让雪景更有氛围但不遮挡聊天内容。
|
|
const mist = ctx.createLinearGradient(0, 0, 0, h);
|
|
mist.addColorStop(0, "rgba(226,240,255,0.08)");
|
|
mist.addColorStop(0.4, "rgba(226,240,255,0.03)");
|
|
mist.addColorStop(1, "rgba(226,240,255,0)");
|
|
ctx.fillStyle = mist;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
breezeBands.forEach((band) => {
|
|
band.x += band.drift;
|
|
if (band.x - band.radius > w) {
|
|
band.x = -band.radius;
|
|
band.y = Math.random() * h;
|
|
}
|
|
|
|
const breeze = ctx.createRadialGradient(
|
|
band.x,
|
|
band.y,
|
|
0,
|
|
band.x,
|
|
band.y,
|
|
band.radius,
|
|
);
|
|
breeze.addColorStop(0, `rgba(255,255,255,${band.alpha})`);
|
|
breeze.addColorStop(1, "rgba(255,255,255,0)");
|
|
ctx.fillStyle = breeze;
|
|
ctx.beginPath();
|
|
ctx.arc(band.x, band.y, band.radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
|
|
flakes.forEach((f) => {
|
|
f.update();
|
|
f.draw(ctx);
|
|
});
|
|
|
|
if (now - startTime < DURATION) {
|
|
animId = requestAnimationFrame(animate);
|
|
} else {
|
|
cancelAnimationFrame(animId);
|
|
ctx.clearRect(0, 0, w, h);
|
|
onEnd();
|
|
}
|
|
}
|
|
|
|
animId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
return { start };
|
|
})();
|