import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { createRegie } from '../../../lib/hustlerRegie.js' // Regie-Ebene der Hochhaus-Welt (Spec E9): zeichnet die Effekte der Regie (src/lib/hustlerRegie.js) als absolut positionierte Elemente in // Weltkoordinaten über der Welt. Gehört als Kind in die skalierte Bühne (Wurzel mit position: absolute; inset: 0), nimmt keine Klicks an und // ist für Bildschirmleser versteckt (der Status steht schon an den Figuren). Keine Props: die Seite spielt über das Handle // { push(events), flush() } (regieRef) neue Ereignisse vor. Kein CSS-Import und keine Bilder: die Regeln stehen in styles/hustler/hustler-regie.css. // Ein Zeitgeber läuft nur, solange Effekte sichtbar sind oder gleich starten (setTimeout bis zur nächsten Änderung, kein Dauer-Intervall); // beim Abbau werden Zeitgeber und Beobachter aufgeräumt. „Weniger Bewegung“ (matchMedia, auch bei Änderung) zeigt nur Blasen, // ein verdeckter Tab leert die Ebene (visibilitychange) und nimmt keine neuen Ereignisse an. const WENIGER_BEWEGUNG = '(prefers-reduced-motion: reduce)' // Fenster und Dokument nur, wo es sie gibt (Tests unter Node, Vorschau ohne Browser) function bewegungMedium() { try { return typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WENIGER_BEWEGUNG) : null } catch { return null } } const verdeckt = () => typeof document !== 'undefined' && document.hidden === true // Gleiche Effekte (nach Kennung und Reihenfolge)? Dann bleibt der Zustand unverändert und React zeichnet nicht neu. const gleich = (a, b) => a.length === b.length && a.every((e, i) => e.id === b[i].id) // Regie, Zeitgeber und Handle: einmal pro Ebene, gehalten in einem Ref (kein Zustand, damit Aufrufe von außen nie veraltete Werte sehen) function baueSteuerung(setEffekte) { const s = { regie: createRegie(), timer: null, lebt: false } const stoppen = () => { if (s.timer !== null) { clearTimeout(s.timer) s.timer = null } } // Liste neu holen, zeigen und den Zeitgeber auf die nächste Änderung stellen (nur wenn etwas läuft oder wartet) const aktualisieren = () => { stoppen() if (!s.lebt) return const liste = s.regie.tick() setEffekte((alt) => (gleich(alt, liste) ? alt : liste)) const bis = s.regie.naechsteIn() if (bis !== null) s.timer = setTimeout(aktualisieren, Math.max(1, bis)) } s.aktualisieren = aktualisieren s.stoppen = stoppen s.push = (events) => { if (!s.lebt || verdeckt()) return s.regie.push(events) aktualisieren() } s.flush = () => { s.regie.flush() aktualisieren() } s.setReduziert = (wert) => { s.regie.setReduziert(wert) aktualisieren() } return s } // Lage in Weltkoordinaten (Mittelpunkt bzw. Spitze der Blase) und Dauer für die CSS const stil = (e) => ({ left: `${e.x}px`, top: `${e.y}px`, '--dauer': `${e.dauer}ms` }) function effektKnoten(e) { switch (e.art) { case 'blase': return (
{e.text}
) case 'icon': return
case 'flug': return (
) case 'jubel': return
default: return null } } const RegieEbene = forwardRef(function RegieEbene(_props, ref) { const [effekte, setEffekte] = useState([]) const steuerung = useRef(null) if (steuerung.current === null) steuerung.current = baueSteuerung(setEffekte) useImperativeHandle(ref, () => ({ push: (events) => steuerung.current.push(events), flush: () => steuerung.current.flush(), }), []) useEffect(() => { const s = steuerung.current s.lebt = true const medium = bewegungMedium() const beiBewegung = () => s.setReduziert(Boolean(medium && medium.matches)) if (medium) { s.regie.setReduziert(medium.matches) if (typeof medium.addEventListener === 'function') medium.addEventListener('change', beiBewegung) else if (typeof medium.addListener === 'function') medium.addListener(beiBewegung) } const beiSicht = () => { if (verdeckt()) s.flush() } if (typeof document !== 'undefined') document.addEventListener('visibilitychange', beiSicht) s.aktualisieren() // nach einem erneuten Einhängen (StrictMode) steht keine alte Liste mehr da return () => { s.lebt = false s.stoppen() s.regie.flush() if (medium) { if (typeof medium.removeEventListener === 'function') medium.removeEventListener('change', beiBewegung) else if (typeof medium.removeListener === 'function') medium.removeListener(beiBewegung) } if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', beiSicht) } }, []) return ( ) }) export default RegieEbene