Files
tickte-system/src/components/workers/hochhaus/HochhausWelt.jsx
T
ClaudeandClaude Sonnet 5.5 6eb4dd3c51 Hustler-Stufe: Keller begehbar ab Stufe 3, Bot-Leads abrufbar erst ab Stufe 6
Die Stufen-Sperre trennt jetzt zwei Dinge: KELLER_BEGEHBAR_AB = 3 (Keller-Etage als Link,
?tab=botleads gueltig, Spur-Links in den Keller) und BOTLEADS_AB = 6 (Abruf von GET /botleads).
kellerOffen/STUFE_KELLER entfallen, dafuer kellerBegehbar() und botleadsAbrufbar().
workersApi.botleads bleibt unter Stufe 6 hart gesperrt, der Text heisst nun
BOTLEADS_GESPERRT_TEXT. Die Keller-Ansicht selbst (leeres Register ohne Abruf) folgt im
naechsten Commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:21:59 +00:00

275 lines
14 KiB
React

// Die Spielwelt des Hochhauses (Hustler-Pixelwelt T5, Spec E1/E4/E5/E8/E14): 1:1 aus dem Markup der Zeichenfläche (entwurf/markup.html), Klassen mit hk- davor.
// Himmel, Funkeln, Mond, die 2400 x 1200 große Bühne (per transform verschoben und gezoomt) mit Welt-, Licht-, Haus- und Fensterebenen, Neon, Antenne, Aufzug,
// Raumschilder, Bildschirme, Radar, Briefkasten-Zähler, Tresor-/Keller-Text, den Etagen (Knöpfe, Freigabe und Keller sind Links) und den 19 Hustlern; davor der Schnee.
// Alles hängt am Dienst-Zustand: Figur und Zusätze nach Status (figurZustand), graue Raum-Ebene samt Papierschild nur für nicht angeschlossene Such-Räume.
// Stufen-Sperre (hustlerStufe.js): unterhalb der Stufe, ab der der Keller begehbar ist (3), ist die Keller-Etage kein Link auf den Keller, sondern ein Knopf wie jede andere Etage (heranzoomen).
// Die Kamera (Rad, Ziehen, Pinch, Tasten) steckt in useKamera. `children` (die Regie-Ebene) liegt in der Bühne über den Figuren, in Weltkoordinaten.
// Bedienung: Tastaturfokus (:focus-visible) auf Etage oder Figur außerhalb des Bildes holt sie per Kamera herein (beiFokus, kein Schwenk bei Mausklick); das Sichtfenster ist
// per tabindex -1 fokussierbar (ein Klick in die Welt gibt ihm den Fokus, damit + - 0 Esc wirken); die Tippflächen der Figuren wachsen mit dem Zoom (--hk-tipp-*, tippRand);
// bei offener Detailansicht (gesperrt) trägt das Sichtfenster hk-verdeckt (Schnee aus).
// Kein CSS-Import (die Stile hängt WorkersPage ein: styles/hustler/hochhaus.css, hustler-ui.css, hustler-welt.css), keine Bilder, kein innerHTML.
import { forwardRef, useImperativeHandle } from 'react'
import { anzeigeName, figurZustand, schilder, tippRand } from '../../../lib/hustlerSicht.js'
import { HUSTLER_STUFE, kellerBegehbar } from '../../../lib/hustlerStufe.js'
import { AUFZUG, ETAGEN, ETAGEN_SPALTE, HAUS_BILD, HUSTLER, RAEUME, SCHILDER } from '../../../lib/hustlerWelt.js'
import { FLOOR_TITLES, ROLES, ROOM_TITLES } from '../../../lib/workersRoster.js'
import useKamera from './useKamera.js'
// Etagen, die ein Bildschirm sind (Link statt Zoom): Text und Label wörtlich aus der Zeichenfläche
const LINK_ETAGEN = Object.freeze({
freigabe: Object.freeze({ tab: 'freigabe', text: 'Freigabe ▸', label: 'Freigabe: Freigabe-Pult öffnen' }),
keller: Object.freeze({ tab: 'botleads', text: 'Keller · Bot-Leads ▸', label: 'Keller · Bot-Leads: Bot-Leads öffnen' }),
})
// Welche Etagen Links sind: der Keller nur, wenn er begehbar ist (ab Stufe 3, auch wenn sein Register noch leer ist); sonst zoomt er heran wie jede andere Etage
const OHNE_KELLER = Object.freeze({ freigabe: LINK_ETAGEN.freigabe })
const linkEtagen = (stufe) => (kellerBegehbar(stufe) ? LINK_ETAGEN : OHNE_KELLER)
const HAUS_RECHTECK = Object.freeze({ left: HAUS_BILD.x, top: HAUS_BILD.y, width: HAUS_BILD.w, height: HAUS_BILD.h })
const PX = (n) => `${n}px`
// Elemente, die Fokus per Tastatur bekommen können und beim Fokus ins Bild geholt werden (M1)
const FOKUS_ZIEL = '.hk-etage, .hk-hustler'
// Namensschild einer Figur: so viele Bildschirm-Pixel über dem Knopf sollen beim Heranholen mit sichtbar sein
const SCHILD_HOEHE = 34
// Feste, je Figur verschiedene Startverschiebung der Animation (deterministisch aus dem Index: 7 und 19 sind teilerfremd, 19 Figuren -> 19 Werte)
const versatz = (index, schritt = 7, anzahl = 19) => `-${(((index * schritt) % anzahl) / 10).toFixed(1)}s`
function geraeteDichte() {
try { return window.devicePixelRatio || 1 } catch { return 1 }
}
// Lage des „nicht angeschlossen“-Zettels: die Daten kennen drei Räume, alle acht Such-Räume haben dieselbe Lage relativ zum Raum (x0 + 16, y0 + 1)
function zettelLage(raum) {
const bekannt = SCHILDER.nichtAngeschlossen.raeume[raum]
if (bekannt) return bekannt
const r = RAEUME[raum]
return { left: r.x0 + 16, top: r.y0 + 1 }
}
const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige, onOpen, gesperrt = false, stufe = HUSTLER_STUFE, children }, ref) {
const kam = useKamera({ gesperrt })
const links = linkEtagen(stufe)
useImperativeHandle(ref, () => ({ ganz: kam.ganz }), [kam.ganz])
const pausiert = state?.paused === true
const tafel = schilder(state?.kpis)
const figuren = ROLES.map((rolle, index) => ({
id: rolle.workerId,
index,
f: figurZustand(rolle.workerId, workers?.[rolle.workerId], { paused: pausiert }),
}))
const grau = new Set(figuren.filter((x) => x.f.raumGrau).map((x) => x.f.raum))
const { s, x, y } = kam
const tipp = tippRand(s)
const etikett = Math.min(18, Math.max(8, 15 / s)).toFixed(2)
const namePx = Math.min(14, Math.max(4, 12 / s)).toFixed(2)
const buehneStil = {
transform: `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${s.toFixed(4)})`,
imageRendering: s * geraeteDichte() < 1 ? 'auto' : 'pixelated',
'--hk-etikett': `${etikett}px`,
'--hk-name': `${namePx}px`,
// Tippfläche der Figuren: wächst mit dem Zoom auf 40 Bildschirm-Pixel (M8, hustler-welt.css)
'--hk-tipp-x': `${tipp.x.toFixed(2)}px`,
'--hk-tipp-oben': `${tipp.oben.toFixed(2)}px`,
'--hk-tipp-unten': `${tipp.unten.toFixed(2)}px`,
}
const zeige = (id) => () => {
if (kam.nachZug()) return
if (typeof onZeige === 'function') onZeige(id)
}
// Freigabe und Keller sind echte Links; ein einfacher Klick wechselt den Bildschirm, Strg/Cmd/Umschalt/Mitteltaste öffnen wie jeder Link
const linkKlick = (tab) => (e) => {
if (kam.nachZug()) { e.preventDefault(); e.stopPropagation(); return }
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return
e.preventDefault()
if (typeof onOpen === 'function') onOpen(tab)
}
// Fokus per Tastatur (:focus-visible) auf Etage oder Figur außerhalb des sichtbaren Bereichs: Kamera holt sie ins Bild (M1). Kein Schwenk bei Mausklick.
const beiFokus = (e) => {
if (gesperrt) return
const knoten = e && e.target
if (!knoten || typeof knoten.closest !== 'function') return
const ziel = knoten.closest(FOKUS_ZIEL)
if (!ziel) return
let tastatur = false
try { tastatur = ziel.matches(':focus-visible') } catch { tastatur = false }
if (!tastatur) return
// Der Browser darf das Sichtfenster nicht selbst verschieben (alte Browser ohne overflow: clip scrollen beim Fokus)
const sicht = e.currentTarget
if (sicht && (sicht.scrollLeft || sicht.scrollTop)) { sicht.scrollLeft = 0; sicht.scrollTop = 0 }
const figur = typeof ziel.matches === 'function' && ziel.matches('.hk-hustler')
kam.zeige({ left: ziel.offsetLeft, top: ziel.offsetTop, width: ziel.offsetWidth, height: ziel.offsetHeight }, figur ? SCHILD_HOEHE : 0)
}
return (
<div
ref={kam.view}
className={`hk-sicht${kam.ziehen ? ' hk-zieht' : ''}${pausiert ? ' hk-steht' : ''}${gesperrt ? ' hk-verdeckt' : ''}`}
tabIndex={-1}
onFocus={beiFokus}
onPointerDown={kam.handlers.onPointerDown}
onPointerMove={kam.handlers.onPointerMove}
onPointerUp={kam.handlers.onPointerUp}
onPointerCancel={kam.handlers.onPointerCancel}
onDoubleClick={kam.handlers.onDoubleClick}
onKeyDown={kam.handlers.onKeyDown}
>
<div className="hk-himmel" aria-hidden="true" />
<div className="hk-funkeln" aria-hidden="true" />
<div className="hk-mond" aria-hidden="true" />
<div className={`hk-buehne${kam.gleiten ? ' hk-gleitet' : ''}${pausiert ? ' hk-steht' : ''}`} style={buehneStil}>
<div className="hk-lage hk-welt-bild" aria-hidden="true" />
<div className="hk-lage hk-licht-1" aria-hidden="true" />
<div className="hk-lage hk-licht-2" aria-hidden="true" />
<div className="hk-lage hk-licht-3" aria-hidden="true" />
<div className="hk-lage hk-licht-4" aria-hidden="true" />
<div className="hk-lage hk-laternen" aria-hidden="true" />
<div className="hk-haus hk-haus-bild" aria-hidden="true" style={HAUS_RECHTECK} />
<div className="hk-haus hk-fenster-a" aria-hidden="true" style={HAUS_RECHTECK} />
<div className="hk-haus hk-fenster-b" aria-hidden="true" style={HAUS_RECHTECK} />
<div className="hk-haus hk-fenster-c" aria-hidden="true" style={HAUS_RECHTECK} />
<div className="hk-haus hk-schild-a" aria-hidden="true" style={HAUS_RECHTECK} />
<div className="hk-haus hk-schild-b" aria-hidden="true" style={HAUS_RECHTECK} />
<div
className="hk-neon"
aria-hidden="true"
style={{ left: SCHILDER.neon.left, top: SCHILDER.neon.top, width: SCHILDER.neon.w, height: SCHILDER.neon.h, fontSize: PX(SCHILDER.neon.schrift), lineHeight: PX(SCHILDER.neon.schrift) }}
>
HUSTLER
</div>
<div
className="hk-antenne"
aria-hidden="true"
style={{ left: SCHILDER.antenne.left, top: SCHILDER.antenne.top, width: SCHILDER.antenne.w, height: SCHILDER.antenne.h }}
/>
<div className="hk-haus hk-seil" aria-hidden="true" style={{ left: AUFZUG.seil.left, top: AUFZUG.seil.top, width: AUFZUG.seil.w, height: AUFZUG.seil.h }} />
<div className="hk-haus hk-aufzug" aria-hidden="true" style={{ left: AUFZUG.kabine.left, top: AUFZUG.kabine.top, width: AUFZUG.kabine.w, height: AUFZUG.kabine.h }} />
{/* Nicht angeschlossene Such-Räume: abgedunkelte Ebene, unter Schildern und Figuren */}
{Object.keys(RAEUME).filter((raum) => grau.has(raum)).map((raum) => (
<div key={raum} className={`hk-raum-grau-${raum}`} aria-hidden="true" />
))}
{/* Raumschilder; ein grauer Raum bekommt stattdessen den Papier-Zettel „nicht angeschlossen“ */}
{Object.keys(RAEUME).map((raum) => {
if (grau.has(raum)) {
const lage = zettelLage(raum)
return (
<span key={raum} className="hk-zettel" aria-hidden="true" style={{ left: lage.left, top: lage.top, width: SCHILDER.nichtAngeschlossen.breite }}>
{ROOM_TITLES[raum]}<br />nicht angeschlossen
</span>
)
}
return (
<span key={raum} className="hk-raumschild" aria-hidden="true" style={{ left: RAEUME[raum].etikett.x, top: RAEUME[raum].etikett.y }}>
{ROOM_TITLES[raum]}
</span>
)
})}
{/* Bildschirme (nur wer arbeitet) und Rita-Radar */}
{figuren.filter((e) => e.f.bildschirm && HUSTLER[e.id].bildschirm).map((e) => (
<span
key={e.id}
className="hk-spr hk-bildschirm"
aria-hidden="true"
style={{ left: HUSTLER[e.id].bildschirm.left, top: HUSTLER[e.id].bildschirm.top, animationDelay: versatz(e.index, 3, 8) }}
/>
))}
{figuren.filter((e) => e.f.radar && HUSTLER[e.id].radar).map((e) => (
<span key={e.id} className="hk-spr hk-radar" aria-hidden="true" style={{ left: HUSTLER[e.id].radar.left, top: HUSTLER[e.id].radar.top }} />
))}
{tafel.tresor !== null ? (
<span
className="hk-schildtext hk-schildtext-tresor"
style={{ left: SCHILDER.tresor.left, top: SCHILDER.tresor.top, fontSize: PX(SCHILDER.tresor.schrift), lineHeight: PX(SCHILDER.tresor.zeile) }}
>
{tafel.tresor}
</span>
) : null}
{tafel.keller !== null ? (
<span
className="hk-schildtext hk-schildtext-keller"
style={{ left: SCHILDER.keller.left, top: SCHILDER.keller.top, fontSize: PX(SCHILDER.keller.schrift), lineHeight: PX(SCHILDER.keller.zeile) }}
>
{tafel.keller}
</span>
) : null}
{tafel.briefkasten !== null ? (
<span
className="hk-huepf hk-briefkasten"
aria-hidden="true"
style={{
left: SCHILDER.briefkasten.left,
top: SCHILDER.briefkasten.top,
minWidth: SCHILDER.briefkasten.minW,
height: SCHILDER.briefkasten.h,
fontSize: PX(SCHILDER.briefkasten.schrift),
lineHeight: PX(SCHILDER.briefkasten.schrift),
}}
>
{tafel.briefkasten}
</span>
) : null}
{/* Etagen: Knöpfe zoomen heran, Freigabe (und ab der Stufe, ab der der Keller begehbar ist, der Keller) sind Links auf die anderen Bildschirme */}
{ETAGEN.map((e) => {
const stil = { left: ETAGEN_SPALTE.x, top: e.oben, width: ETAGEN_SPALTE.w, height: e.unten - e.oben }
const link = links[e.id]
if (link) {
return (
<a key={e.id} className="hk-etage" data-hk-etage={e.id} href={`/hustler?tab=${link.tab}`} draggable={false} onClick={linkKlick(link.tab)} aria-label={link.label} style={stil}>
<span className="hk-etage-name">{link.text}</span>
</a>
)
}
return (
<button key={e.id} type="button" className="hk-etage" data-hk-etage={e.id} onClick={() => kam.etage(e.oben, e.unten)} aria-label={`${FLOOR_TITLES[e.id]} heranzoomen`} style={stil}>
<span className="hk-etage-name">{FLOOR_TITLES[e.id]}</span>
</button>
)
})}
{/* Die 19 Hustler: Knopf mit Sprite nach Status, Zusätzen und Namensschild */}
{figuren.map(({ id, index, f }) => {
const k = HUSTLER[id].knopf
return (
<button
key={id}
type="button"
className="hk-hustler"
data-hk-hustler={id}
aria-haspopup="dialog"
onClick={zeige(id)}
aria-label={f.label}
style={{ left: k.left, top: k.top, width: k.w, height: k.h }}
>
<span className={f.gedimmt ? `${f.klasse} hk-gedimmt` : f.klasse} aria-hidden="true" style={{ animationDelay: versatz(index) }} />
{f.punkte ? <span className="hk-spr hk-punkte" aria-hidden="true" /> : null}
{f.zzz ? <span className="hk-spr hk-zzz" aria-hidden="true" /> : null}
{f.alarm ? <span className="hk-alarm" aria-hidden="true">!</span> : null}
<span className="hk-namensschild">{anzeigeName(id)}</span>
</button>
)
})}
{children}
</div>
<div className="hk-schnee hk-schnee-1" aria-hidden="true" />
<div className="hk-schnee hk-schnee-2" aria-hidden="true" />
<div className="hk-schnee hk-schnee-3" aria-hidden="true" />
</div>
)
})
export default HochhausWelt