From d9d46fea6a69a8b01aa874c30328624517e1859b Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 18 Sep 2026 12:10:19 +0200 Subject: [PATCH] feat(pixel): 8-Bit-Handwerker laeuft einmal pro Minute durch die Seitenleiste Im Theme "Pixel Art" laeuft eine kleine Figur ueber die Steinmauer der Seitenleiste, macht einen Huepfer und verschwindet wieder nach links. Auftritt alle 60 Sekunden, der erste nach 6 Sekunden. Die Figur ist eine eigene Zeichnung - gruene Muetze, Schutzbrille, Latzhose - und steht als Pixelraster 14 x 16 direkt im Code, ohne Bilddatei. Bewusst kein Sprite aus einem kommerziellen Spiel: solche Figuren und Grafiken sind geschuetzt und gehoeren nicht in eine produktiv genutzte Anwendung. Technik wie bei den Wetter-Ebenen: ein Canvas genau ueber der Seitenleiste, ohne Klickfang, unter den Dialogen, per ResizeObserver an die ein- und ausklappbare Breite gebunden. Zwischen zwei Auftritten laeuft ein Timer statt eines Animationsframes. Bei "Bewegung reduzieren" und unterhalb der Tabletbreite bleibt alles aus. Rueckbau: diesen Commit reverten, oder die Datei loeschen und die eine Zeile in lib/theme.tsx entfernen. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 +- src/components/layout/PixelRunner.tsx | 247 ++++++++++++++++++++++++++ src/lib/theme.tsx | 2 + 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/components/layout/PixelRunner.tsx 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' && } ); };