import React, { useEffect, useRef } from "react"; /* * Das Wetter des Themes "Snow": Schneefall ueber dem Bildschirm, und was * unten ankommt, bleibt liegen und haeuft sich am unteren Fensterrand an. * * Wie beim Regen (RainyWeather.tsx) ein Canvas ueber der ganzen Oberflaeche, * ohne Klicks abzufangen und unter den Dialogen (z-[100]). Bei "Bewegung * reduzieren" im Betriebssystem schneit es nicht. * * Die Schneedecke ist eine Hoehenkarte: der Fensterrand ist in schmale Spalten * geteilt, jede Flocke erhoeht die Spalte, in der sie landet, ein wenig. Damit * keine Nadeln wachsen, gleicht sich jede Spalte laufend an ihre Nachbarn an - * so entstehen weiche Verwehungen. Die Decke waechst langsam * (bis zur vollen Hoehe rund vier Minuten) und ist gedeckelt, damit sie nie * mehr als einen schmalen Streifen am Rand verdeckt. * * Die Decke lebt nur im Speicher: neu laden, und der Boden ist wieder frei. */ interface Flake { x: number; y: number; radius: number; /** Fallgeschwindigkeit in px/s */ vy: number; /** Phase und Staerke des seitlichen Schaukelns */ phase: number; sway: number; } /** Breite einer Spalte der Hoehenkarte in px. */ const COLUMN = 3; /** Hoechste Schneedecke in px - danach schmilzt, was noch landet. */ const MAX_DEPTH = 26; /** Wie schnell sich die Decke glaettet (Diffusion, Spalten^2 je Sekunde). */ const SPREAD = 6; const SPAWN_MIN_MS = 70; const SPAWN_MAX_MS = 160; const MAX_FLAKES = 70; function random(min: number, max: number) { return min + Math.random() * (max - min); } function prefersReducedMotion() { return ( window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false ); } /** Farben aus index.css, damit hell und dunkel passen. */ function snowColors() { const style = getComputedStyle(document.documentElement); const read = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback; return { flake: read("--snow-flake", "#ffffff"), flakeEdge: read("--snow-flake-edge", "rgb(120 145 170 / 0.35)"), pile: read("--snow-pile", "#ffffff"), pileShade: read("--snow-pile-shade", "#dbe6f1"), pileEdge: read("--snow-pile-edge", "rgb(120 145 170 / 0.5)"), }; } export const SnowWeather: React.FC = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx || prefersReducedMotion()) return; let width = 0; let height = 0; let pile = new Float32Array(0); const resize = () => { const dpr = window.devicePixelRatio || 1; width = window.innerWidth; height = window.innerHeight; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Die Decke beim Aendern der Fensterbreite mitnehmen statt wegwerfen: // auf die neue Spaltenzahl umrechnen. const next = new Float32Array(Math.ceil(width / COLUMN) + 1); if (pile.length > 0) { for (let i = 0; i < next.length; i++) { next[i] = pile[ Math.min( pile.length - 1, Math.floor((i / next.length) * pile.length), ) ]; } } pile = next; }; resize(); window.addEventListener("resize", resize); const flakes: Flake[] = []; let nextSpawn = 0; let last = performance.now(); let frame = 0; let colors = snowColors(); let colorsReadAt = 0; const spawn = () => { if (flakes.length >= MAX_FLAKES) return; const radius = random(1, 3); flakes.push({ x: random(-20, width + 20), y: -radius - random(0, 30), radius, // Grosse Flocken fallen etwas schneller - das gibt Tiefe. vy: random(28, 45) + radius * 14, phase: random(0, Math.PI * 2), sway: random(8, 22), }); }; /** Flocke landet: Spalte erhoehen, die Nachbarn etwas mit. */ const land = (x: number, radius: number) => { const i = Math.round(x / COLUMN); const amount = radius * 5; const add = (index: number, value: number) => { if (index < 0 || index >= pile.length) return; pile[index] = Math.min(MAX_DEPTH, pile[index] + value); }; // Ueber fuenf Spalten verteilt statt als Spitze: sonst waechst die Decke // als Saegezahn. add(i, amount * 0.36); add(i - 1, amount * 0.24); add(i + 1, amount * 0.24); add(i - 2, amount * 0.08); add(i + 2, amount * 0.08); }; /* * Glaetten: jede Spalte gleicht sich ein wenig an ihre Nachbarn an * (Diffusion). Das erhaelt die Schneemenge, laesst einzelne Flocken zu * flachen Huegeln zerlaufen und bleibt auf 60 und 144 Hz gleich schnell, * weil der Faktor an der Zeit haengt und nicht an der Framezahl. */ let smoothed = new Float32Array(0); const settle = (dt: number) => { if (smoothed.length !== pile.length) smoothed = new Float32Array(pile.length); const k = Math.min(0.25, SPREAD * dt); const last = pile.length - 1; for (let i = 0; i <= last; i++) { const left = pile[i > 0 ? i - 1 : i]; const right = pile[i < last ? i + 1 : i]; smoothed[i] = pile[i] + k * (left + right - 2 * pile[i]); } pile.set(smoothed); }; const drawPile = () => { let top = 0; for (let i = 0; i < pile.length; i++) top = Math.max(top, pile[i]); if (top < 0.3) return; ctx.beginPath(); ctx.moveTo(0, height); ctx.lineTo(0, height - pile[0]); // Durch die Mittelpunkte zwischen den Spalten - weiche Kanten statt Treppe. for (let i = 1; i < pile.length; i++) { const x0 = (i - 1) * COLUMN; const x1 = i * COLUMN; ctx.quadraticCurveTo( x0, height - pile[i - 1], (x0 + x1) / 2, height - (pile[i - 1] + pile[i]) / 2, ); } ctx.lineTo(width, height - pile[pile.length - 1]); ctx.lineTo(width, height); ctx.closePath(); const gradient = ctx.createLinearGradient(0, height - top, 0, height); gradient.addColorStop(0, colors.pile); gradient.addColorStop(1, colors.pileShade); ctx.fillStyle = gradient; ctx.fill(); // Nur die Oberkante nachziehen, nicht den Rahmen unten und seitlich. ctx.beginPath(); ctx.moveTo(0, height - pile[0]); for (let i = 1; i < pile.length; i++) { const x0 = (i - 1) * COLUMN; const x1 = i * COLUMN; ctx.quadraticCurveTo( x0, height - pile[i - 1], (x0 + x1) / 2, height - (pile[i - 1] + pile[i]) / 2, ); } ctx.strokeStyle = colors.pileEdge; ctx.lineWidth = 1; ctx.stroke(); }; const tick = (now: number) => { const dt = Math.min((now - last) / 1000, 0.05); last = now; if (now - colorsReadAt > 500) { colors = snowColors(); colorsReadAt = now; } if (now >= nextSpawn) { spawn(); nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS); } ctx.clearRect(0, 0, width, height); ctx.fillStyle = colors.flake; ctx.strokeStyle = colors.flakeEdge; ctx.lineWidth = 0.6; const t = now / 1000; for (let i = flakes.length - 1; i >= 0; i--) { const flake = flakes[i]; flake.y += flake.vy * dt; const x = flake.x + Math.sin(t * 1.3 + flake.phase) * flake.sway; const column = Math.round(x / COLUMN); const ground = height - (pile[column] ?? 0); if (flake.y + flake.radius >= ground) { flakes.splice(i, 1); if (x >= 0 && x <= width) land(x, flake.radius); continue; } ctx.beginPath(); ctx.arc(x, flake.y, flake.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } settle(dt); drawPile(); frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => { cancelAnimationFrame(frame); window.removeEventListener("resize", resize); }; }, []); return (