/** * 文件功能:聊天室下雪特效 * * 使用 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 }; })();