diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 42a91a5..6312d03 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -250,6 +250,9 @@ ein `` ü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. diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index f93411c..42a7f76 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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. diff --git a/src/components/SnowWeather.tsx b/src/components/SnowWeather.tsx index eb3b7b4..cf303b4 100644 --- a/src/components/SnowWeather.tsx +++ b/src/components/SnowWeather.tsx @@ -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); }; }, []);