feat(design): neues Theme Snow mit Schneefall und Schneedecke
Eisblau in hell und dunkel. Flocken fallen per Canvas und sammeln sich am unteren Fensterrand in einer Hoehenkarte, die per Diffusion geglaettet wird; nach rund vier Minuten ist die Decke voll (26 px). Bei prefers-reduced-motion bleibt es still. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
77b61b4a04
commit
831429d559
@@ -0,0 +1,268 @@
|
||||
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<HTMLCanvasElement>(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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed inset-0 z-[90] h-full w-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user