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) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-18 12:10:19 +02:00
co-authored by Claude Opus 5
parent 3b818bd1c6
commit d9d46fea6a
3 changed files with 261 additions and 1 deletions
+12 -1
View File
@@ -91,18 +91,29 @@ mindestens Vorgesetzter ist.
Sieben Farbwelten, jede in hell und dunkel, gespeichert am Benutzer Sieben Farbwelten, jede in hell und dunkel, gespeichert am Benutzer
(`pulse_users.theme`, `theme_mode`) und vor dem ersten Bild gesetzt (Inline-Skript (`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 | | Theme | Bewegung | Wo |
| --- | --- | --- | | --- | --- | --- |
| Rainy | Nieselregen mit Aufprall, Wetterleuchten in den Wolken der Kopfleiste | `src/components/layout/RainyWeather.tsx`, `.rainy-flash` | | 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` | | 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` | | 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 Die Animationen hängen am `ThemeProvider`, liegen über der Oberfläche, fangen
keine Klicks ab und bleiben unter Dialogen. Bei „Bewegung reduzieren“ im keine Klicks ab und bleiben unter Dialogen. Bei „Bewegung reduzieren“ im
Betriebssystem bleiben sie alle aus. 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 <Commit>` – oder die Datei löschen und die Zeile
`{palette === 'pixel' && <PixelRunner />}` 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 Ein neues Theme braucht vier Stellen: Farbblock in `src/index.css`, Eintrag in
`THEME_PALETTES` samt Beschriftung (`src/types.ts`), die Freigabeliste im `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` Inline-Skript von `index.html` und die Prüfung in `server/src/routes/auth.js`
+247
View File
@@ -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<string, string> = {
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<string, string[]> = {
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<HTMLCanvasElement>(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<string, HTMLCanvasElement>;
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<HTMLElement>('.app-sidebar');
const nav = sidebar?.querySelector<HTMLElement>('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<HTMLElement>('.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 <canvas ref={ref} aria-hidden className="pointer-events-none fixed z-[20] hidden md:block" />;
};
+2
View File
@@ -13,6 +13,7 @@ import { THEME_PALETTES, type ThemePalette } from '../types';
import { RainyWeather } from '../components/layout/RainyWeather'; import { RainyWeather } from '../components/layout/RainyWeather';
import { SnowWeather } from '../components/layout/SnowWeather'; import { SnowWeather } from '../components/layout/SnowWeather';
import { AuraSky } from '../components/layout/AuraSky'; import { AuraSky } from '../components/layout/AuraSky';
import { PixelRunner } from '../components/layout/PixelRunner';
/* /*
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen: * Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
@@ -231,6 +232,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
{palette === 'rainy' && <RainyWeather />} {palette === 'rainy' && <RainyWeather />}
{palette === 'snow' && <SnowWeather />} {palette === 'snow' && <SnowWeather />}
{palette === 'aura' && <AuraSky />} {palette === 'aura' && <AuraSky />}
{palette === 'pixel' && <PixelRunner />}
</ThemeContext.Provider> </ThemeContext.Provider>
); );
}; };