feat(design): Raeumfahrzeug im Theme Snow

Faehrt gleich nach dem Laden und dann alle vier Minuten abwechselnd
von links und rechts ueber den Boden, raeumt die Schneedecke und schiebt
den Schnee als Wall mit fliegenden Brocken aus dem Fenster.

Nebenbei SnowWeather.tsx wieder im Stil des Projekts formatiert
(einfache Anfuehrungszeichen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 18:12:32 +02:00
co-authored by Claude Opus 5
parent 831429d559
commit 575de2e954
3 changed files with 236 additions and 27 deletions
+229 -25
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react";
import React, { useEffect, useRef } from 'react';
/*
* Das Wetter des Themes "Snow": Schneefall ueber dem Bildschirm, und was
@@ -16,8 +16,22 @@ import React, { useEffect, useRef } from "react";
* mehr als einen schmalen Streifen am Rand verdeckt.
*
* Die Decke lebt nur im Speicher: neu laden, und der Boden ist wieder frei.
*
* Alle vier Minuten - und einmal gleich nach dem Laden - faehrt ein kleines
* Raeumfahrzeug ueber den Boden, abwechselnd nach rechts und nach links. Es
* setzt jede Spalte, ueber die seine Schaufel faehrt, auf null und schiebt den
* Schnee als Wall vor sich her, der am Fensterrand verschwindet. Vier Minuten
* sind kein Zufall: so lange braucht die Decke, bis sie voll ist.
*/
interface Chunk {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
interface Flake {
x: number;
y: number;
@@ -40,27 +54,33 @@ const SPAWN_MIN_MS = 70;
const SPAWN_MAX_MS = 160;
const MAX_FLAKES = 70;
/** Abstand zwischen zwei Raeumfahrten; die erste startet kurz nach dem Laden. */
const PLOW_INTERVAL_MS = 4 * 60 * 1000;
const PLOW_FIRST_MS = 2500;
/** px/s - ueber ein breites Fenster in gut zehn Sekunden. */
const PLOW_SPEED = 140;
/** Vom Mittelpunkt des Fahrzeugs bis zur Schaufelkante bzw. zum Heck. */
const PLOW_FRONT = 38;
const PLOW_REAR = 36;
function random(min: number, max: number) {
return min + Math.random() * (max - min);
}
function prefersReducedMotion() {
return (
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
);
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;
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)"),
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)'),
};
}
@@ -69,7 +89,7 @@ export const SnowWeather: React.FC = () => {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx || prefersReducedMotion()) return;
let width = 0;
@@ -89,19 +109,13 @@ export const SnowWeather: React.FC = () => {
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),
)
];
next[i] = pile[Math.min(pile.length - 1, Math.floor((i / next.length) * pile.length))];
}
}
pile = next;
};
resize();
window.addEventListener("resize", resize);
window.addEventListener('resize', resize);
const flakes: Flake[] = [];
let nextSpawn = 0;
@@ -110,6 +124,12 @@ export const SnowWeather: React.FC = () => {
let colors = snowColors();
let colorsReadAt = 0;
// Raeumfahrzeug: null = steht in der Garage.
let plow: { x: number; dir: 1 | -1; pushed: number } | null = null;
let nextPlowDir: 1 | -1 = 1;
let nextPlowAt = performance.now() + PLOW_FIRST_MS;
const chunks: Chunk[] = [];
const spawn = () => {
if (flakes.length >= MAX_FLAKES) return;
const radius = random(1, 3);
@@ -149,8 +169,7 @@ export const SnowWeather: React.FC = () => {
*/
let smoothed = new Float32Array(0);
const settle = (dt: number) => {
if (smoothed.length !== pile.length)
smoothed = new Float32Array(pile.length);
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++) {
@@ -177,7 +196,7 @@ export const SnowWeather: React.FC = () => {
x0,
height - pile[i - 1],
(x0 + x1) / 2,
height - (pile[i - 1] + pile[i]) / 2,
height - (pile[i - 1] + pile[i]) / 2
);
}
ctx.lineTo(width, height - pile[pile.length - 1]);
@@ -200,7 +219,7 @@ export const SnowWeather: React.FC = () => {
x0,
height - pile[i - 1],
(x0 + x1) / 2,
height - (pile[i - 1] + pile[i]) / 2,
height - (pile[i - 1] + pile[i]) / 2
);
}
ctx.strokeStyle = colors.pileEdge;
@@ -208,6 +227,185 @@ export const SnowWeather: React.FC = () => {
ctx.stroke();
};
/**
* Das Fahrzeug, nach rechts gezeichnet und fuer die Rueckfahrt gespiegelt.
* Ursprung ist die Mitte auf dem Boden; alles in px.
*/
const drawPlow = (x: number, dir: 1 | -1, t: number) => {
ctx.save();
ctx.translate(x, height);
ctx.scale(dir, 1);
// Rundumleuchte: Schein zuerst, damit das Fahrzeug darueber liegt.
const beaconOn = Math.sin(t * 9) > 0;
if (beaconOn) {
const glow = ctx.createRadialGradient(-15, -41, 0, -15, -41, 16);
glow.addColorStop(0, 'rgb(255 170 40 / 0.7)');
glow.addColorStop(1, 'rgb(255 170 40 / 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(-15, -41, 16, 0, Math.PI * 2);
ctx.fill();
}
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgb(40 45 55 / 0.55)';
ctx.lineWidth = 1;
// Schaufelarm
ctx.fillStyle = '#5b6470';
ctx.fillRect(18, -15, 14, 4);
// Fahrgestell und Motorhaube
ctx.fillStyle = '#f2a900';
ctx.beginPath();
ctx.roundRect(-PLOW_REAR, -21, 56, 14, 3);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.roundRect(-4, -28, 24, 10, [5, 3, 0, 0]);
ctx.fill();
ctx.stroke();
// Fuehrerhaus mit Fenster
ctx.beginPath();
ctx.roundRect(-31, -38, 27, 19, [3, 3, 0, 0]);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#bfe3f5';
ctx.beginPath();
ctx.roundRect(-25, -35, 17, 10, 2);
ctx.fill();
// Warnstreifen
ctx.fillStyle = '#2b2f36';
for (let i = 0; i < 4; i++) ctx.fillRect(-30 + i * 12, -12, 5, 3);
// Schnee auf dem Dach
ctx.fillStyle = colors.pile;
ctx.beginPath();
ctx.ellipse(-19, -38, 11, 2.5, 0, Math.PI, Math.PI * 2);
ctx.fill();
// Leuchte auf dem Dach
ctx.fillStyle = beaconOn ? '#ffb020' : '#b86e00';
ctx.beginPath();
ctx.roundRect(-18, -43, 6, 5, [2, 2, 0, 0]);
ctx.fill();
// Raeder
for (const wheelX of [-22, 10]) {
ctx.fillStyle = '#2b2f36';
ctx.beginPath();
ctx.arc(wheelX, -6, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#9aa3ad';
ctx.beginPath();
ctx.arc(wheelX, -6, 2, 0, Math.PI * 2);
ctx.fill();
}
// Schaufel: schraeg gestellt, unten auf dem Boden
ctx.fillStyle = '#c9d1d9';
ctx.beginPath();
ctx.moveTo(28, -24);
ctx.lineTo(33, -25);
ctx.lineTo(PLOW_FRONT, 0);
ctx.lineTo(29, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
};
/** Der vorgeschobene Schnee: ein Huegel vor der Schaufel, der mit der Menge waechst. */
const drawWall = (bladeX: number, dir: 1 | -1, pushed: number) => {
if (pushed < 1) return;
// Hoeher als die Decke, damit er sich sichtbar vor der Schaufel auftuermt.
const wallHeight = Math.min(MAX_DEPTH + 8, 6 + Math.sqrt(pushed) * 1.2);
const wallWidth = wallHeight * 1.6;
ctx.beginPath();
ctx.moveTo(bladeX - dir * 2, height);
ctx.lineTo(bladeX - dir * 2, height - wallHeight);
ctx.quadraticCurveTo(
bladeX + dir * wallWidth * 0.55,
height - wallHeight * 1.05,
bladeX + dir * wallWidth,
height
);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, height - wallHeight, 0, height);
gradient.addColorStop(0, colors.pile);
gradient.addColorStop(1, colors.pileShade);
ctx.fillStyle = gradient;
ctx.fill();
ctx.strokeStyle = colors.pileEdge;
ctx.lineWidth = 1;
ctx.stroke();
};
/** Eine Fahrt: Schaufel vorwaerts, geraeumte Spalten auf null, Menge in den Wall. */
const drivePlow = (now: number, dt: number) => {
if (!plow) {
if (now < nextPlowAt) return;
const dir = nextPlowDir;
plow = { x: dir === 1 ? -PLOW_FRONT - 40 : width + PLOW_FRONT + 40, dir, pushed: 0 };
nextPlowDir = dir === 1 ? -1 : 1;
nextPlowAt = now + PLOW_INTERVAL_MS;
}
plow.x += plow.dir * PLOW_SPEED * dt;
const blade = plow.x + plow.dir * PLOW_FRONT;
// Alles hinter der Schaufelkante raeumen, auch was unter das Fahrzeug
// gefallen ist - die Spur bleibt frei.
const left = plow.dir === 1 ? plow.x - PLOW_REAR - COLUMN * 4 : blade;
const right = plow.dir === 1 ? blade : plow.x + PLOW_REAR + COLUMN * 4;
const from = Math.max(0, Math.ceil(left / COLUMN));
const to = Math.min(pile.length - 1, Math.floor(right / COLUMN));
let cleared = 0;
for (let i = from; i <= to; i++) {
cleared += pile[i];
pile[i] = 0;
}
plow.pushed += cleared;
// Ein paar Brocken fliegen von der Schaufel, solange sie Schnee schiebt.
if (plow.pushed > 5 && blade > 0 && blade < width && Math.random() < dt * 14) {
chunks.push({
x: blade,
y: height - Math.min(20, 2 + Math.sqrt(plow.pushed)),
vx: plow.dir * random(40, 120),
vy: -random(60, 140),
radius: random(1.2, 2.6),
});
}
const gone = plow.dir === 1 ? plow.x - PLOW_REAR > width + 60 : plow.x + PLOW_REAR < -60;
if (gone) plow = null;
};
const drawChunks = (dt: number) => {
ctx.fillStyle = colors.flake;
ctx.strokeStyle = colors.flakeEdge;
ctx.lineWidth = 0.6;
for (let i = chunks.length - 1; i >= 0; i--) {
const c = chunks[i];
c.vy += 600 * dt;
c.x += c.vx * dt;
c.y += c.vy * dt;
if (c.y >= height) {
chunks.splice(i, 1);
continue;
}
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
};
const tick = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
@@ -246,7 +444,13 @@ export const SnowWeather: React.FC = () => {
}
settle(dt);
drivePlow(now, dt);
drawPile();
if (plow) {
drawWall(plow.x + plow.dir * PLOW_FRONT, plow.dir, plow.pushed);
drawPlow(plow.x, plow.dir, t);
}
drawChunks(dt);
frame = requestAnimationFrame(tick);
};
@@ -254,7 +458,7 @@ export const SnowWeather: React.FC = () => {
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", resize);
window.removeEventListener('resize', resize);
};
}, []);