136 lines
5.2 KiB
React
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
|