diff --git a/README.md b/README.md index dc51859..571d651 100644 --- a/README.md +++ b/README.md @@ -91,18 +91,29 @@ mindestens Vorgesetzter ist. Sieben Farbwelten, jede in hell und dunkel, gespeichert am Benutzer (`pulse_users.theme`, `theme_mode`) und vor dem ersten Bild gesetzt (Inline-Skript -in `index.html`). Sie kommen 1:1 aus dem perinco CRM; drei bringen Bewegung mit: +in `index.html`). Sie kommen 1:1 aus dem perinco CRM; vier bringen Bewegung mit: | Theme | Bewegung | Wo | | --- | --- | --- | | Rainy | Nieselregen mit Aufprall, Wetterleuchten in den Wolken der Kopfleiste | `src/components/layout/RainyWeather.tsx`, `.rainy-flash` | | Snow | Schneefall, der sich unten zu einer Decke sammelt; alle 4 Minuten fährt ein Räumfahrzeug durch | `src/components/layout/SnowWeather.tsx` | | Aura | hell: Sonnenstrahlen, dunkel: Mond mit Sternen; ziehende Wolken und **schaukelnde Hügel in der Seitenleiste** (`@keyframes aura-hills`, nur hell) | `src/components/layout/AuraSky.tsx`, `src/index.css` | +| Pixel Art | einmal pro Minute läuft ein 8-Bit-Handwerker über die Steinmauer der Seitenleiste, hüpft und geht wieder | `src/components/layout/PixelRunner.tsx` | Die Animationen hängen am `ThemeProvider`, liegen über der Oberfläche, fangen keine Klicks ab und bleiben unter Dialogen. Bei „Bewegung reduzieren“ im Betriebssystem bleiben sie alle aus. +**Die Figur im Pixel-Theme** ist eine eigene Zeichnung – grüne Mütze, Schutzbrille, +Latzhose – und steht als Pixelraster (14 × 16) direkt in der Komponente, ohne +Bilddatei. Bewusst kein Sprite aus einem kommerziellen Spiel: solche Figuren und +Grafiken sind geschützt. Sie läuft von links herein, hüpft und geht wieder nach +links hinaus; Boden ist die Unterkante der Menüliste, also die freie Mauer über +*Einstellungen*. Nur ab Tablet-Breite, nicht bei „Bewegung reduzieren“. +**Rückbau:** `git revert ` – oder die Datei löschen und die Zeile +`{palette === 'pixel' && }` samt Import aus `src/lib/theme.tsx` +entfernen. Sonst hängt nichts daran. + Ein neues Theme braucht vier Stellen: Farbblock in `src/index.css`, Eintrag in `THEME_PALETTES` samt Beschriftung (`src/types.ts`), die Freigabeliste im Inline-Skript von `index.html` und die Prüfung in `server/src/routes/auth.js` diff --git a/src/components/layout/PixelRunner.tsx b/src/components/layout/PixelRunner.tsx new file mode 100644 index 0000000..24bbbf1 --- /dev/null +++ b/src/components/layout/PixelRunner.tsx @@ -0,0 +1,247 @@ +import React, { useEffect, useRef } from 'react'; + +/* + * Kleiner Gag im Theme "Pixel Art": Einmal pro Minute läuft ein 8-Bit-Handwerker + * über die Steinmauer der Seitenleiste, macht einen Hüpfer und verschwindet + * wieder nach links. + * + * Die Figur ist eine eigene Zeichnung - grüne Mütze, Schutzbrille, Latzhose - + * und steht als Pixelraster direkt im Code. Bewusst kein fremdes Spiel-Sprite: + * Figuren und Grafiken aus kommerziellen Spielen sind geschützt und haben in + * einer produktiv genutzten Anwendung nichts verloren. + * + * Technik wie bei den Wetter-Ebenen: ein Canvas über der Oberfläche, das keine + * Klicks abfängt. Es liegt genau über der Seitenleiste und läuft nur, solange + * eine Runde läuft - dazwischen wartet ein Timer, kein Animationsframe. + * + * Rückbau: Diese Datei löschen und die eine Zeile in lib/theme.tsx entfernen. + */ + +/** Pause zwischen zwei Auftritten. */ +const INTERVAL_MS = 60 * 1000; +/** Der erste Auftritt kommt früher, damit man ihn beim Umschalten erlebt. */ +const FIRST_MS = 6000; +/** Kantenlänge eines Bildpunkts der Figur in echten Pixeln. */ +const SCALE = 3; +/** Laufgeschwindigkeit in px/s. */ +const SPEED = 82; +/** Höhe des Sprungs in px. */ +const JUMP_HEIGHT = 30; +/** Dauer des Sprungs in ms. */ +const JUMP_MS = 560; +/** Wie lange ein Laufbild steht. */ +const STEP_MS = 110; +/** Kurzes Innehalten vor und nach dem Sprung. */ +const PAUSE_MS = 220; + +const W = 14; +const H = 16; + +const PALETTE: Record = { + k: '#1a2118', // Kontur + g: '#2e8b22', // Pulse-Grün: Mütze und Latzhose + d: '#1b5613', // Schatten im Grün + s: '#e9ba8c', // Haut + c: '#78d6e9', // Schutzbrille + w: '#f6f8f1', // Hemd + b: '#5a3e28', // Stiefel +}; + +/** Kopf und Rumpf sind in jedem Bild gleich, nur die Beine wechseln. */ +const TORSO = [ + '....gggggg....', + '...gggggggg...', + '..gggggggggg..', + '..kkkkkkkkkk..', + '...scccccs....', + '...scccccs....', + '....sssss.....', + '.....ssss.....', + '....kwwwwk....', + '...skwwwwks...', + '...sgddddgs...', + '....gggggg....', +]; + +const LEGS: Record = { + schritt1: ['....gg..gg....', '...gg....gg...', '..bbb....bbb..', '..kkk....kkk..'], + stand: ['....gggggg....', '.....gggg.....', '....bbbbb.....', '....kkkkk.....'], + schritt2: ['...gg....gg...', '..gg......gg..', '.bbb......bbb.', '.kkk......kkk.'], + sprung: ['....gg..gg....', '....gg..gg....', '...bbb..bbb...', '...kkk..kkk...'], +}; + +/** Reihenfolge der Laufbilder - hin und zurück, damit der Schritt rundläuft. */ +const CYCLE = ['schritt1', 'stand', 'schritt2', 'stand'] as const; + +type Phase = 'rein' | 'warten' | 'sprung' | 'raus'; + +/** Malt ein Bild der Figur einmal in 1:1 und gibt es als Canvas zurück. */ +function renderFrame(legs: string[]): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d')!; + [...TORSO, ...legs].forEach((row, y) => { + [...row].forEach((ch, x) => { + const color = PALETTE[ch]; + if (color) { + ctx.fillStyle = color; + ctx.fillRect(x, y, 1, 1); + } + }); + }); + return canvas; +} + +export const PixelRunner: React.FC = () => { + const ref = useRef(null); + + useEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const frames = Object.fromEntries(Object.entries(LEGS).map(([name, legs]) => [name, renderFrame(legs)])) as Record; + + let raf = 0; + let timer = 0; + let stopped = false; + /** Masse der Seitenleiste in CSS-Pixeln, jeweils frisch gemessen. */ + let box = { left: 0, top: 0, width: 0, height: 0, ground: 0 }; + + const measure = () => { + const sidebar = document.querySelector('.app-sidebar'); + const nav = sidebar?.querySelector('nav'); + if (!sidebar || !nav) return false; + const s = sidebar.getBoundingClientRect(); + // Unsichtbar (Handy) oder zu schmal: kein Auftritt. + if (s.width < 60 || s.height < 200) return false; + const n = nav.getBoundingClientRect(); + box = { + left: s.left, + top: s.top, + width: s.width, + height: s.height, + // Boden: der untere Rand der Menüliste - dort beginnt die freie Mauer. + ground: Math.round(n.bottom - s.top), + }; + const dpr = window.devicePixelRatio || 1; + canvas.style.left = `${s.left}px`; + canvas.style.top = `${s.top}px`; + canvas.style.width = `${s.width}px`; + canvas.style.height = `${s.height}px`; + canvas.width = Math.round(s.width * dpr); + canvas.height = Math.round(s.height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.imageSmoothingEnabled = false; + return true; + }; + + const clear = () => ctx.clearRect(0, 0, box.width, box.height); + + const draw = (x: number, lift: number, frame: string, facing: 1 | -1) => { + clear(); + const w = W * SCALE; + const h = H * SCALE; + const y = box.ground - h - lift; + ctx.save(); + if (facing === -1) { + // Spiegeln: der Ursprung wandert an die rechte Kante der Figur. + ctx.translate(x + w, y); + ctx.scale(-1, 1); + ctx.drawImage(frames[frame], 0, 0, w, h); + } else { + ctx.translate(x, y); + ctx.drawImage(frames[frame], 0, 0, w, h); + } + ctx.restore(); + }; + + /** Ein kompletter Auftritt: hereinlaufen, hüpfen, nach links abhauen. */ + const run = () => { + if (stopped || !measure()) { + timer = window.setTimeout(run, INTERVAL_MS); + return; + } + const w = W * SCALE; + const start = -w - 4; + const ziel = Math.round(box.width * 0.55 - w / 2); + let phase: Phase = 'rein'; + let x = start; + let phaseStart = performance.now(); + let last = phaseStart; + + const step = (now: number) => { + if (stopped) return; + const dt = Math.min(64, now - last); + last = now; + const seit = now - phaseStart; + const bild = CYCLE[Math.floor(now / STEP_MS) % CYCLE.length]; + + if (phase === 'rein') { + x = Math.min(ziel, x + (SPEED * dt) / 1000); + draw(x, 0, bild, 1); + if (x >= ziel) { + phase = 'warten'; + phaseStart = now; + } + } else if (phase === 'warten') { + draw(x, 0, 'stand', 1); + if (seit >= PAUSE_MS) { + phase = 'sprung'; + phaseStart = now; + } + } else if (phase === 'sprung') { + const t = Math.min(1, seit / JUMP_MS); + // Wurfparabel: 4t(1-t) ist bei t = 0,5 genau 1. + const lift = JUMP_HEIGHT * 4 * t * (1 - t); + draw(x, lift, t < 1 ? 'sprung' : 'stand', 1); + if (t >= 1) { + phase = 'raus'; + phaseStart = now; + } + } else { + // Kurz stehen bleiben, dann umdrehen und links hinaus. + if (seit < PAUSE_MS) { + draw(x, 0, 'stand', -1); + } else { + x -= (SPEED * 1.15 * dt) / 1000; + draw(x, 0, bild, -1); + if (x < start) { + clear(); + timer = window.setTimeout(run, INTERVAL_MS); + return; + } + } + } + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + }; + + // Einmal sofort messen, damit das Canvas nicht bis zum ersten Auftritt in + // seiner Standardgroesse irgendwo im Fluss steht. + measure(); + timer = window.setTimeout(run, FIRST_MS); + + // Die Leiste aendert ihre Breite beim Ein- und Ausklappen. Ohne neue + // Messung bliebe das Canvas in der alten Groesse ueber dem Inhalt liegen. + const onResize = () => measure(); + window.addEventListener('resize', onResize); + const sidebar = document.querySelector('.app-sidebar'); + const observer = sidebar ? new ResizeObserver(onResize) : null; + observer?.observe(sidebar!); + + return () => { + stopped = true; + cancelAnimationFrame(raf); + clearTimeout(timer); + window.removeEventListener('resize', onResize); + observer?.disconnect(); + }; + }, []); + + return ; +}; diff --git a/src/lib/theme.tsx b/src/lib/theme.tsx index b3d2c03..c636675 100644 --- a/src/lib/theme.tsx +++ b/src/lib/theme.tsx @@ -13,6 +13,7 @@ import { THEME_PALETTES, type ThemePalette } from '../types'; import { RainyWeather } from '../components/layout/RainyWeather'; import { SnowWeather } from '../components/layout/SnowWeather'; import { AuraSky } from '../components/layout/AuraSky'; +import { PixelRunner } from '../components/layout/PixelRunner'; /* * Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen: @@ -231,6 +232,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre {palette === 'rainy' && } {palette === 'snow' && } {palette === 'aura' && } + {palette === 'pixel' && } ); };