Hustler-Pixelwelt T5: Hochhaus-Welt (Ebenen, Figuren nach Status, graue Räume, Etagen) und Kamera
HochhausWelt.jsx baut die Welt 1:1 aus dem Markup der Zeichenfläche (Himmel, Bühne 2400x1200, Haus-, Licht- und Fensterebenen, Neon, Aufzug, Raumschilder, Bildschirme, Radar, Schilder, Etagen, 19 Hustler, Schnee); Figuren, Zusätze, graue Raum-Ebenen samt Papier-Zettel, Briefkasten-Zähler und Etagen-Links folgen dem Dienst-Zustand. useKamera.js ist der Hook für Rad, Ziehen, Pinch, Doppeltipp, Tasten und Gleiten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f4350495f
commit
94b88462c7
4 files changed
+1466
No files matched your search
@@ -0,0 +1,233 @@
|
||||
// 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.
|
||||
// Die Kamera (Rad, Ziehen, Pinch, Tasten) steckt in useKamera. `children` (die Regie-Ebene) liegt in der Bühne über den Figuren, in Weltkoordinaten.
|
||||
// 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 } from '../../../lib/hustlerSicht.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' }),
|
||||
})
|
||||
|
||||
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`
|
||||
|
||||
// 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, children }, ref) {
|
||||
const kam = useKamera({ gesperrt })
|
||||
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 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`,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={kam.view}
|
||||
className={`hk-sicht${kam.ziehen ? ' hk-zieht' : ''}${pausiert ? ' hk-steht' : ''}`}
|
||||
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"
|
||||
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 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 = LINK_ETAGEN[e.id]
|
||||
if (link) {
|
||||
return (
|
||||
<a key={e.id} className="hk-etage" 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" 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}
|
||||
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
|
||||
@@ -0,0 +1,274 @@
|
||||
// Kamera der Hochhaus-Welt (Hustler-Pixelwelt T5, Spec E1/E8/E14): Port von logik.js der Zeichenfläche als Hook.
|
||||
// Mausrad zoomt um den Mauspunkt, Ziehen verschiebt, zwei Finger zoomen (Pinch), Doppelklick/Doppeltipp zeigt das ganze Haus,
|
||||
// Tasten +/=/-/0/Esc, Etagen heranzoomen. Gleiten 0,55 s (aus bei „weniger Bewegung“). Nach einem Zug kein versehentlicher Klick (nachZug()).
|
||||
// Gerechnet wird nur mit den reinen Kamera-Funktionen aus lib/hustlerSicht.js; hier steht die Zeiger- und Zustandsverwaltung.
|
||||
// Die Kamera selbst (baueKamera) ist reines JS ohne React und bekommt nur die Meldung „Zustand hat sich geändert“.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { SICHT_STANDARD, kameraEtage, kameraGrenzen, kameraPassend, kameraZoomAuf } from '../../../lib/hustlerSicht.js'
|
||||
|
||||
// Gleiten: die CSS-Klasse hk-gleitet dauert 0,55 s, der Merker bleibt etwas länger (Ende der Bewegung abwarten)
|
||||
export const GLEITEN_MS = 600
|
||||
const DOPPELTIPP_MS = 350
|
||||
const TASTE_FAKTOR = 1.25
|
||||
const ZUG_SCHWELLE_MAUS = 5
|
||||
const ZUG_SCHWELLE_TOUCH = 10
|
||||
// Beschreibt Elemente, auf denen ein Doppelklick/-tipp nicht „ganzes Haus“ bedeutet
|
||||
const BEDIENBAR = '.hk-etage, .hk-hustler'
|
||||
|
||||
const jetzt = () => Date.now()
|
||||
|
||||
function ruhig() {
|
||||
try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) } catch { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Kamera. `melde(zustand)` wird nach jeder Änderung von { s, x, y, bereit, gleiten, ziehen } mit dem neuen Zustandsobjekt gerufen.
|
||||
* Alle Methoden sind feste Funktionen (stabile Bezüge für React).
|
||||
*/
|
||||
export function baueKamera(melde) {
|
||||
const m = {
|
||||
st: { ...kameraPassend(SICHT_STANDARD), bereit: false, gleiten: false, ziehen: false },
|
||||
el: null,
|
||||
verbunden: false,
|
||||
ro: null,
|
||||
timer: null,
|
||||
zeiger: new Map(),
|
||||
drag: null,
|
||||
pinch: null,
|
||||
gezogenBis: 0,
|
||||
letzterTipp: 0,
|
||||
selbst: false, // Nutzer hat selbst gezoomt oder gezogen: bei Größenänderung nur Grenzen nachziehen, nicht neu einpassen
|
||||
gesperrt: false,
|
||||
}
|
||||
|
||||
const setze = (patch) => {
|
||||
m.st = { ...m.st, ...patch }
|
||||
melde(m.st)
|
||||
}
|
||||
|
||||
const groesse = () => {
|
||||
if (m.el) {
|
||||
const r = m.el.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0) return { w: r.width, h: r.height }
|
||||
}
|
||||
return SICHT_STANDARD
|
||||
}
|
||||
|
||||
const gleite = (ziel) => {
|
||||
clearTimeout(m.timer)
|
||||
m.timer = null
|
||||
if (ruhig()) { setze({ ...ziel, gleiten: false, bereit: true }); return }
|
||||
setze({ ...ziel, gleiten: true, bereit: true })
|
||||
m.timer = setTimeout(() => { m.timer = null; setze({ gleiten: false }) }, GLEITEN_MS)
|
||||
}
|
||||
|
||||
const ganz = () => {
|
||||
m.selbst = false
|
||||
gleite(kameraPassend(groesse()))
|
||||
}
|
||||
|
||||
const nachZug = () => jetzt() < m.gezogenBis
|
||||
|
||||
const zoomAuf = (mx, my, neuS) => {
|
||||
if (!m.st.s) return
|
||||
const g = groesse()
|
||||
m.selbst = true
|
||||
setze({ ...kameraZoomAuf(m.st, mx, my, neuS, g), gleiten: false, bereit: true })
|
||||
}
|
||||
|
||||
const rad = (e) => {
|
||||
if (m.gesperrt) return
|
||||
e.preventDefault()
|
||||
if (!m.st.bereit || m.st.gleiten || !m.el) return
|
||||
const r = m.el.getBoundingClientRect()
|
||||
const k = (e.deltaMode === 1 ? 0.05 : e.deltaMode === 2 ? 1 : 0.0016) * (e.ctrlKey ? 10 : 1)
|
||||
zoomAuf(e.clientX - r.left, e.clientY - r.top, m.st.s * Math.exp(-e.deltaY * k))
|
||||
}
|
||||
|
||||
const abbruchZiehen = () => {
|
||||
const d = m.drag
|
||||
m.drag = null
|
||||
if (d && d.moved) { try { m.el.releasePointerCapture(d.id) } catch { /* Zeiger schon weg */ } }
|
||||
if (m.st.ziehen) setze({ ziehen: false })
|
||||
}
|
||||
|
||||
const unten = (e) => {
|
||||
if (m.gesperrt || m.st.gleiten || !m.st.bereit) return
|
||||
if (e.pointerType === 'mouse' && (e.button !== 0 || e.ctrlKey)) return
|
||||
if (e.pointerType === 'mouse') m.zeiger.clear() // es gibt nur eine Maus: ein verwaister Eintrag darf keinen Pinch auslösen
|
||||
m.zeiger.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
if (m.zeiger.size === 2) {
|
||||
const [p, q] = [...m.zeiger.values()]
|
||||
abbruchZiehen()
|
||||
m.pinch = { d0: Math.hypot(p.x - q.x, p.y - q.y) || 1, s0: m.st.s, x0: m.st.x, y0: m.st.y, mx: (p.x + q.x) / 2, my: (p.y + q.y) / 2 }
|
||||
m.gezogenBis = jetzt() + 400
|
||||
return
|
||||
}
|
||||
if (m.zeiger.size > 2) return
|
||||
m.drag = { id: e.pointerId, sx: e.clientX, sy: e.clientY, x0: m.st.x, y0: m.st.y, moved: false, touch: e.pointerType !== 'mouse' }
|
||||
}
|
||||
|
||||
const loslassen = (e) => {
|
||||
m.zeiger.delete(e.pointerId)
|
||||
if (m.pinch) {
|
||||
if (m.zeiger.size < 2) { m.pinch = null; m.drag = null; m.gezogenBis = jetzt() + 300 }
|
||||
return
|
||||
}
|
||||
const d = m.drag
|
||||
if (!d || e.pointerId !== d.id) return
|
||||
m.drag = null
|
||||
if (d.moved) {
|
||||
m.gezogenBis = jetzt() + 250
|
||||
try { m.el.releasePointerCapture(d.id) } catch { /* Zeiger schon weg */ }
|
||||
setze({ ziehen: false })
|
||||
return
|
||||
}
|
||||
if (d.touch) {
|
||||
const frei = !(e.target && e.target.closest && e.target.closest(BEDIENBAR))
|
||||
const t = jetzt()
|
||||
if (frei && t - m.letzterTipp < DOPPELTIPP_MS) { m.letzterTipp = 0; ganz() }
|
||||
else m.letzterTipp = frei ? t : 0
|
||||
}
|
||||
}
|
||||
|
||||
const bewegen = (e) => {
|
||||
if (!m.zeiger.has(e.pointerId)) return
|
||||
if (e.pointerType === 'mouse' && !(e.buttons & 1)) { loslassen(e); return }
|
||||
m.zeiger.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
const g = groesse()
|
||||
if (m.pinch) {
|
||||
if (m.zeiger.size < 2) return
|
||||
const [p, q] = [...m.zeiger.values()]
|
||||
const r = m.el.getBoundingClientRect()
|
||||
const P = m.pinch
|
||||
const d = Math.hypot(p.x - q.x, p.y - q.y) || 1
|
||||
const n = kameraGrenzen({ s: (P.s0 * d) / P.d0, x: 0, y: 0 }, g)
|
||||
const f = n.s / P.s0
|
||||
const mx = (p.x + q.x) / 2 - r.left
|
||||
const my = (p.y + q.y) / 2 - r.top
|
||||
m.selbst = true
|
||||
setze({ ...kameraGrenzen({ s: n.s, x: mx - (P.mx - r.left - P.x0) * f, y: my - (P.my - r.top - P.y0) * f }, g), gleiten: false })
|
||||
return
|
||||
}
|
||||
const d = m.drag
|
||||
if (!d || e.pointerId !== d.id) return
|
||||
const dx = e.clientX - d.sx
|
||||
const dy = e.clientY - d.sy
|
||||
if (!d.moved) {
|
||||
const sw = d.touch ? ZUG_SCHWELLE_TOUCH : ZUG_SCHWELLE_MAUS
|
||||
if (dx * dx + dy * dy < sw * sw) return
|
||||
d.moved = true
|
||||
try { m.el.setPointerCapture(d.id) } catch { /* Zeiger schon weg */ }
|
||||
setze({ ziehen: true, gleiten: false })
|
||||
}
|
||||
m.selbst = true
|
||||
setze(kameraGrenzen({ s: m.st.s, x: d.x0 + dx, y: d.y0 + dy }, g))
|
||||
}
|
||||
|
||||
const doppelklick = (e) => {
|
||||
if (m.gesperrt || nachZug()) return
|
||||
if (e && e.target && e.target.closest && e.target.closest(BEDIENBAR)) return
|
||||
ganz()
|
||||
}
|
||||
|
||||
const taste = (e) => {
|
||||
if (m.gesperrt) return
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return // Browser-Zoom (Strg +/-) und Kürzel nicht abfangen
|
||||
const g = groesse()
|
||||
if (e.key === 'Escape' || e.key === '0') { e.preventDefault(); ganz() }
|
||||
else if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomAuf(g.w / 2, g.h / 2, m.st.s * TASTE_FAKTOR) }
|
||||
else if (e.key === '-') { e.preventDefault(); zoomAuf(g.w / 2, g.h / 2, m.st.s / TASTE_FAKTOR) }
|
||||
}
|
||||
|
||||
const etage = (oben, untenRand) => {
|
||||
if (nachZug()) return
|
||||
m.selbst = true
|
||||
gleite(kameraEtage(oben, untenRand, groesse()))
|
||||
}
|
||||
|
||||
// Mausrad (passive: false, sonst scrollt die Seite mit) und Größenänderung am Sichtfenster
|
||||
const verbinden = () => {
|
||||
if (!m.el || m.verbunden) return
|
||||
m.verbunden = true
|
||||
m.el.addEventListener('wheel', rad, { passive: false })
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
m.ro = new ResizeObserver(() => {
|
||||
const g = groesse()
|
||||
setze(m.selbst ? kameraGrenzen(m.st, g) : { ...kameraPassend(g), bereit: true })
|
||||
})
|
||||
m.ro.observe(m.el)
|
||||
}
|
||||
}
|
||||
|
||||
const loesen = () => {
|
||||
if (m.el && m.verbunden) m.el.removeEventListener('wheel', rad)
|
||||
m.verbunden = false
|
||||
if (m.ro) { m.ro.disconnect(); m.ro = null }
|
||||
clearTimeout(m.timer)
|
||||
m.timer = null
|
||||
}
|
||||
|
||||
// Ref-Callback für das Sichtfenster: beim Einhängen einpassen und verbinden, beim Aushängen alles lösen
|
||||
const view = (el) => {
|
||||
if (m.el === el) return
|
||||
loesen()
|
||||
m.el = el
|
||||
m.zeiger.clear()
|
||||
m.drag = null
|
||||
m.pinch = null
|
||||
if (!el) return
|
||||
verbinden()
|
||||
setze({ ...kameraPassend(groesse()), bereit: true, gleiten: false, ziehen: false })
|
||||
}
|
||||
|
||||
return {
|
||||
get st() { return m.st },
|
||||
set gesperrt(wert) { m.gesperrt = wert === true },
|
||||
view,
|
||||
verbinden,
|
||||
loesen,
|
||||
ganz,
|
||||
etage,
|
||||
nachZug,
|
||||
handlers: {
|
||||
onPointerDown: unten,
|
||||
onPointerMove: bewegen,
|
||||
onPointerUp: loslassen,
|
||||
onPointerCancel: loslassen,
|
||||
onDoubleClick: doppelklick,
|
||||
onKeyDown: taste,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook für die Welt. `gesperrt` = Detailansicht offen: Rad, Ziehen und Tasten wirken dann nicht.
|
||||
* Liefert { view (Ref-Callback für das Sichtfenster), s, x, y, bereit, gleiten, ziehen, handlers, ganz(), etage(oben, unten), nachZug() }.
|
||||
* s/x/y: Welt-Pixel mal s plus Verschiebung x/y (transform: translate(x, y) scale(s)).
|
||||
*/
|
||||
export default function useKamera({ gesperrt = false } = {}) {
|
||||
const halter = useRef(null)
|
||||
const [stand, setStand] = useState(null)
|
||||
if (halter.current === null) halter.current = baueKamera((neu) => setStand(neu))
|
||||
const kam = halter.current
|
||||
kam.gesperrt = gesperrt
|
||||
// Aufräumen beim Abbau; im Entwicklungsmodus (Effekte laufen doppelt) hängt der zweite Lauf Rad und Beobachter wieder ein
|
||||
useEffect(() => {
|
||||
kam.verbinden()
|
||||
return () => kam.loesen()
|
||||
}, [kam])
|
||||
const st = stand ?? kam.st
|
||||
return {
|
||||
view: kam.view,
|
||||
s: st.s,
|
||||
x: st.x,
|
||||
y: st.y,
|
||||
bereit: st.bereit,
|
||||
gleiten: st.gleiten,
|
||||
ziehen: st.ziehen,
|
||||
handlers: kam.handlers,
|
||||
ganz: kam.ganz,
|
||||
etage: kam.etage,
|
||||
nachZug: kam.nachZug,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Handgeschriebene Regeln der Hochhaus-Welt (HochhausWelt.jsx, Hustler-Pixelwelt T5). Nicht erzeugt: hochhaus.css kommt von scripts/hustler-import.mjs und
|
||||
wird nie von Hand geändert. Hier steht nur, was die Zeichenfläche als Inline-Stile hatte (Sichtfenster, Bühne, Raumschilder, Zettel, Schildtexte,
|
||||
Briefkasten-Zähler, Neon, Antenne, Sprite-Lagen der Figuren) und deshalb nicht im erzeugten CSS ist. Inline bleibt im JSX nur Geometrie aus
|
||||
lib/hustlerWelt.js und alles, was vom Zoom abhängt (transform der Bühne, Schriftgrößen über --hk-etikett und --hk-name). Alle Klassen beginnen mit hk-
|
||||
und stehen unter der Wurzel .hk-hochhaus. */
|
||||
|
||||
/* ---------- Sichtfenster: füllt die Wurzel, Ziehen und Pinch gehören der Kamera (touch-action: none) ---------- */
|
||||
.hk-hochhaus .hk-sicht {
|
||||
position: absolute; inset: 0;
|
||||
overflow: hidden; overflow: clip;
|
||||
touch-action: none; -webkit-user-select: none; user-select: none;
|
||||
cursor: grab;
|
||||
font-size: 15px; line-height: 20px;
|
||||
}
|
||||
.hk-hochhaus .hk-sicht.hk-zieht { cursor: grabbing; }
|
||||
.hk-hochhaus .hk-sicht > .hk-himmel,
|
||||
.hk-hochhaus .hk-sicht > .hk-funkeln { position: absolute; inset: 0; }
|
||||
.hk-hochhaus .hk-sicht > .hk-mond { position: absolute; right: 13%; top: 15%; width: 80px; height: 80px; }
|
||||
|
||||
/* ---------- Bühne: 2400 x 1200 Weltpixel, transform setzt die Kamera (translate und scale, Ursprung links oben) ---------- */
|
||||
.hk-hochhaus .hk-buehne { position: absolute; left: 0; top: 0; width: 2400px; height: 1200px; transform-origin: 0 0; }
|
||||
.hk-hochhaus .hk-buehne.hk-gleitet { transition: transform 0.55s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hk-hochhaus .hk-buehne.hk-gleitet { transition: none; }
|
||||
}
|
||||
|
||||
/* Schriftgrößen der Etagen-Namen und Namensschilder: wachsen beim Herauszoomen, damit sie lesbar bleiben (Werte setzt die Bühne) */
|
||||
.hk-hochhaus .hk-buehne .hk-etage-name { font-size: var(--hk-etikett, 15px); }
|
||||
.hk-hochhaus .hk-buehne .hk-namensschild { font-size: var(--hk-name, 12px); }
|
||||
|
||||
/* ---------- Neon „HUSTLER“ und Antenne (Lage und Größe inline aus hustlerWelt.js; Blinken steht in hochhaus.css) ---------- */
|
||||
.hk-hochhaus .hk-neon {
|
||||
position: absolute; display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Press Start 2P', 'Pixelify Sans', monospace; letter-spacing: 2px;
|
||||
color: #ffe27a; text-shadow: 0 0 4px #ffb84d, 0 0 12px #ff8a3d; pointer-events: none;
|
||||
}
|
||||
.hk-hochhaus .hk-antenne { position: absolute; background: #ff4d4d; box-shadow: 0 0 6px 2px rgba(255, 77, 77, 0.6); pointer-events: none; }
|
||||
|
||||
/* ---------- Raumschilder, Papier-Zettel für nicht angeschlossene Räume, Schildtexte, Briefkasten-Zähler ---------- */
|
||||
.hk-hochhaus .hk-raumschild { position: absolute; font-size: 8px; line-height: 9px; color: #4a5266; white-space: nowrap; pointer-events: none; }
|
||||
.hk-hochhaus .hk-zettel {
|
||||
position: absolute; padding: 1px 0; background: #f1ede4; color: #2b2230;
|
||||
font-size: 6px; line-height: 7px; text-align: center; white-space: nowrap;
|
||||
box-shadow: 0 0 0 1px #3a2f3d; pointer-events: none;
|
||||
}
|
||||
.hk-hochhaus .hk-schildtext { position: absolute; white-space: nowrap; pointer-events: none; }
|
||||
.hk-hochhaus .hk-schildtext-tresor { color: #2b3a33; }
|
||||
.hk-hochhaus .hk-schildtext-keller { color: #2f3646; }
|
||||
.hk-hochhaus .hk-briefkasten {
|
||||
position: absolute; box-sizing: border-box; padding: 0 2px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #e2483d; color: #ffffff; font-family: 'Press Start 2P', 'Pixelify Sans', monospace;
|
||||
box-shadow: 0 0 0 1px #3a1216, inset 0 1px 0 0 #ff7d70; pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- Figuren im Knopf: Sprite oben links, Kaffeepunkte und Zzz über dem Kopf (der Fehler-Marker steht in hustler-ui.css) ---------- */
|
||||
.hk-hochhaus .hk-hustler > .hk-spr { left: 0; top: 0; }
|
||||
.hk-hochhaus .hk-hustler > .hk-punkte { left: 14px; top: -6px; }
|
||||
.hk-hochhaus .hk-hustler > .hk-zzz { left: 15px; top: -11px; }
|
||||
Reference in new issue
Block a user