Files
tickte-system/src/components/workers/hochhaus/RegieEbene.jsx
T

136 lines
5.2 KiB
React

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 (
<div key={e.id} className="hk-regie-blase" style={stil(e)}>
<span className="hk-regie-blase-text">{e.text}</span>
</div>
)
case 'icon':
return <div key={e.id} className={`hk-regie-icon hk-regie-bild-${e.icon}`} style={stil(e)} />
case 'flug':
return (
<div
key={e.id}
className={`hk-regie-flug hk-regie-bild-${e.icon}`}
style={{ ...stil(e), '--dx': `${e.x2 - e.x}px`, '--dy': `${e.y2 - e.y}px` }}
/>
)
case 'jubel':
return <div key={e.id} className={`hk-regie-jubel hk-regie-bild-${e.icon}`} style={stil(e)} />
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 (
<div className="hk-regie" aria-hidden="true">
{effekte.map(effektKnoten)}
</div>
)
})
export default RegieEbene