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
+3
View File
@@ -250,6 +250,9 @@ ein `<canvas>` über dem Fenster (`z-[90]`, `pointer-events: none`), bei
| **Schneedecke** | Eine Höhenkarte in 3-px-Spalten über die Fensterbreite. Eine Flocke landet, sobald sie die Oberfläche **ihrer** Spalte erreicht, und verteilt sich auf fünf Spalten |
| **Glätten** | Diffusion: jede Spalte gleicht sich an ihre Nachbarn an, Faktor an der Zeit (`SPREAD`), nicht an der Framezahl - gleich auf 60 und 144 Hz. Mengenerhaltend, aus Einzelflocken werden flache Hügel |
| **Deckel** | `MAX_DEPTH` = 26 px, voll nach rund vier Minuten. Was danach landet, verschwindet |
| **Räumfahrzeug** | Startet 2,5 s nach dem Laden, dann alle 4 min (`PLOW_INTERVAL_MS`), Richtung wechselt jedes Mal. Fährt mit 140 px/s von außerhalb des Fensters herein, setzt jede Spalte hinter der Schaufelkante (und unter dem Fahrzeug) auf null und sammelt die Menge in `pushed`. Daraus wächst der Wall vor der Schaufel (Wurzel der Menge, gedeckelt auf `MAX_DEPTH + 8`); dazu fliegen Brocken. Hinter dem Fensterrand verschwindet alles. Gezeichnet im Canvas, nach rechts und für die Rückfahrt per `scale(-1, 1)` gespiegelt |
Vier Minuten sind abgestimmt: so lange braucht die Decke, bis sie voll ist.
Die Decke lebt nur im Speicher; beim Ändern der Fensterbreite wird sie auf die
neue Spaltenzahl umgerechnet, nach dem Neuladen ist der Boden frei.
+4 -2
View File
@@ -103,8 +103,10 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
alle paar Sekunden kurz auf.
* Bei **Snow** schneit es, und der Schnee bleibt unten liegen: nach etwa vier
Minuten ist am unteren Rand eine Schneedecke gewachsen. Sie bleibt ein
schmaler Streifen und wächst nicht weiter. Neu laden, und der Boden ist
wieder frei.
schmaler Streifen und wächst nicht weiter. Alle vier Minuten kommt ein
kleines **Räumfahrzeug** mit Rundumleuchte vorbei und schiebt den Schnee
vom Bildschirm - abwechselnd von links nach rechts und zurück. Die erste
Fahrt startet gleich nach dem Laden der Seite.
Wer im Betriebssystem **„Bewegung reduzieren"** eingeschaltet hat, sieht bei
beiden nur Farben und Grafiken - ohne Regen, Blitz und Schnee.
+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);
};
}, []);