Hustler-Pixelwelt T11: alte helle Oberfläche und Kanvas-Engine entfernt, workers.css gekürzt, Zusicherungen in die neuen Tests übertragen

- gelöscht: components/workers/engine/* (10 Dateien), WorkersStage, WorkersBoard, WorkerCard, WorkerList, WorkersKpis,
  die neun Engine-Tests samt workersSmoke (211 Tests) und die Helfer engineVm.mjs und fakeCtx.mjs (nur die Engine-Tests brauchten sie)
- workers.css (310 -> 60 Zeilen) behält nur Einstellungen-Dialog und Interessenten-Panel; @font-face für Pixelify Sans steht jetzt in hustler/schrift.css
- workersComponents: kein innerHTML und kein Kanvas rekursiv unter components/workers/**, jeder Knopf mit type, Links nur aus geprüften Feldern
  (externe mit noopener), keine Anmeldung in den Bildschirmen, alte Dateien weg und nirgends mehr importiert
- workersCss: jede wk-Klasse im Code hat eine Regel, keine Regel ohne Nutzer; workersFont: Pixelify Sans genau einmal in schrift.css
- hustlerAssets: Pixelify Sans in schrift.css erlaubt; hustlerDialog: Link-Ziele nur aus safeLink, Knöpfe mit type; hustlerSeite: Laden/Fehler/bereit am selben Einhängepunkt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-22 09:09:37 +00:00
1 parent 4be9750f36
commit ba007a325a
35 files changed
+277 -6495

No files matched your search

-144
View File
@@ -1,144 +0,0 @@
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import Badge from '../ui/Badge'
import { counterLines, formatCount, formatUsd, safeLink, statusTone, tokenTotal, traceRows, workerStatusLabel } from '../../lib/workersView'
import { MODEL_IDS, hatForWorker, roleOf } from '../../lib/workersRoster'
import { drawWorker, setGrid } from './engine/sprites'
const DASH = '–'
// Modell-Chip je Hut (Wörterbuch statt zusammengesetzter Klasse: der CSS-Test in workers.css findet die Namen)
const CHIP = {
opus: 'wk-chip-opus',
sonnet: 'wk-chip-sonnet',
haiku: 'wk-chip-haiku',
overseer: 'wk-chip-overseer',
}
const PORTRAIT_W = 72
const PORTRAIT_H = 96
// Beginnt die Karte tiefer als so viele Pixel über der Unterkante des Fensters, wird sie nach oben geholt
const MIN_VISIBLE = 120
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// Status aus dem Zustandseintrag; ein nicht angeschlossener Worker ist grau (wie in indexWorkers)
function statusOf(worker) {
if (!isRecord(worker)) return ''
return worker.connected === false ? 'grau' : worker.status
}
// Porträt der Figur auf einem eigenen Canvas (Raster 4, Figur bei 6/8)
function Portrait({ hat, grey }) {
const ref = useRef(null)
useEffect(() => {
const canvas = ref.current
const ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null
if (!ctx) return
ctx.clearRect(0, 0, PORTRAIT_W, PORTRAIT_H)
setGrid(ctx, { k: 4 })
drawWorker(ctx, 6, 8, { hat, grey, scale: 1 })
}, [hat, grey])
return <canvas ref={ref} className="wk-portrait" width={PORTRAIT_W} height={PORTRAIT_H} aria-hidden="true" />
}
// Einziger Ort der Karte, an dem ein Ziel für einen Link entsteht: nur was safeLink durchlässt wird ein Link
// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text.
function TraceText({ link, children }) {
const safe = link ? safeLink(link.href) : null
if (!safe) return <>{children}</>
if (safe.external) return <a href={safe.href} target="_blank" rel="noopener noreferrer">{children}</a>
return <Link to={safe.href}>{children}</Link>
}
// Karte eines Workers: Porträt, Rolle, Modell, Status, Tageswerte und die letzten Aktionen.
// worker = Zustandseintrag (status, task, counters, connected), trace = Antwort von api.trace (oder null).
export default function WorkerCard({ workerId, worker, trace, loading, error, onClose }) {
const sectionRef = useRef(null)
const headingRef = useRef(null)
// Neue Auswahl: Der Name nimmt den Fokus (Screenreader sagen die Karte an), und liegt der Kartenanfang außerhalb des Fensters,
// wird die Karte an den oberen Rand geholt. Auf Handy und Tablet steht die Karte über der langen Liste, aus der man wählt.
useEffect(() => {
if (!workerId) return
const heading = headingRef.current
if (heading && typeof heading.focus === 'function') heading.focus({ preventScroll: true })
const section = sectionRef.current
if (!section || typeof section.scrollIntoView !== 'function') return
const top = section.getBoundingClientRect().top
const viewport = window.innerHeight || document.documentElement.clientHeight
const margin = parseFloat(window.getComputedStyle(section).scrollMarginTop) || 0
if (top < margin || top > viewport - MIN_VISIBLE) section.scrollIntoView({ block: 'start' })
}, [workerId])
if (!workerId) {
return (
<div className="wk-card">
<p className="wk-hint">Klicke eine Figur oder wähle einen Worker aus der Liste.</p>
</div>
)
}
const role = roleOf(workerId)
const info = trace && isRecord(trace.worker) ? trace.worker : null
const name = (info && info.name) || (role && role.name) || workerId
const roleText = (info && info.role) || (role && role.role) || ''
const modelId = (info && info.modelId) || (role ? MODEL_IDS[role.model] : '')
const hat = hatForWorker(workerId)
const status = statusOf(worker)
const today = trace && isRecord(trace.today) ? trace.today : null
const counters = counterLines(today && isRecord(today.counters) ? today.counters : worker && worker.counters)
const task = isRecord(worker) && typeof worker.task === 'string' && worker.task.trim() !== '' ? worker.task : DASH
const rows = traceRows(trace && trace.events, Date.now())
return (
<section className="wk-card" aria-label="Worker-Karte" ref={sectionRef}>
{onClose && <button type="button" className="btn btn-quiet" onClick={onClose} aria-label="Karte schließen">×</button>}
<Portrait hat={hat} grey={status === 'grau'} />
<h2 className="wk-name" ref={headingRef} tabIndex={-1}>{name}</h2>
{roleText && <p className="wk-role">{roleText}</p>}
<p>
{modelId && <span className={`wk-chip ${CHIP[hat]}`}>{modelId}</span>}
<Badge tone={statusTone(status)}>{workerStatusLabel(status)}</Badge>
</p>
{error && <div className="msg err" role="alert">{error}</div>}
<dl className="wk-facts">
<div><dt>Macht gerade</dt><dd>{task}</dd></div>
<div><dt>Tokens heute</dt><dd>{formatCount(today ? tokenTotal(today.tokens) : null)}</dd></div>
<div><dt>Kosten heute</dt><dd>{formatUsd(today ? today.costUsd : null)}</dd></div>
</dl>
{counters.length > 0 && (
<div className="wk-counters">
<h3>Zähler heute</h3>
<dl>
{counters.map((c) => (
<div key={c.key}><dt>{c.label}</dt><dd>{c.value}</dd></div>
))}
</dl>
</div>
)}
{(loading || trace) && (
<>
<h3>Letzte Aktionen</h3>
{loading && <p className="wk-hint">Spur wird geladen …</p>}
{!loading && rows.length === 0 && <p className="wk-hint">Noch keine Aktionen.</p>}
{rows.length > 0 && (
<ol className="wk-trace">
{rows.map((row, i) => (
<li key={`${row.id}-${i}`}>
<span className="wk-trace-time">{row.time}</span>{' '}
<TraceText link={row.link}>{row.text}</TraceText>
</li>
))}
</ol>
)}
</>
)}
</section>
)
}
-62
View File
@@ -1,62 +0,0 @@
import { workerStatusLabel } from '../../lib/workersView'
import { FLOORS, FLOOR_TITLES, ROLES } from '../../lib/workersRoster'
// Statuspunkt je Dienst-Status (Wörterbuch statt zusammengesetzter Klasse: der CSS-Test in workers.css findet die Namen)
const DOT = {
arbeitet: 'wk-dot-arbeitet',
wartet: 'wk-dot-wartet',
leerlauf: 'wk-dot-leerlauf',
grau: 'wk-dot-grau',
fehler: 'wk-dot-fehler',
}
// Etagen von oben nach unten mit ihren Workern; Etagen ohne Worker (Pult, Tresor) entfallen
const GROUPS = FLOORS.map((floor) => ({ floor, roles: ROLES.filter((r) => r.floor === floor) })).filter((g) => g.roles.length > 0)
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// workers = Tabelle nach workerId (indexWorkers/reduceEvents) oder die Liste aus state.workers; unbekannt -> null
function entryOf(workers, workerId) {
if (Array.isArray(workers)) return workers.find((w) => isRecord(w) && w.workerId === workerId) ?? null
return isRecord(workers) && Object.hasOwn(workers, workerId) && isRecord(workers[workerId]) ? workers[workerId] : null
}
// Status aus dem Eintrag; ein nicht angeschlossener Worker ist grau (wie in indexWorkers)
function statusOf(entry) {
if (!entry) return ''
return entry.connected === false ? 'grau' : entry.status
}
// Alle Worker je Etage als Knopfliste. Ein Klick meldet die workerId über onSelect, der gewählte Knopf ist aria-pressed.
export default function WorkerList({ workers, selectedId, onSelect }) {
return (
<div className="wk-list">
{GROUPS.map((group) => (
<section key={group.floor}>
<h3 className="wk-list-floor">{FLOOR_TITLES[group.floor]}</h3>
<ul>
{group.roles.map((role) => {
const status = statusOf(entryOf(workers, role.workerId))
const label = workerStatusLabel(status)
return (
<li key={role.workerId}>
<button
type="button"
data-wk-worker={role.workerId}
aria-pressed={selectedId === role.workerId}
aria-label={`${role.name}, ${label}`}
onClick={() => { if (onSelect) onSelect(role.workerId) }}
>
<span className={Object.hasOwn(DOT, status) ? `wk-dot ${DOT[status]}` : 'wk-dot'} aria-hidden="true" />
<span>{role.name}</span>
<span>{label}</span>
</button>
</li>
)
})}
</ul>
</section>
))}
</div>
)
}
-190
View File
@@ -1,190 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Button from '../ui/Button'
import Dialog from '../ui/Dialog'
import EmptyState from '../ui/EmptyState'
import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed'
import { indexWorkers, reduceEvents } from '../../lib/workersEvents'
import { pauseLabel } from '../../lib/workersView'
import WorkerCard from './WorkerCard'
import WorkerList from './WorkerList'
import WorkersKpis from './WorkersKpis'
import WorkersSettingsDialog from './WorkersSettingsDialog'
import WorkersStage from './WorkersStage'
const RUNNING_TEXT = 'Läuft'
const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.'
const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.'
const NO_REASON_TEXT = 'Grund nicht angegeben'
// Grund der Notbremse für den Satz „Versand gestoppt: <Grund>. …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“)
function haltReasonOf(reason) {
const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : ''
return text || NO_REASON_TEXT
}
// Inhalt des Reiters „Hochhaus“: Kopfzeile mit Pausenschalter, Kennzahlen, Hochhaus mit Seitenspalte (Karte und Liste).
// Ohne Auth-Bezug: isAdmin kommt von der Seite. stageRef ist optional (nur der Prüfstand reicht eine durch); ohne eins legt das Board
// selbst eins an, damit pushEvents die Bühne immer erreicht.
export default function WorkersBoard({ api, isAdmin, stageRef }) {
const ownRef = useRef(null)
const ref = stageRef ?? ownRef
const aliveRef = useRef(true)
const sideRef = useRef(null)
const toolbarRef = useRef(null)
const refocusPauseRef = useRef(false) // nach dem Umschalten bekommt der Knopf „Alles anhalten“ / „Fortsetzen“ den Fokus zurück, falls er ihn verlor
const [workers, setWorkers] = useState({})
const [selectedId, setSelectedId] = useState(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [pausing, setPausing] = useState(false)
const [pauseError, setPauseError] = useState('')
const [confirmOpen, setConfirmOpen] = useState(false)
useEffect(() => {
aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
// Neue Ereignisse: Tabelle fortschreiben und der Bühne vorspielen (die Bühne kennt den Rückstau selbst)
const onEvents = useCallback((events) => {
setWorkers((w) => reduceEvents(w, events))
if (ref.current) ref.current.pushEvents(events)
}, [ref])
const { state, error, loaded, refresh } = useWorkersFeed(api, { onEvents })
useEffect(() => {
if (state) setWorkers(indexWorkers(state))
}, [state])
const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId)
// Fokus nach dem Umschalten: Während pausing gilt, ist der Knopf der Leiste gesperrt. Schließt der Dialog im selben Takt, in dem pausing
// beginnt, kann useDialogFocus den Fokus nicht auf den gesperrten Knopf zurückgeben, und er bliebe für immer auf <body>. Deshalb
// wird er erst hier gesetzt, wenn pausing wieder endet (auch nach einem Fehler) und der Knopf wieder bedienbar ist. Hat der Mensch
// inzwischen woanders hin gewechselt (Fokus nicht auf <body>), bleibt es dabei.
useEffect(() => {
if (pausing || !refocusPauseRef.current) return
refocusPauseRef.current = false
const active = document.activeElement
if (active && active !== document.body) return
const button = toolbarRef.current?.querySelector('[data-wk-pause]')
if (button) button.focus()
}, [pausing])
// Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen
const haltedByState = Boolean(state?.paused)
useEffect(() => {
if (haltedByState) setConfirmOpen(false)
}, [haltedByState])
if (!loaded || !state) {
if (error) {
return (
<EmptyState
title="Der Hustler-Dienst ist noch nicht erreichbar"
hint={<p>{error}</p>}
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
/>
)
}
return <div className="loading">Wird geladen …</div>
}
const paused = Boolean(state.paused)
const worker = selectedId && Object.hasOwn(workers, selectedId) ? workers[selectedId] : undefined
// Karte schließen: der Fokus geht zurück auf den Listeneintrag des Workers (sonst fiele er mit dem Knopf „ד ins Leere)
const closeCard = () => {
const id = selectedId
setSelectedId(null)
if (!id || !sideRef.current) return
for (const button of sideRef.current.querySelectorAll('[data-wk-worker]')) {
if (button.getAttribute('data-wk-worker') === id) {
button.focus()
return
}
}
}
// Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert (true = anhalten), danach wird der Zustand sofort neu geholt.
// Der Zielwert wird übergeben und nicht aus dem Zustand abgeleitet: ein offener Dialog darf nie fortsetzen, falls inzwischen
// jemand anderes angehalten hat.
const setPause = async (target) => {
if (pausing) return
refocusPauseRef.current = true
setPausing(true)
setPauseError('')
try {
await api.pause(target)
refresh()
} catch (err) {
if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT)
}
if (aliveRef.current) setPausing(false)
}
// Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage
const requestPause = () => {
if (paused) setPause(false)
else setConfirmOpen(true)
}
const confirmHalt = () => {
setConfirmOpen(false)
setPause(true)
}
return (
<>
<div className="wk-toolbar" ref={toolbarRef}>
<span className="wk-status">{paused ? PAUSED_TEXT : RUNNING_TEXT}</span>
<span className="grow" />
<Button type="button" onClick={requestPause} disabled={pausing} data-wk-pause>{pauseLabel(paused)}</Button>
{isAdmin && <Button type="button" onClick={() => setSettingsOpen(true)}>Einstellungen</Button>}
</div>
{pauseError && <div className="msg err" role="alert">{pauseError}</div>}
{state.sendingHalted === true && (
<div className="msg err" role="alert">
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
</div>
)}
{error && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
<WorkersKpis kpis={state.kpis} />
<div className="wk-main">
<WorkersStage ref={ref} state={state} selectedId={selectedId} onSelect={setSelectedId} />
<div className="wk-side" ref={sideRef}>
<WorkerCard
workerId={selectedId}
worker={worker}
trace={trace}
loading={traceLoading}
error={traceError}
onClose={closeCard}
/>
<WorkerList workers={workers} selectedId={selectedId} onSelect={setSelectedId} />
</div>
</div>
{isAdmin && settingsOpen && (
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
)}
{confirmOpen && !paused && (
<Dialog
title="Alles anhalten?"
onClose={() => setConfirmOpen(false)}
footer={
<>
<span className="grow" />
<Button type="button" onClick={() => setConfirmOpen(false)}>Abbrechen</Button>
<Button variant="primary" type="button" onClick={confirmHalt}>Anhalten</Button>
</>
}
>
<p>Die Worker hören sofort auf, bis jemand fortsetzt.</p>
</Dialog>
)}
</>
)
}
-13
View File
@@ -1,13 +0,0 @@
import Stat from '../ui/Stat'
import { kpiTiles } from '../../lib/workersView'
// Sechs Kennzahlen des Hustler-Dienstes (state.kpis). Fehlende Daten ergeben Striche, nie NaN (kpiTiles).
export default function WorkersKpis({ kpis }) {
return (
<div className="stats wk-kpis">
{kpiTiles(kpis).map((tile) => (
<Stat key={tile.key} label={tile.label} value={tile.value} sub={tile.sub} tone={tile.tone} />
))}
</div>
)
}
-120
View File
@@ -1,120 +0,0 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import Button from '../ui/Button'
import { ZOOM_STEP, mountTower } from './engine/scene'
const FONT = '600 12px "Pixelify Sans"'
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)'
const COARSE_POINTER = '(pointer: coarse)'
// matchMedia, wo es das gibt (sonst null)
const media = (query) => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(query) : null)
const NO_STATS = { frames: 0, avgDrawMs: 0, zoom: 1 }
const NO_DEBUG = { pending: 0, agents: [], lift: 'idle', fx: 0 }
// Bühne des Hochhauses: ein Canvas, den die Engine (mountTower) bespielt, und die Zoomknöpfe.
// state = Zustand der Dienst-API (oder null), selectedId = markierte Figur, onSelect(id | null) kommt nur bei Klicks auf die Leinwand.
// Handle über ref: { pushEvents(events), locate(id), stats(), debug() }; vor dem Einhängen der Engine sind alle Aufrufe leer.
// Die Engine wird neu eingehängt, wenn sich „weniger Bewegung“ ändert (Standbild statt Schleife); engineKey zählt die Einhängungen,
// damit Zustand und Markierung an jede neue Engine gehen (auch bei StrictMode: einhängen, abbauen, einhängen).
// Im Standbild kostet jeder Aufruf ein Bild: darum kein setState vor der ersten Einhängung, kein select ohne Änderung und
// kein resize, solange die Breite gleich bleibt.
export default forwardRef(function WorkersStage({ state, selectedId, onSelect }, ref) {
const canvasRef = useRef(null)
const handleRef = useRef(null)
const onSelectRef = useRef(onSelect)
const shownRef = useRef(null) // Markierung, die die aktuelle Engine zeigt (eine neue Engine startet ohne)
const [reduceMotion, setReduceMotion] = useState(() => Boolean(media(REDUCED_MOTION)?.matches))
const [engineKey, setEngineKey] = useState(0)
useEffect(() => { onSelectRef.current = onSelect }, [onSelect])
useImperativeHandle(ref, () => ({
pushEvents: (events) => { if (handleRef.current) handleRef.current.pushEvents(events) },
locate: (id) => (handleRef.current ? handleRef.current.locate(id) : null),
stats: () => (handleRef.current ? handleRef.current.stats() : NO_STATS),
debug: () => (handleRef.current ? handleRef.current.debug() : NO_DEBUG),
}), [])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return undefined
const handle = mountTower(canvas, {
onSelect: (id) => { if (onSelectRef.current) onSelectRef.current(id) },
reducedMotion: reduceMotion,
coarsePointer: Boolean(media(COARSE_POINTER)?.matches),
fontsReady: document.fonts && typeof document.fonts.load === 'function' ? document.fonts.load(FONT) : undefined,
dpr: window.devicePixelRatio || 1,
})
handleRef.current = handle
shownRef.current = null
// mountTower hat schon gemessen; erst eine andere Breite löst resize() aus (der erste Aufruf des Beobachters meldet nur den Ist-Wert)
let width = canvas.getBoundingClientRect().width
const observer = typeof ResizeObserver === 'function'
? new ResizeObserver(() => {
const next = canvas.getBoundingClientRect().width
if (next === width) return
width = next
handle.resize()
})
: null
if (observer) observer.observe(canvas)
// Versteckter Tab: die Szene ruht. Beim Zurückkehren wird die Regie beruhigt (flush), damit nichts aus der Zeit davor nachläuft.
// Die Ereignisse, die inzwischen aufgelaufen sind, holt der Feed selbst und gibt sie danach über pushEvents herein.
const onVisibility = () => {
const hidden = document.hidden
handle.setPaused(hidden)
if (!hidden) handle.flush()
}
document.addEventListener('visibilitychange', onVisibility)
if (document.hidden) handle.setPaused(true)
const motion = media(REDUCED_MOTION)
const onMotion = (e) => setReduceMotion(Boolean(e.matches))
if (motion) motion.addEventListener('change', onMotion)
setEngineKey((k) => k + 1)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
if (motion) motion.removeEventListener('change', onMotion)
if (observer) observer.disconnect()
handle.destroy()
if (handleRef.current === handle) handleRef.current = null
}
}, [reduceMotion])
// engineKey 0 = noch keine Engine eingehängt (der Zähler springt gleich nach dem Einhängen auf 1)
useEffect(() => {
if (engineKey > 0 && state && handleRef.current) handleRef.current.setState(state)
}, [state, engineKey])
useEffect(() => {
const handle = handleRef.current
const wanted = selectedId ?? null
if (!handle || wanted === shownRef.current) return
shownRef.current = wanted
handle.select(wanted)
}, [selectedId, engineKey])
const zoomBy = (factor) => { if (handleRef.current) handleRef.current.zoomBy(factor) }
const resetView = () => { if (handleRef.current) handleRef.current.resetView() }
return (
<div className="wk-stage">
<canvas
ref={canvasRef}
className="wk-canvas"
role="img"
aria-label="Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste."
/>
<div className="wk-zoom">
<Button type="button" onClick={() => zoomBy(ZOOM_STEP)} aria-label="Vergrößern">+</Button>
<Button type="button" onClick={() => zoomBy(1 / ZOOM_STEP)} aria-label="Verkleinern">−</Button>
<Button type="button" onClick={resetView} aria-label="Ganzes Hochhaus zeigen">Ganz</Button>
</div>
</div>
)
})
-114
View File
@@ -1,114 +0,0 @@
// Gebäudehülle des Hustler-Hochhauses: Boden und Erde, Dach, Aufzugschacht, Etagenböden und -wände, Schachttüren mit Lampen,
// Etagenschilder rechts außen und der Pausenschleier.
// Portiert aus entwuerfe/scene-turm.js (drawOutside Z. 99-111, drawBuilding Z. 118-172) auf 10 Etagen. Die Stadtsilhouette,
// das Rohrpost-Rohr und die Welle der Vorlage entfallen. Die Räume selbst zeichnet rooms.js (danach, über der Rückwand).
// Etagenmaße kommen aus dem Layout, die Hüllenmaße (Turm x 14..230, Räume x 42..228) stehen hier. Kein React, kein Appwrite,
// kein window/document.
import { px, tint, label, PAL, TEAM } from './sprites.js'
import { WORLD } from './layout.js'
const GROUND_Y = 224 // Bodenlinie = Boden der Tresor-Etage (Erdgeschoss); darunter liegt der Keller
const ROOF_Y = 4 // Oberkante der Dachlinie
const HULL_X0 = 14 // Turm: linke Außenkante
const HULL_W = 216 // Turm: Breite (x 14..230)
const WALL_W = 2 // Wandstärke
const SHAFT_X = 16 // Aufzugschacht x 16..40
const SHAFT_W = 24
const SHAFT_WALL_X = 40 // Trennwand zwischen Schacht und Räumen
const ROOMS_X0 = 42
const ROOMS_W = 186
const DOOR_H = 18 // Schachttür: Sturz 18 über dem Boden
const LAMP_X = 43
const SIGN_DOT_X = 230 // Etagenschild: Punkt rechts neben der Außenwand, Text bei x 234
const SIGN_X = 234
const SIDE_DOOR_X = 225
const C = {
wall: '#aab2c0',
back: '#f3f4f7',
strip: '#e6e9ef',
shaft: '#e4e8ef',
rail: '#d3d8e1',
frame: PAL.metal2,
mute: PAL.mute,
lampOff: '#c3cad6',
ground: PAL.metal,
earth: '#f7f4f0',
speck: '#e8e2da',
post: '#d5dae3',
doorWood: '#a8825f',
}
// Erdflecken: [x, Abstand unter der Bodenlinie]; die mittleren liegen unter dem Keller und sind verdeckt
const SPECKS = [[4, 7], [9, 15], [6, 25], [244, 9], [262, 17], [290, 8], [306, 19], [300, 26], [60, 28], [140, 30], [210, 27]]
// Boden, Erde, Dachlinie mit Fahne und eine Laterne links. Wird vor dem Turm gezeichnet.
export function drawOutside(ctx) {
px(ctx, 0, GROUND_Y + 1, WORLD.w, WORLD.h - GROUND_Y - 1, C.earth)
px(ctx, 0, GROUND_Y, WORLD.w, 1, C.ground)
for (const [x, dy] of SPECKS) px(ctx, x, GROUND_Y + dy, 2, 1, C.speck)
// Laterne
px(ctx, 6, GROUND_Y - 19, 1, 19, C.post)
px(ctx, 4, GROUND_Y - 21, 5, 2, C.post)
px(ctx, 5, GROUND_Y - 19, 3, 1, '#fff3c4')
// Dachlinie und Fahne
px(ctx, HULL_X0, ROOF_Y, HULL_W, 2, C.frame)
px(ctx, 60, 0, 1, 4, C.frame)
px(ctx, 61, 0, 4, 2, TEAM.freigabe)
}
// Hülle, Schacht, Rückwände, Böden, Schachttüren mit Lampen (grün: Kabine steht auf der Etage) und die Etagenschilder.
// t ist im Aufruf vorgesehen (Takt der Szene), die Hülle bewegt sich heute nicht.
export function drawTower(ctx, layout, t, lift) {
const floors = layout.floors
if (floors.length === 0) return
const topY = floors[0].top
const bottom = floors[floors.length - 1].floorY
// Schacht mit Schienen
px(ctx, SHAFT_X, topY, SHAFT_W, bottom - topY, C.shaft)
px(ctx, SHAFT_X, topY, 1, bottom - topY, C.rail)
px(ctx, SHAFT_X + SHAFT_W - 1, topY, 1, bottom - topY, C.rail)
// Rückwand und Bodenstreifen jeder Etage
for (const f of floors) {
px(ctx, ROOMS_X0, f.top, ROOMS_W, f.h, C.back)
px(ctx, ROOMS_X0, f.floorY - 3, ROOMS_W, 3, C.strip)
}
// Trennwand zum Schacht mit Türöffnung je Etage, Sturz und Etagenlampe
px(ctx, SHAFT_WALL_X, topY, WALL_W, bottom - topY, C.back)
floors.forEach((f, i) => {
px(ctx, SHAFT_WALL_X, f.top, WALL_W, f.h - DOOR_H, C.wall)
px(ctx, SHAFT_WALL_X, f.floorY - DOOR_H, WALL_W, 1, C.frame)
px(ctx, LAMP_X, f.floorY - 14, 2, 2, lift.at(i) ? PAL.green : C.lampOff)
})
// Etagenböden, Fundament und Seitenwände
for (const f of floors) px(ctx, HULL_X0, f.floorY, HULL_W, 1, C.wall)
px(ctx, HULL_X0, bottom, HULL_W, 2, C.frame)
px(ctx, HULL_X0, ROOF_Y, WALL_W, bottom + 2 - ROOF_Y, C.wall)
px(ctx, ROOMS_X0 + ROOMS_W, ROOF_Y, WALL_W, bottom + 2 - ROOF_Y, C.wall)
// Seitentür im Erdgeschoss (Tresor-Etage): hier gehen die Scouts in die Stadt
const ground = floors.find((f) => f.id === 'tresor')
if (ground) {
const g = ground.floorY
px(ctx, ROOMS_X0 + ROOMS_W, g - 14, WALL_W, 11, C.back)
px(ctx, ROOMS_X0 + ROOMS_W, g - 3, WALL_W, 3, C.strip)
px(ctx, SIDE_DOOR_X, g - 16, 8, 2, C.doorWood)
px(ctx, SIDE_DOOR_X, g - 14, 2, 14, C.doorWood)
px(ctx, SIDE_DOOR_X + 6, g - 14, 2, 14, C.doorWood)
}
// Schachtkopf mit Rolle
px(ctx, 23, topY, 11, 3, C.frame)
px(ctx, 24, topY + 1, 9, 1, C.ground)
// Etagenschilder rechts außen: Punkt in der Teamfarbe des ersten Raums, Titel aus dem Layout
for (const f of floors) {
px(ctx, SIGN_DOT_X, f.top + 2, 2, 2, TEAM[f.rooms[0]?.team] ?? C.wall)
label(ctx, f.title, SIGN_X, f.top + 1, { size: 11, color: C.mute, weight: 600 })
}
}
// Weißer Schleier mit „Pausiert“ über der ganzen Welt; nur bei look.paused
export function drawPauseVeil(ctx, look) {
if (!look.paused) return
tint(ctx, -2, -2, WORLD.w + 4, WORLD.h + 4, PAL.w, 0.45)
label(ctx, 'Pausiert', WORLD.w / 2, WORLD.h / 2 - 5, { size: 28, color: PAL.ink, weight: 700, align: 'center', bg: PAL.w })
}
-79
View File
@@ -1,79 +0,0 @@
// Kamera des Hochhauses: Ausschnitt der Welt 320 × 256, Zoom 1 bis 3, Verschieben, Umkehr-Transformation, Treffer-Auswahl.
// Das Sichtfeld ist Welt / zoom. Orte in der Ansicht sind Bruchteile 0..1 (links oben = 0, 0), Orte in der Welt logische Pixel.
// Die Funktionen zoomAt, panBy, centerOn und clampCamera ändern die übergebene Kamera und geben dieselbe Kamera zurück.
// Kein React, kein window/document.
import { WORLD } from './layout.js'
export const ZOOM_MIN = 1
export const ZOOM_MAX = 3
const FIT_WIDTH = 560 // ab dieser Breite (CSS-Pixel) passt das ganze Hochhaus bei Zoom 1 lesbar auf die Bühne
const FIT_MAX = 2.2
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
// Endliche Zahl oder Ersatzwert: NaN/Infinity aus Zeigerereignissen dürfen die Kamera nie verderben
const num = (v, fallback) => (Number.isFinite(v) ? v : fallback)
// Startzoom für eine Bühne der Breite cssWidth: ab 560 px 1, darunter wachsend bis 2,2; ungültige Breite -> 1
export function fitZoom(cssWidth) {
if (!Number.isFinite(cssWidth) || cssWidth <= 0 || cssWidth >= FIT_WIDTH) return 1
return Math.min(FIT_MAX, FIT_WIDTH / cssWidth)
}
export function createCamera() {
return { zoom: 1, x: 0, y: 0 }
}
// Zoom in 1..3 und Lage so, dass der Ausschnitt in der Welt bleibt
export function clampCamera(cam, world = WORLD) {
cam.zoom = clamp(num(cam.zoom, ZOOM_MIN), ZOOM_MIN, ZOOM_MAX)
cam.x = clamp(num(cam.x, 0), 0, world.w - world.w / cam.zoom)
cam.y = clamp(num(cam.y, 0), 0, world.h - world.h / cam.zoom)
return cam
}
// Ansicht (Bruchteile) -> Welt (logische Pixel)
export function viewToWorld(cam, vx, vy) {
return { x: cam.x + vx * (WORLD.w / cam.zoom), y: cam.y + vy * (WORLD.h / cam.zoom) }
}
// Welt -> Ansicht; Gegenstück zu viewToWorld
export function worldToView(cam, x, y) {
return { vx: (x - cam.x) / (WORLD.w / cam.zoom), vy: (y - cam.y) / (WORLD.h / cam.zoom) }
}
// Zoom um factor; der Weltpunkt unter (fx, fy) bleibt stehen, solange der Rand nicht klemmt
export function zoomAt(cam, factor, fx, fy) {
const f = factor > 0 ? num(factor, 1) : 1
const px = num(fx, 0.5)
const py = num(fy, 0.5)
const w = viewToWorld(cam, px, py)
cam.zoom = clamp(cam.zoom * f, ZOOM_MIN, ZOOM_MAX)
cam.x = w.x - px * (WORLD.w / cam.zoom)
cam.y = w.y - py * (WORLD.h / cam.zoom)
return clampCamera(cam)
}
// Verschieben um Bruchteile der Ansicht (0,5 = halbe Bildbreite)
export function panBy(cam, dxFrac, dyFrac) {
cam.x += num(dxFrac, 0) * (WORLD.w / cam.zoom)
cam.y += num(dyFrac, 0) * (WORLD.h / cam.zoom)
return clampCamera(cam)
}
// Weltpunkt (x, y) in die Mitte der Ansicht
export function centerOn(cam, x, y) {
cam.x = x - WORLD.w / cam.zoom / 2
cam.y = y - WORLD.h / cam.zoom / 2
return clampCamera(cam)
}
// Oberste Figur an (x, y) in Weltkoordinaten; boxes in Zeichenreihenfolge (die letzte liegt oben), pad vergrößert jeden Kasten
export function pickAgent(boxes, x, y, pad = 0) {
if (!Array.isArray(boxes)) return null
for (let i = boxes.length - 1; i >= 0; i--) {
const b = boxes[i]?.box
if (b && x >= b.x - pad && x <= b.x + b.w + pad && y >= b.y - pad && y <= b.y + b.h + pad) return boxes[i].id
}
return null
}
-673
View File
@@ -1,673 +0,0 @@
// Regie des Hustler-Hochhauses: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Schritte (Übergaben mit Aufzugfahrten,
// Effekte, Sprechblasen), Rückstau-Kappung, Wachhund und Zeichnen.
// Portiert aus entwuerfe/scene-turm.js (Agenten Z. 596-625, Schritte Z. 416-464, Update Z. 864-905, Zeichnen Z. 925-960).
// Kein React, kein Appwrite, kein window/document.
import { ROLES, hatForWorker } from '../../../lib/workersRoster.js'
import { indexWorkers, reduceEvents, eventToSteps, cutBubble } from '../../../lib/workersEvents.js'
import { homeOf } from './layout.js'
import { LIFT } from './lift.js'
import { spawnFx } from './fx.js'
import { trophyPaging } from './rooms.js'
import { drawWorker, drawBubble, drawSideBubble, drawAlertMark, drawPaper } from './sprites.js'
export const BACKLOG_KEEP = 6 // je pushEvents animieren nur die neuesten so viele Gruppen, ältere wirken still
export const GROUPS_PER_AGENT = 4 // je Worker höchstens so viele wartende Gruppen
export const IDLE_COFFEE_S = 25 // so lange trinkt ein Worker im Leerlauf Kaffee, dann schläft er
export const STEP_TIMEOUT_S = 45 // ein Schritt dauert höchstens so lange, dann greift der Wachhund
export const BUBBLE_MAX = 3 // höchstens so viele Sprechblasen gleichzeitig
const PAGE_S = 6 // Pokal-Seiten blättern alle 6 Sekunden
const POP_S = 1.2 // so lange blinkt ein neuer Pokal
const BUBBLE_S = 2.2 // Standarddauer einer Sprechblase
const LANE = -2 // wer am Platz steht, wird 2 Einheiten höher gezeichnet (hinter dem Tisch)
const LANE_SPEED = 14 // Einheiten je Sekunde, mit der die Spur wechselt
const HOME_EPS = 0.5 // so nah am Heimatplatz gilt ein Worker als am Platz
const ALERT_H = 9 // Höhe des „!“ mit Umriss (sprites.js)
const WALK_SPEED = 46 // Einheiten je Sekunde (Vorlage WALK 38, schnell 46)
const ARRIVE_EPS = 0.5 // so nah am Ziel gilt ein Weg als zu Ende
const PICKUP_S = 0.4 // so lange dauert es, das Dokument aufzunehmen
const SAY_S = 1.2 // so lange wartet die Gruppe nach einer Sprechblase, bevor der nächste Schritt kommt
const THANKS_S = 1.0 // so lange bleibt der Giver nach der Übergabe beim Empfänger stehen
const CHEER_S = 2 // so lange jubelt ein Worker
const VAULT_FLASH_S = 0.7 // Tresor-Blitz, wenn eine Münze ankommt
const BESIDE_DX = 12 // Abstand zum Empfänger bei der Übergabe
const CAB_DY = 7 // Fahrgast: obere Kante des Körpers 7 unter der Kabinenoberkante (wie in lift.js)
const MAX_STEPS_PER_FRAME = 32 // so viele Schritte endet ein Worker höchstens in einem Bild (Schutz vor Endlosschleifen)
// Blickrichtung am Heimatplatz (Vorlage FACE); alle anderen blicken nach rechts
const FACE_LEFT = new Set(['ali', 'hanna', 'sven', 'lea', 'paul', 'clara'])
const faceOf = (id) => (FACE_LEFT.has(id) ? -1 : 1)
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
// Tiefe für die Zeichenreihenfolge: wer weiter unten steht, wird später (davor) gezeichnet
const depthOf = (a) => a.y + a.lane + a.bob + 13 * a.scale
const byDepth = (agents) => [...agents].sort((p, q) => depthOf(p) - depthOf(q))
// Pose nach Status: nicht angeschlossen schläft, Leerlauf trinkt Kaffee und schläft nach IDLE_COFFEE_S, Arbeit tippt
function poseFor(a) {
if (a.grey) return 'sleep'
if (a.status === 'leerlauf') return a.idleT >= IDLE_COFFEE_S ? 'sleep' : 'coffee'
if (a.status === 'arbeitet') return 'type'
return 'stand'
}
// Läuft für den Worker eine Gruppe? Dann bestimmt die Gruppe die Pose, nicht der Status
const busy = (a) => a.groups > 0
// Steht der Worker an seinem Platz? (kein Schritt, nicht im Aufzug)
const atHome = (a) => a.frame === 0 && Math.abs(a.x - a.home.x) < HOME_EPS && Math.abs(a.y - a.home.y) < HOME_EPS
// Spur: am Platz -2 (Otto steht immer vorn), unterwegs 0
const laneTarget = (a) => (a.id !== 'otto' && atHome(a) ? LANE : 0)
// Zähler als ganze Zahl >= 0; alles andere (NaN, Text, null, fehlend) ist kein Wert
const kpiOf = (kpis, key) => (isRecord(kpis) && Number.isFinite(kpis[key]) ? Math.max(0, Math.floor(kpis[key])) : null)
// Läuft auf (tx, ty) zu (Vorlage stepTowards); speed in Einheiten je Sekunde; true = angekommen (dann steht die Figur: frame 0)
function stepTowards(a, tx, ty, speed, dt) {
const dx = tx - a.x
const dy = ty - a.y
const dist = Math.hypot(dx, dy)
if (dist < ARRIVE_EPS) {
a.x = tx
a.y = ty
a.frame = 0
return true
}
const step = Math.min(dist, speed * dt)
a.x += (dx / dist) * step
a.y += (dy / dist) * step
if (Math.abs(dx) > 0.3) a.facing = dx < 0 ? -1 : 1
a.walkT += dt
a.frame = 1 + (Math.floor(a.walkT * 8) % 2)
return false
}
/* createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random })
→ { agents, byId, applyState(state), pushEvents(events), update(dt), flush(), reset(), pending(), debug(), boxes(), bubble(agent, text, dur) }
Ein Agent je Rolle (Rollen ohne Heimatplatz im Layout entfallen). applyState übernimmt einen Dienst-Zustand: der erste Snapshot setzt
alle Agenten auf ihren Platz, spätere bewegen nie und ändern Pose und Aufgabe nur bei Agenten ohne laufende Gruppe (groups = 0); was dabei
ausfällt, holt die Regie am Ende der letzten Gruppe aus der Tabelle nach. Die Tabelle (workers) bleibt dauerhaft: Snapshot, fortgeschrieben mit
reduceEvents.
pushEvents(events) übersetzt jedes Ereignis über eventToSteps in eine Gruppe von Schritten. Besitzer der Gruppe ist der Absender des ersten
give, sonst die workerId (wenn es den Worker gibt), sonst Otto. Die neuesten BACKLOG_KEEP Gruppen laufen als Animation in der Warteschlange
ihres Besitzers (agent.q, agent.groups), alle älteren wirken still (nur Aufgabe und Pose); still ist auch jede Gruppe eines grauen Besitzers
und jede, die der Besitzer wegen GROUPS_PER_AGENT nicht mehr aufnimmt. Vor dem ersten Snapshot tut pushEvents nichts.
update(dt) tut bei look.paused nichts und läuft sonst ohne Obergrenze für dt (flush ruft es mit 0,25).
Wachhund: ein Schritt, der länger als STEP_TIMEOUT_S läuft, bricht die Gruppe ab (Aufzug freigeben, Türzähler zurücknehmen, Agent heim).
flush() spielt alles schnell zu Ende und setzt dann alles zurück, reset() setzt sofort zurück (Warteschlangen leer, Aufzug und Effekte
zurückgesetzt, alle Agenten heim). pending() = wartende und laufende Gruppen über alle Agenten. rnd streut die Wipp-Phase. */
export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) {
const agents = []
const byId = Object.create(null)
const door = new Array(layout.floors.length).fill(0) // Wartende vor dem Aufzug je Etage (Türzähler)
const counted = new Map() // Agent -> Etage, solange er im Türzähler mitgezählt wird
const lagging = new Set() // Kennungen, bei denen Aufgabe und Pose noch aus der Tabelle nachzuholen sind
let workers = {} // Tabelle nach workerId: letzter Snapshot, fortgeschrieben mit den Ereignissen (indexWorkers, reduceEvents)
let first = true // der erste Snapshot stellt alle Agenten an ihren Platz
let prevDeals = null // dealsMonth des vorigen Snapshots; null = noch keiner gesehen
let clock = 0 // Regie-Zeit in Sekunden (nur für das Wippen)
for (const role of roles) {
const home = homeOf(layout, role.workerId)
if (!home) continue
const a = {
id: role.workerId,
name: role.name,
hat: hatForWorker(role.workerId),
scale: role.workerId === 'otto' ? 2 : 1,
x: home.x,
y: home.y,
floor: home.floor,
facing: faceOf(role.workerId),
frame: 0,
pose: 'stand',
carrying: false,
stackN: 0,
say: null,
bubT: 0,
grey: false,
alert: false,
status: 'wartet',
task: '',
counters: {},
lane: 0,
bob: 0,
idleT: 0,
cheer: 0,
q: [], // wartende und laufende Gruppen { steps, dead }, vorne die laufende
cur: null, // laufender Schritt
stepT: 0, // so lange läuft der Schritt schon (Wachhund)
groups: 0,
box: null,
home,
walkT: 0, // Laufzeit für den Schrittwechsel der Beine
dy: home.y - layout.floors[home.floor].floorY, // Standhöhe relativ zum Boden (Otto steht höher)
ph: rnd() * 6, // Phase des Wippens: nicht alle im Gleichschritt
}
a.lane = laneTarget(a)
agents.push(a)
byId[a.id] = a
}
const entryOf = (a) => (Object.hasOwn(workers, a.id) ? workers[a.id] : null)
const standY = (a, floor) => layout.floors[floor].floorY + a.dy
const pending = () => agents.reduce((n, a) => n + a.groups, 0)
// Auf den Heimatplatz stellen (erster Snapshot, Abbruch, reset)
function placeHome(a) {
a.x = a.home.x
a.y = a.home.y
a.floor = a.home.floor
a.facing = faceOf(a.id)
a.frame = 0
a.carrying = false
a.stackN = 0
a.bob = 0
a.lane = laneTarget(a)
}
// grey- und busyRooms aus den Agenten neu bilden (die Mengen bleiben dieselben Objekte)
function syncRooms() {
look.greyRooms.clear()
look.busyRooms.clear()
for (const a of agents) {
if (a.grey) look.greyRooms.add(a.home.room)
else if (a.status === 'arbeitet') look.busyRooms.add(a.home.room)
}
}
// Nur den Status aus einem Eintrag der Tabelle (oder null = fehlt im Zustand) übernehmen
function adoptStatus(a, w) {
a.status = w ? w.status : 'grau'
a.grey = a.status === 'grau'
a.alert = a.status === 'fehler'
}
// Eintrag der Tabelle auf den Agenten übertragen; Aufgabe und Pose erst, wenn keine Gruppe läuft (sonst später per catchUp)
function adopt(a, w) {
adoptStatus(a, w)
a.counters = w ? w.counters : {}
if (busy(a)) {
lagging.add(a.id)
return
}
lagging.delete(a.id)
a.task = w ? w.task : ''
a.pose = poseFor(a)
}
function applyKpis(kpis) {
const pult = kpiOf(kpis, 'pultOpen')
const deals = kpiOf(kpis, 'dealsMonth')
const bot = kpiOf(kpis, 'botLeads')
if (pult !== null) look.pultOpen = pult
if (bot !== null) look.botLeads = bot
if (deals !== null) {
if (prevDeals !== null && deals > prevDeals) look.trophyPop = POP_S
look.dealsMonth = deals
prevDeals = deals
}
}
function applyState(state) {
workers = indexWorkers(state)
for (const a of agents) adopt(a, entryOf(a))
if (first) {
first = false
for (const a of agents) placeHome(a)
}
syncRooms()
applyKpis(isRecord(state) ? state.kpis : null)
look.paused = isRecord(state) && !!state.paused
}
// Sprechblase zeigen; höchstens BUBBLE_MAX Worker gleichzeitig (der Sprecher selbst zählt nicht mit), sonst stillschweigend verworfen
function bubble(a, text, dur = BUBBLE_S) {
const str = cutBubble(text)
if (!a || str === '' || !agents.includes(a)) return false
let others = 0
for (const o of agents) if (o.say && o !== a) others += 1
if (others >= BUBBLE_MAX) return false
a.say = str
a.bubT = dur
return true
}
// ---------- Schritte ----------
// Ort für Effekte: Worker (Kopfoberkante, Mitte) oder Platz aus layout.spots; unbekannt oder fehlend -> null
function placeOf(place) {
if (typeof place !== 'string') return null
if (Object.hasOwn(byId, place)) {
const a = byId[place]
return { x: a.x + 6 * a.scale, y: a.y + a.lane - 2 * a.scale }
}
return Object.hasOwn(layout.spots, place) ? layout.spots[place] : null
}
// Aus dem Türzähler austragen, aber nur, wenn der Agent noch mitgezählt wird (vor dem Einsteigen)
function uncount(a) {
if (!counted.has(a)) return
door[counted.get(a)] -= 1
counted.delete(a)
}
// Gruppe gilt als abgebrochen: alle weiteren Schritte entfallen, der Agent steht sofort wieder an seinem Platz
function abort(a, group) {
uncount(a)
group.dead = true
group.steps.length = 0
placeHome(a)
return true
}
const guarded = (group, fn) => (a, dt) => (group.dead ? true : fn(a, dt))
const instant = (fn) => (a) => {
fn(a)
return true
}
// Wartet sec Sekunden; onStart läuft einmal beim ersten Aufruf
function timed(sec, onStart) {
let t = 0
let begun = false
return (a, dt) => {
if (!begun) {
begun = true
if (onStart) onStart(a)
}
t += dt
return t >= sec
}
}
// Aufzug fahren von Etage fromF nach toF (der Agent steht auf fromF); Algorithmus der Vorlage rideStep, erweitert um Abbruch
function rideStep(group, fromF, toF) {
let ph = 0
let req = null
let sx = 0
return (a, dt) => {
if (ph === 0) {
sx = LIFT.slot0 + LIFT.slotStep * door[fromF]
door[fromF] += 1
counted.set(a, fromF)
ph = 1
}
if (ph === 1) {
if (!stepTowards(a, sx, standY(a, fromF), WALK_SPEED, dt)) return false
a.facing = -1
req = lift.request(a, fromF, toF) || { st: 'cancelled' }
ph = 2
}
if (req.st === 'cancelled') return abort(a, group)
if (ph === 2) {
if (req.st !== 'open') return false
uncount(a)
ph = 3
}
if (ph === 3) {
if (!stepTowards(a, LIFT.cabWx, standY(a, fromF), WALK_SPEED, dt)) return false
// der Fahrgast steht vor dem Einsteigen schon in der Kabine
a.facing = 1
a.frame = 0
a.x = LIFT.cabWx
a.y = lift.y + CAB_DY
req.st = 'boarded'
ph = 4
}
if (ph === 4) {
if (req.st !== 'arrived') return false
a.floor = toF
a.x = LIFT.cabWx
a.y = standY(a, toF)
ph = 5
}
if (!stepTowards(a, LIFT.exitX, standY(a, toF), WALK_SPEED, dt)) return false
req.st = 'out'
return true
}
}
// Neben den Empfänger laufen (links oder rechts, je nach Seite des Gebers), zu ihm hinblicken
function besideStep(r) {
let tx = null
let face = 1
return (a, dt) => {
if (tx === null) {
const fromLeft = a.x < r.home.x
tx = fromLeft ? r.home.x - BESIDE_DX : r.home.x + BESIDE_DX * r.scale
face = fromLeft ? 1 : -1
a.floor = r.home.floor
}
if (!stepTowards(a, tx, standY(a, r.home.floor), WALK_SPEED, dt)) return false
a.facing = face
return true
}
}
// Zurück zum Platz, dann Blickrichtung wie am Platz
function homeStep() {
return (a, dt) => {
if (!stepTowards(a, a.home.x, a.home.y, WALK_SPEED, dt)) return false
a.facing = faceOf(a.id)
a.carrying = false
a.stackN = 0
return true
}
}
// Übergabe: Dokument aufnehmen, (Aufzug,) zum Empfänger, Danke, (Aufzug,) heim. Ohne Empfänger, bei sich selbst oder bei grauem Empfänger entfällt sie.
function giveSteps(op, owner, group) {
const r = byId[op.to]
if (!r || r === owner || r.grey) return []
const fromF = owner.home.floor
const toF = r.home.floor
const steps = [
timed(PICKUP_S, (a) => {
a.pose = 'stand'
a.carrying = true
a.stackN = 1
}),
]
if (fromF !== toF) steps.push(rideStep(group, fromF, toF))
steps.push(
besideStep(r),
timed(THANKS_S, (a) => {
a.carrying = false
a.stackN = 0
if (!r.grey) bubble(r, 'Danke!')
}),
)
if (fromF !== toF) steps.push(rideStep(group, toF, fromF))
steps.push(homeStep())
return steps
}
// Effekt starten (ohne zu warten); fehlt Start oder Ziel im Layout, entfällt er
function fxStep(op) {
return instant(() => {
const from = placeOf(op.at)
const hasTo = Object.hasOwn(op, 'to')
const to = hasTo ? placeOf(op.to) : op.fx === 'drawer' ? layout.spots.archiv : null
if (!from || ((hasTo || op.fx === 'drawer') && !to)) return
spawnFx(fx, op.fx, {
from,
to: to || undefined,
delay: op.delay,
onEnd: op.fx === 'coin' ? () => { look.vaultFlash = VAULT_FLASH_S } : undefined,
})
})
}
// Sprechblase zeigen und kurz warten (nur, wenn sie wirklich erscheint)
function sayStep(op) {
let hold = -1
let t = 0
return (a, dt) => {
if (hold < 0) {
const who = byId[op.who]
hold = who && !who.grey && bubble(who, op.text) ? SAY_S : 0
}
t += dt
return t >= hold
}
}
// Schritte eines Ops; Ziel der Aufgabe, Pose, Blase und des Jubels ist op.who (nicht unbedingt der Besitzer der Gruppe)
function stepsOf(op, owner, group) {
const who = byId[op.who]
switch (op.op) {
case 'task':
return [instant(() => {
if (!who || who.grey) return
if (who !== owner && busy(who)) lagging.add(who.id)
else who.task = op.text
})]
case 'pose':
return [instant(() => {
if (!who || who.grey || (who !== owner && busy(who))) return
who.pose = op.pose
who.idleT = 0
})]
case 'cheer':
return [instant(() => {
if (who && !who.grey) who.cheer = CHEER_S
})]
case 'say':
return [sayStep(op)]
case 'fx':
return [fxStep(op)]
case 'give':
return giveSteps(op, owner, group)
default:
return []
}
}
// Gruppe an die Warteschlange des Besitzers hängen; zu Beginn weckt sie ihn (wer schläft, spricht und läuft nicht im Schlaf)
function queueGroup(owner, ops) {
const group = { steps: [], dead: false }
group.steps.push((a) => {
const pose = poseFor(a)
if (a.grey) group.dead = true
else a.pose = pose === 'sleep' ? 'stand' : pose
return true
})
for (const op of ops) group.steps.push(...stepsOf(op, owner, group).map((fn) => guarded(group, fn)))
owner.q.push(group)
owner.groups += 1
}
// Stille Gruppe: nur Aufgabe und Pose, sofort. Bei einem Worker mit laufender Gruppe holt die Tabelle es am Gruppenende nach.
function runQuiet(ops) {
for (const op of ops) {
if (op.op !== 'task' && op.op !== 'pose') continue
const who = byId[op.who]
if (!who || who.grey) continue
if (busy(who)) lagging.add(who.id)
else if (op.op === 'task') who.task = op.text
else {
who.pose = op.pose
who.idleT = 0
}
}
}
// Besitzer der Gruppe: Absender des ersten give, sonst die workerId (wenn es den Worker gibt), sonst Otto
function ownerOf(e, ops) {
const give = ops.find((s) => s.op === 'give')
for (const id of [give && give.from, e.workerId, 'otto']) {
if (typeof id === 'string' && Object.hasOwn(byId, id)) return byId[id]
}
return null
}
function pushEvents(events) {
if (first || !Array.isArray(events) || events.length === 0) return
workers = reduceEvents(workers, events)
for (const a of agents) adoptStatus(a, entryOf(a))
syncRooms()
const groups = []
for (const e of events) {
const ops = eventToSteps(e)
const owner = ops.length > 0 ? ownerOf(e, ops) : null
if (owner) groups.push({ owner, ops })
}
// Animiert werden die neuesten BACKLOG_KEEP Gruppen; nimmt ein Besitzer nicht alle auf, behält er die neuesten
const animated = new Set()
const claimed = new Map()
for (let i = groups.length - 1; i >= 0 && i >= groups.length - BACKLOG_KEEP; i--) {
const { owner } = groups[i]
const held = owner.groups + (claimed.get(owner) || 0)
if (owner.grey || held >= GROUPS_PER_AGENT) continue
claimed.set(owner, (claimed.get(owner) || 0) + 1)
animated.add(groups[i])
}
for (const g of groups) if (!animated.has(g)) runQuiet(g.ops)
for (const g of groups) if (animated.has(g)) queueGroup(g.owner, g.ops)
}
// ---------- Ablauf je Bild ----------
// Aufgabe und Pose aus der Tabelle nachholen (nach dem Ende der letzten Gruppe)
function catchUp(a) {
adopt(a, entryOf(a))
}
// Nächsten Schritt der laufenden Gruppe holen; ist sie zu Ende, zählt sie ab und die nächste beginnt
function nextStep(a) {
a.stepT = 0
a.cur = null
while (a.q.length > 0) {
const group = a.q[0]
if (group.steps.length > 0) {
a.cur = group.steps.shift()
return
}
a.q.shift()
a.groups = Math.max(0, a.groups - 1)
if (a.groups === 0 && lagging.has(a.id)) catchUp(a)
}
}
// Wachhund: der Schritt dauert zu lange, Aufzug freigeben, Türzähler zurücknehmen, Agent heim, Gruppe beenden
function watchdog(a) {
lift.cancel(a)
if (a.q.length > 0) abort(a, a.q[0])
else uncount(a)
nextStep(a)
}
function runSteps(a, dt) {
if (!a.cur) nextStep(a)
for (let n = 0; a.cur && n < MAX_STEPS_PER_FRAME; n++) {
if (a.cur(a, dt)) {
nextStep(a)
continue
}
a.stepT += dt
if (a.stepT > STEP_TIMEOUT_S) watchdog(a)
break
}
}
function stepAgent(a, dt) {
runSteps(a, dt)
a.idleT = a.status === 'leerlauf' ? a.idleT + dt : 0
if (!busy(a)) a.pose = poseFor(a)
a.lane += clamp(laneTarget(a) - a.lane, -dt * LANE_SPEED, dt * LANE_SPEED)
a.bob = 0
if (a.cheer > 0) {
a.cheer = Math.max(0, a.cheer - dt)
a.bob = Math.floor(clock * 8) % 2 ? -2 : 0
} else if (a.pose === 'type' && atHome(a)) {
a.bob = Math.floor(clock * 3 + a.ph) % 2 ? -1 : 0
}
if (a.bubT > 0) {
a.bubT -= dt
if (a.bubT <= 0) {
a.bubT = 0
a.say = null
}
}
}
// Ein Bild weiterschalten, ohne auf Pause oder ungültiges dt zu achten (update und flush)
function advance(dt) {
clock += dt
for (const a of agents) stepAgent(a, dt)
lift.update(dt)
fx.update(dt)
look.trophyPop = Math.max(0, look.trophyPop - dt)
look.vaultFlash = Math.max(0, look.vaultFlash - dt)
look.pageT += dt
if (look.pageT >= PAGE_S) {
look.pageT %= PAGE_S
look.trophyPage = (look.trophyPage + 1) % trophyPaging(look.dealsMonth, 0).pages
}
}
function update(dt) {
if (look.paused || !Number.isFinite(dt) || dt <= 0) return
advance(dt)
}
// Sofort ruhig: Warteschlangen leer, Aufzug und Effekte zurückgesetzt, alle Agenten heim, Blasen weg
function reset() {
for (const a of agents) {
a.q.length = 0
a.cur = null
a.stepT = 0
a.groups = 0
a.say = null
a.bubT = 0
a.cheer = 0
placeHome(a)
if (lagging.has(a.id)) catchUp(a)
a.pose = poseFor(a)
}
door.fill(0)
counted.clear()
lift.reset()
fx.clear()
}
// Alles schnell zu Ende spielen (höchstens 2000 Bilder à 0,25 s), danach zurücksetzen
function flush() {
for (let i = 0; i < 2000 && pending() > 0; i++) advance(0.25)
reset()
}
// Trefferflächen der zuletzt gezeichneten Figuren in Zeichenreihenfolge (die letzte liegt oben), für pickAgent
function boxes() {
return byDepth(agents).filter((a) => a.box).map((a) => ({ id: a.id, box: a.box }))
}
const debug = () => ({ door: door.slice(), groups: pending() })
return { agents, byId, applyState, pushEvents, update, flush, reset, pending, debug, boxes, bubble }
}
// ---------- Zeichnen ----------
// Eine Figur mit Stapel-Papieren und „!“; merkt die Trefferfläche in agent.box
function drawAgent(ctx, a, selected, t) {
const sc = a.scale
const x = Math.round(a.x)
const y = Math.round(a.y + a.lane + a.bob)
if (a.carrying && a.stackN > 1) {
const bx = x + (a.facing === -1 ? -2 : 8) * sc
const by = y + 6 * sc + (a.frame === 1 ? -1 : 0)
drawPaper(ctx, bx + 1, by - 1)
if (a.stackN > 4) drawPaper(ctx, bx + 2, by - 2)
}
a.box = drawWorker(ctx, x, y, { hat: a.hat, frame: a.frame, facing: a.facing, carrying: a.carrying, scale: sc, selected, pose: a.pose, grey: a.grey, t })
// Das „!“ steht über dem Hut, bei Auswahl über dem Pfeil (Anker: Mitte der Unterkante)
if (a.alert) drawAlertMark(ctx, x + 6 * sc, y - 2 * sc - (selected ? 6 * sc : 0))
}
// Blase eines Agenten: Otto an der Oberkante der Welt seitlich, alle anderen mit der Spitze auf der Kopfoberkante
// (Hut, wie „!“ und Effekte), bei Auswahl und Fehler darüber, damit sie Pfeil und „!“ nicht verdeckt
function drawSay(ctx, a, selected) {
const sc = a.scale
const y = a.y + a.lane + a.bob
if (a.id === 'otto') {
drawSideBubble(ctx, a.say, { leftX: a.x, rightX: a.x + 12 * sc, y: y + 3 })
} else if (a.x > -6 && a.x < 326) {
const lift = (selected ? 6 * sc : 0) + (a.alert ? ALERT_H + 1 : 0)
drawBubble(ctx, a.x + 6 * sc, y - 2 * sc - lift, a.say)
}
}
/* Zeichnet alle Figuren von hinten nach vorn (nach depthOf sortiert), danach die Sprechblasen.
Setzt agent.box (für boxes()), zeichnet Auswahlpfeil bei selectedId und „!“ bei Fehler. t = Zeit in Sekunden. */
export function drawAgents(ctx, director, selectedId, t) {
const order = byDepth(director.agents)
for (const a of order) drawAgent(ctx, a, a.id === selectedId, t)
for (const a of order) if (a.say) drawSay(ctx, a, a.id === selectedId)
}
-188
View File
@@ -1,188 +0,0 @@
// Effekt-System des Hustler-Hochhauses: kurzlebige Zeichnungen (Papier, Brief, Münze, Stempel, Funkeln, Warnzeichen),
// die in Weltkoordinaten von einem Punkt zum anderen laufen. Portiert aus entwuerfe/scene-turm.js (addFx/updateFx/drawFx/envelope).
// Zeit läuft nur über update(dt) der Szene, nie über setTimeout (pausierte Tabs laufen so nicht vor).
// Kein React, kein Appwrite, kein window/document; gezeichnet wird ausschließlich über die Engine-Helfer (px, drawPaper, ...).
import { px, PAL, drawPaper, drawAlertMark } from './sprites.js'
import { drawCoin, drawStamp } from './props.js'
export const FX_NAMES = Object.freeze(['paper', 'envelope', 'coin', 'stampOk', 'stampNo', 'sparkle', 'alert', 'drawer'])
export const MAX_FX = 80
// ---------- Liste der laufenden Effekte ----------
/* createFx() → { add(life, draw, onEnd?, delay = 0), update(dt), draw(ctx), clear(), size() }
add: Lebensdauer und Verzögerung in Sekunden; draw(ctx, progress 0..1, effect) mit effect = { t, life } (t = Sekunden seit dem Start).
onEnd läuft genau einmal beim Ablauf. Ist die Liste mit MAX_FX Effekten voll, wird der neue verworfen und sein onEnd sofort gerufen.
clear() verwirft alles ohne onEnd. size() zählt auch Effekte, die noch auf ihre Verzögerung warten. */
export function createFx() {
let list = []
return {
add(life, draw, onEnd, delay = 0) {
if (list.length >= MAX_FX) {
if (typeof onEnd === 'function') onEnd()
return
}
list.push({
t: Number.isFinite(delay) && delay > 0 ? -delay : 0, // negativ = wartet noch auf den Start
life: Number.isFinite(life) && life > 0 ? life : 0,
draw,
onEnd,
})
},
update(dt) {
if (!Number.isFinite(dt) || dt <= 0) return
const alive = []
const ended = []
for (const f of list) {
f.t += dt
if (f.t >= f.life) ended.push(f)
else alive.push(f)
}
// Erst die Liste ersetzen, dann die Rückrufe: Ein onEnd darf neue Effekte anlegen oder clear() rufen.
list = alive
let failure = null
for (const f of ended) {
if (typeof f.onEnd !== 'function') continue
try {
f.onEnd()
} catch (err) {
if (!failure) failure = { err } // die übrigen onEnd laufen trotzdem, der erste Fehler wird danach geworfen
}
}
if (failure) throw failure.err
},
draw(ctx) {
for (const f of list) {
if (f.t < 0 || typeof f.draw !== 'function') continue
f.draw(ctx, f.life > 0 ? Math.min(1, f.t / f.life) : 1, f)
}
},
clear() {
list = []
},
size() {
return list.length
},
}
}
// ---------- Zeichnungen ----------
// Brief 7 × 5 (x, y = linke obere Ecke)
const ENVELOPE_COLOR = '#7a5cd6'
function drawEnvelope(ctx, x, y) {
px(ctx, x, y, 7, 5, ENVELOPE_COLOR)
px(ctx, x + 1, y + 1, 5, 3, PAL.w)
px(ctx, x + 1, y + 1, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 2, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 3, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 4, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 5, y + 1, 1, 1, ENVELOPE_COLOR)
}
// Vier Goldpixel in den Ecken wachsen nach außen, ein heller Pixel steht oben (um (x, y) als Mitte)
function drawSparkle(ctx, x, y, p) {
const s = Math.round(p * 6)
px(ctx, x - s - 1, y - s - 2, 1, 1, PAL.gold)
px(ctx, x + s + 1, y - s - 2, 1, 1, PAL.gold)
px(ctx, x - s - 1, y + s + 2, 1, 1, PAL.gold)
px(ctx, x + s + 1, y + s + 2, 1, 1, PAL.gold)
px(ctx, x, y - s - 4, 1, 1, '#fff3c4')
}
// Punkt auf dem Bogen von a nach b, in ganzen Einheiten (Pixel-Optik); high = Höhe des Bogens über der Geraden
function arcPoint(a, b, p, high) {
return {
x: Math.round(a.x + (b.x - a.x) * p),
y: Math.round(a.y + (b.y - a.y) * p - Math.sin(p * Math.PI) * high),
}
}
// Ziel um len Einheiten vor b (auf der Strecke a nach b); liegt b näher als len, bleibt der Start
function shortenedEnd(a, b, len) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.hypot(dx, dy)
if (dist <= len) return a
return { x: b.x - (dx / dist) * len, y: b.y - (dy / dist) * len }
}
// Der Stempel hüpft dreimal über dem Punkt, die Sprünge werden flacher, am Ende liegt er auf
function stampDraw(a, ok) {
return (ctx, p) => {
const hop = Math.round(Math.abs(Math.sin(p * Math.PI * 3)) * 6 * (1 - p))
drawStamp(ctx, Math.round(a.x) - 3, Math.round(a.y) - 5 - hop, ok)
}
}
/* Je Effektname: Lebensdauer in Sekunden und eine Fabrik, die aus Start (a) und Ziel (b) die Zeichenfunktion baut.
Papier, Brief und Münze sind auf ihren Punkt zentriert (Mitte des Sprites). Stempel und Warnzeichen brauchen nur a und stehen mit
der Unterkante darauf (wie die Spitze der Sprechblase: die Regie gibt die Kopfoberkante des Workers), das Funkeln ist um a zentriert. */
const KINDS = {
paper: {
life: 0.9,
make: (a, b) => (ctx, p) => {
const q = arcPoint(a, b, p, 8)
drawPaper(ctx, q.x - 3, q.y - 3)
},
},
envelope: {
life: 3.4,
make: (a, b) => (ctx, p) => {
const q = arcPoint(a, b, p, 12)
drawEnvelope(ctx, q.x - 3, q.y - 2)
},
},
coin: {
life: 0.7,
make: (a, b) => (ctx, p, f) => {
const q = arcPoint(a, b, p, 14)
drawCoin(ctx, q.x - 2, q.y - 2, f.t)
},
},
stampOk: { life: 1.2, make: (a) => stampDraw(a, true) },
stampNo: { life: 1.2, make: (a) => stampDraw(a, false) },
sparkle: {
life: 1.0,
make: (a) => (ctx, p) => drawSparkle(ctx, Math.round(a.x), Math.round(a.y), p),
},
alert: {
life: 1.5,
// Das Zeichen blinkt (vier Wechsel je Sekunde) und beginnt sichtbar; a = Kopfoberkante des Workers
make: (a) => (ctx, p, f) => {
if (Math.floor(f.t * 4) % 2 === 0) drawAlertMark(ctx, Math.round(a.x), Math.round(a.y))
},
},
drawer: {
life: 0.9,
// Das Blatt verschwindet in der Lade: es endet 3 Einheiten vor dem Ziel
make: (a, b) => {
const end = shortenedEnd(a, b, 3)
return (ctx, p) => {
const q = arcPoint(a, end, p, 6)
drawPaper(ctx, q.x - 3, q.y - 3)
}
},
},
}
/* Startet den benannten Effekt in Weltkoordinaten. from/to = { x, y }; fehlt eines, gilt das andere, fehlen beide, (0, 0).
delay in Sekunden, onEnd genau einmal beim Ablauf. Unbekannter Name oder volle Liste: nichts wird gezeichnet,
onEnd läuft sofort (damit eine Regie, die darauf wartet, nicht hängen bleibt). */
export function spawnFx(fx, name, opts) {
const { from, to, delay = 0, onEnd } = opts || {}
const kind = FX_NAMES.includes(name) ? KINDS[name] : null
if (!kind) {
if (typeof onEnd === 'function') onEnd()
return
}
const a = from || to || { x: 0, y: 0 }
const b = to || a
fx.add(kind.life, kind.make(a, b), onEnd, delay)
}
-134
View File
@@ -1,134 +0,0 @@
// Hochhaus-Layout als Daten: Etagen, Räume und Heimatplätze der Figuren in der Welt 320 × 256 (Übersicht §3).
// Zeichnen (rooms, building) und Regie (director) lesen nur diese Tabelle; ein neuer Raum ist eine Zeile in
// ROOM_META plus eine Rolle. Kein React, kein Appwrite, kein window/document.
import { ROLES, FLOORS, FLOOR_TITLES, ROOM_TITLES } from '../../../lib/workersRoster.js'
export const WORLD = Object.freeze({ w: 320, h: 256 })
export const FLOOR_PITCH = 23 // Etage 22 hoch plus eine Bodenlinie
export const BRIDGE_H = 34 // die Kommandobrücke (Etage 0) ist höher
const TOP0 = 6 // Oberkante der Brücke
const FLOOR_H = 22
const ROOM_X0 = 42 // Räume stehen zwischen x 42 und 228 (Schacht und Etagenschilder liegen daneben)
const ROOM_W = 186
const HOME_DY = 14 // Figurenhöhe: Platz y = Boden - 14
const OTTO_DY = 27 // Otto steht höher (Kommandomütze, 24 breit)
const OTTO_W = 24
const WORKER_W = 12
const CAB_H = 21 // Kabinenhöhe des Aufzugs (lift.js importiert layout.js, nicht umgekehrt)
const meta = (kind, team) => Object.freeze({ kind, team })
// Raum -> Art der Zeichnung und Teamfarbe (Schlüssel aus TEAM in sprites.js)
export const ROOM_META = Object.freeze({
bruecke: meta('bruecke', 'freigabe'),
netz: meta('radar', 'radar'),
kleinanzeigen: meta('inbox', 'radar'),
portale: meta('feed', 'radar'),
foren: meta('board', 'radar'),
aerzte: meta('scout', 'aerzte'),
handwerk: meta('scout', 'handwerk'),
selbst: meta('scout', 'selbst'),
laden: meta('scout', 'laden'),
waage: meta('waage', 'bewertung'),
mail: meta('mail', 'freigabe'),
forum: meta('schreiber', 'freigabe'),
posteingang: meta('sortier', 'freigabe'),
abschluss: meta('vertrag', 'freigabe'),
tisch1: meta('tisch', 'werkstatt'),
tisch2: meta('tisch', 'werkstatt'),
tisch3: meta('tisch', 'werkstatt'),
pruefung: meta('pruefung', 'pruefung'),
pult: meta('pult', 'freigabe'),
tresor: meta('tresor', 'tresor'),
archiv: meta('archiv', 'archiv'),
})
// Räume ohne Worker: stehen in ihrer Etage hinter den Räumen der Rollen
const WORKERLESS = Object.freeze({ freigabe: ['pult'], tresor: ['tresor'] })
// Etage i: Brücke oben, danach im Raster 23 (Oberkante 41, 64, 87, ...); floorY ist die Bodenlinie
function floorBox(i) {
const h = i === 0 ? BRIDGE_H : FLOOR_H
const top = i === 0 ? TOP0 : TOP0 + BRIDGE_H + 1 + FLOOR_PITCH * (i - 1)
return { top, floorY: top + h, h }
}
// n Räume teilen die Breite gleichmäßig; der letzte nimmt den Rest (4 Räume: 46, 46, 46, 48)
function splitRooms(n) {
const w = Math.floor(ROOM_W / n)
return Array.from({ length: n }, (_, i) => ({ x: ROOM_X0 + i * w, w: i === n - 1 ? ROOM_W - w * (n - 1) : w }))
}
// Orte für Effekte: Briefkasten am Pult (Spitze), Tresor (Mitte der Tür), Schublade im Archiv (erster Aktenschrank, rechts neben
// Arne; rooms.js zeichnet die Schränke ab Raum + 100) und ein Punkt rechts außerhalb des Turms. Der Test in workersRooms.test.mjs
// prüft, dass die Zeichnung jeden dieser Orte trifft. Fehlt die Etage im Layout (nur bei eigenen Etagenlisten), ist der Ort null.
function buildSpots(floors) {
const at = (floorId, dx, dy) => {
const f = floors.find((fl) => fl.id === floorId)
return f ? { x: ROOM_X0 + dx, y: f.floorY + dy } : null
}
const tresor = at('tresor', 121, -11)
return {
pult: at('freigabe', 93, -15),
tresor,
aussen: tresor && { x: WORLD.w + 4, y: tresor.y - 1 },
archiv: at('keller', 106, -16),
}
}
// Layout aus Rollen und Etagenliste (Standard: das Rollen-Register). Wirft bei unbekanntem Raum oder unbekannter Etage.
export function buildLayout(roles = ROLES, floors = FLOORS) {
for (const r of roles) {
if (!Object.hasOwn(ROOM_META, r.room)) throw new Error(`Unbekannter Raum: ${r.room}`)
if (!floors.includes(r.floor)) throw new Error(`Unbekannte Etage: ${r.floor}`)
}
const homes = {}
const list = floors.map((id, index) => {
const box = floorBox(index)
const entries = roles.filter((r) => r.floor === id).map((r) => ({ id: r.room, workerId: r.workerId }))
for (const room of WORKERLESS[id] ?? []) entries.push({ id: room, workerId: null })
const slots = splitRooms(entries.length)
const rooms = entries.map((e, k) => {
const { kind, team } = ROOM_META[e.id]
return { id: e.id, kind, title: ROOM_TITLES[e.id], team, x: slots[k].x, w: slots[k].w, workerId: e.workerId }
})
for (const room of rooms) {
if (!room.workerId) continue
const otto = room.workerId === 'otto'
const x = room.x + Math.floor((room.w - (otto ? OTTO_W : WORKER_W)) / 2)
homes[room.workerId] = { x, y: box.floorY - (otto ? OTTO_DY : HOME_DY), floor: index, room: room.id }
}
return { id, index, title: FLOOR_TITLES[id] ?? id, top: box.top, floorY: box.floorY, h: box.h, rooms }
})
return { floors: list, homes, spots: buildSpots(list) }
}
// Etagenindex zur Kennung; unbekannt -> -1
export function floorIndex(layout, id) {
return layout.floors.findIndex((f) => f.id === id)
}
// Raum zur Kennung; unbekannt -> null
export function roomById(layout, roomId) {
for (const f of layout.floors) {
const room = f.rooms.find((r) => r.id === roomId)
if (room) return room
}
return null
}
// Heimatplatz des Workers; unbekannte Kennung (auch Prototyp-Namen) -> null
export function homeOf(layout, workerId) {
return typeof workerId === 'string' && Object.hasOwn(layout.homes, workerId) ? layout.homes[workerId] : null
}
// Standhöhe einer Figur auf Etage i (obere Kante des 14 hohen Körpers)
export function fy(layout, i) {
return layout.floors[i].floorY - HOME_DY
}
// Oberkante der Aufzugkabine, wenn sie auf Etage i hält
export function cabTop(layout, i) {
return layout.floors[i].floorY - CAB_H
}
-169
View File
@@ -1,169 +0,0 @@
// Aufzug des Hustler-Hochhauses: Warteschlange, Zustandsautomat (Anfahren, Einsteigen, Fahren, Aussteigen) und Kabine.
// Portiert aus entwuerfe/scene-turm.js (Kabine Z. 316-330, Zustandsautomat Z. 466-492). Neu: cancel/reset geben die Kabine
// frei (die Regie bricht Gruppen ab, ohne dass der Aufzug hängen bleibt), ungültige Anfragen kommen abgebrochen zurück.
// Die Etagenlampen zeichnet building.js über at(i). Kein React, kein Appwrite, kein window/document.
import { cabTop } from './layout.js'
import { px, tint, PAL } from './sprites.js'
// Maße in Weltkoordinaten: cabWx = linke Kante des Fahrgasts in der Kabine (12 breiter Körper mittig in der 20 breiten Kabine),
// exitX = Ziel des ersten Schritts nach dem Aussteigen, slot0/slotStep = Wartepunkte vor dem Schacht (wer zuerst kommt, steht links),
// speed = Einheiten je Sekunde, cabH = Kabinenhöhe (gleich der in layout.js)
export const LIFT = Object.freeze({ cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 })
const CAB_X = 18 // linke Kante der Kabine (20 breit)
const CAB_W = 20
const ROPE_X = 28
const ROPE_TOP = 9 // das Seil hängt unter der Seilrolle am Schachtkopf
const RIDER_DY = 7 // Fahrgast: obere Kante des Körpers 7 unter der Kabinenoberkante (= Standhöhe auf der Etage)
const CLOSE_S = 0.3 // Türen schließen, bevor die Fahrt beginnt
const NEAR = 1.5 // at(i): Kabine gilt als auf der Etage, wenn sie höchstens so weit davon entfernt ist
const START_FLOOR = 2
const FRAME = PAL.metal2
const CAB_BACK = '#f1f3f8'
const CAB_RAIL = '#cfd5df'
const CAB_BASE = '#e1e5ec'
const CAB_LAMP = '#ffe9a8'
/* createLift(layout) → { y, state, request(agent, from, to), update(dt), cancel(agent), reset(), at(floorIndex), drawBack(ctx), drawFront(ctx) }
y = Oberkante der Kabine, state = idle | toPick | boarding | closing | riding | unloading.
request legt eine Anfrage { a, from, to, st } hinten an (FIFO); st läuft queued -> open -> boarded -> arrived -> out, außerdem cancelled.
boarded und out setzt der Fahrgast (die Regie), alle anderen Übergänge der Aufzug. Während der Fahrt (closing, riding) folgt der Agent
der Kabine: a.y = y + 7, a.x = cabWx, a.frame = 0.
Eine Anfrage mit ungültiger Etage (kein ganzer Index im Layout) oder ohne Agenten-Objekt kommt sofort als cancelled zurück und wird nie bedient. */
export function createLift(layout) {
const floorCount = layout.floors.length
const isFloor = (i) => Number.isInteger(i) && i >= 0 && i < floorCount
const lift = { y: cabTop(layout, Math.min(START_FLOOR, floorCount - 1)), state: 'idle' }
let queue = [] // wartende Anfragen, vorne die nächste
let req = null // laufende Anfrage
let closeT = 0
// Kabine frei: keine laufende Anfrage, Leerlauf (die Kabine bleibt, wo sie ist)
function release() {
req = null
lift.state = 'idle'
closeT = 0
}
// Fährt um höchstens speed * dt auf ty zu; true = angekommen
function cabMove(ty, dt) {
const d = ty - lift.y
const s = LIFT.speed * dt
if (Math.abs(d) <= s) {
lift.y = ty
return true
}
lift.y += d > 0 ? s : -s
return false
}
// Der Fahrgast steht in der Kabine
function follow() {
if (!req) return
req.a.y = lift.y + RIDER_DY
req.a.x = LIFT.cabWx
req.a.frame = 0
}
lift.request = function request(a, from, to) {
const r = { a, from, to, st: 'queued' }
if (!a || typeof a !== 'object' || !isFloor(from) || !isFloor(to)) {
r.st = 'cancelled'
return r
}
queue.push(r)
return r
}
lift.update = function update(dt) {
if (!Number.isFinite(dt) || dt <= 0) return
// von außen abgebrochene laufende Anfrage: Kabine frei, kein Festhängen in boarding
if (req && req.st === 'cancelled') release()
if (lift.state === 'idle') {
while (queue.length && queue[0].st === 'cancelled') queue.shift()
if (queue.length) {
req = queue.shift()
lift.state = 'toPick'
}
}
if (lift.state === 'toPick') {
if (cabMove(cabTop(layout, req.from), dt)) {
req.st = 'open'
lift.state = 'boarding'
}
} else if (lift.state === 'boarding') {
if (req.st === 'boarded') {
lift.state = 'closing'
closeT = 0
}
} else if (lift.state === 'closing') {
closeT += dt
follow()
if (closeT > CLOSE_S) lift.state = 'riding'
} else if (lift.state === 'riding') {
// erst fahren, dann mitnehmen: der Fahrgast hinkt der Kabine nicht um einen Schritt hinterher (die Vorlage tut es)
const arrived = cabMove(cabTop(layout, req.to), dt)
follow()
if (arrived) {
req.st = 'arrived'
lift.state = 'unloading'
}
} else if (lift.state === 'unloading') {
if (req.st === 'out') release()
}
}
// Alle Anfragen des Agenten abbrechen; steckt er in der Kabine, ist sie danach frei (der Agent wird nicht mehr bewegt).
// Ein bereits ausgestiegener Fahrgast (out) bleibt out.
lift.cancel = function cancel(agent) {
const keep = []
for (const r of queue) {
if (r.a === agent) r.st = 'cancelled'
else keep.push(r)
}
queue = keep
if (req && req.a === agent) {
if (req.st !== 'out') req.st = 'cancelled'
release()
}
}
// Alle wartenden und die laufende Anfrage abbrechen (Schnappschuss, Neuaufbau der Szene); die Kabine bleibt stehen
lift.reset = function reset() {
for (const r of queue) r.st = 'cancelled'
queue = []
if (req && req.st !== 'out') req.st = 'cancelled'
release()
}
// Steht die Kabine auf Etage i? (Etagenlampe grün); ungültiger Index -> false
lift.at = function at(i) {
return isFloor(i) && Math.abs(lift.y - cabTop(layout, i)) < NEAR
}
// Seil und Rückwand: vor den Figuren zeichnen
lift.drawBack = function drawBack(ctx) {
const cy = Math.round(lift.y)
px(ctx, ROPE_X, ROPE_TOP, 1, cy - ROPE_TOP, FRAME)
px(ctx, CAB_X, cy, CAB_W, LIFT.cabH, CAB_BACK)
px(ctx, CAB_X + 1, cy + 11, CAB_W - 2, 1, CAB_RAIL)
px(ctx, CAB_X + 1, cy + 18, CAB_W - 2, 2, CAB_BASE)
}
// Glasschleier, Rahmen, Lampe und Aufhängung: nach den Figuren zeichnen
lift.drawFront = function drawFront(ctx) {
const cy = Math.round(lift.y)
tint(ctx, CAB_X + 1, cy + 1, CAB_W - 2, LIFT.cabH - 2, '#ffffff', 0.18)
px(ctx, CAB_X, cy, CAB_W, 1, FRAME)
px(ctx, CAB_X, cy + LIFT.cabH - 1, CAB_W, 1, FRAME)
px(ctx, CAB_X, cy, 1, LIFT.cabH, FRAME)
px(ctx, CAB_X + CAB_W - 1, cy, 1, LIFT.cabH, FRAME)
px(ctx, 24, cy + 1, 8, 1, CAB_LAMP)
px(ctx, 26, cy - 2, 4, 2, FRAME)
}
return lift
}
-255
View File
@@ -1,255 +0,0 @@
// Möbel, Requisiten und Branchen-Icons des Hustler-Hochhauses.
// Portiert aus entwuerfe/sprites.js (Möbel und Requisiten, ohne drawConveyor und drawZone). Neu: drawTray, drawSign,
// drawIcon samt ICONS und der Schalter `on` an drawMonitor.
// Alle Maße in logischen Einheiten; gezeichnet wird über px() aus sprites.js, es gibt keinen eigenen Zustand.
// Kein React, kein Appwrite, kein window/document.
import { px, label, PAL, TEAM, HAT } from './sprites.js'
// ---------- Möbel ----------
export function drawDesk(ctx, x, y, w, accent) {
px(ctx, x, y, w, 1, PAL.wood2)
px(ctx, x, y + 1, w, 4, PAL.wood)
px(ctx, x, y + 5, w, 1, PAL.wood2)
if (accent) px(ctx, x, y + 5, w, 1, accent)
px(ctx, x + 1, y + 6, 1, 5, PAL.wood2)
px(ctx, x + w - 2, y + 6, 1, 5, PAL.wood2)
px(ctx, x + 1, y + 11, w - 2, 1, PAL.shadow)
}
// on = false: nur Rahmen, Schirm und Fuß, keine Zeilen und kein Blinken
export function drawMonitor(ctx, x, y, t, color, on = true) {
const c = color || '#7fb2ff'
px(ctx, x, y, 10, 7, PAL.o)
px(ctx, x + 1, y + 1, 8, 5, PAL.screen)
if (on) {
const long = Math.floor((t || 0) * 2) % 2 === 0
px(ctx, x + 2, y + 2, 5, 1, c)
px(ctx, x + 2, y + 4, long ? 4 : 2, 1, c)
}
px(ctx, x + 4, y + 7, 2, 2, PAL.o)
px(ctx, x + 2, y + 9, 6, 1, PAL.o)
}
export function drawCabinet(ctx, x, y, drawers) {
const n = drawers || 3
px(ctx, x, y, 12, n * 6 + 1, PAL.metal2)
for (let i = 0; i < n; i++) {
px(ctx, x + 1, y + 1 + i * 6, 10, 5, PAL.metal)
px(ctx, x + 4, y + 3 + i * 6, 4, 1, PAL.metal2)
}
px(ctx, x, y + n * 6 + 1, 12, 1, PAL.shadow)
}
export function drawPlant(ctx, x, y) {
px(ctx, x + 2, y, 2, 2, '#5cbf8a')
px(ctx, x, y + 2, 2, 2, '#5cbf8a')
px(ctx, x + 4, y + 1, 2, 3, '#3a9466')
px(ctx, x + 2, y + 2, 2, 3, '#3a9466')
px(ctx, x + 1, y + 5, 4, 4, '#c9714f')
px(ctx, x + 1, y + 5, 4, 1, '#a85a3c')
}
// Ablagekorb / Eingangskorb, 10 × 4: voll = Papierstapel ragt über den Rand
export function drawTray(ctx, x, y, filled) {
px(ctx, x, y + 1, 10, 1, PAL.metal2) // Rand
px(ctx, x, y + 2, 10, 1, PAL.metal) // Vorderwand
px(ctx, x, y + 3, 10, 1, PAL.metal2) // Boden
if (filled) {
px(ctx, x + 1, y, 8, 1, PAL.g) // Oberkante des Stapels
px(ctx, x + 1, y + 1, 8, 1, PAL.w) // oberstes Blatt in der Öffnung
px(ctx, x + 3, y + 1, 4, 1, PAL.p) // eine Textzeile darauf
}
}
// ---------- Requisiten ----------
export function drawCoin(ctx, x, y, t) {
const f = Math.floor((t || 0) * 6) % 4
const w = [5, 3, 1, 3][f]
const ox = (5 - w) / 2
px(ctx, x + ox, y, w, 5, PAL.gold2)
if (w > 2) px(ctx, x + ox + 1, y + 1, w - 2, 3, PAL.gold)
}
export function drawTrophy(ctx, x, y) {
px(ctx, x, y, 7, 1, PAL.gold2)
px(ctx, x, y + 1, 7, 3, PAL.gold)
px(ctx, x + 1, y + 4, 5, 1, PAL.gold2)
px(ctx, x + 3, y + 5, 1, 2, PAL.gold2)
px(ctx, x + 1, y + 7, 5, 2, PAL.wood2)
px(ctx, x + 1, y + 1, 1, 2, '#fff3c4')
}
export function drawStamp(ctx, x, y, ok) {
const c = ok ? PAL.green : PAL.red
px(ctx, x, y, 7, 1, c)
px(ctx, x, y + 4, 7, 1, c)
px(ctx, x, y, 1, 5, c)
px(ctx, x + 6, y, 1, 5, c)
if (ok) {
// Haken
px(ctx, x + 2, y + 2, 1, 1, c)
px(ctx, x + 3, y + 3, 1, 1, c)
px(ctx, x + 4, y + 1, 1, 2, c)
} else {
// Kreuz
px(ctx, x + 2, y + 1, 1, 1, c)
px(ctx, x + 4, y + 1, 1, 1, c)
px(ctx, x + 3, y + 2, 1, 1, c)
px(ctx, x + 2, y + 3, 1, 1, c)
px(ctx, x + 4, y + 3, 1, 1, c)
}
}
export function drawRadar(ctx, x, y, t) {
px(ctx, x + 2, y, 10, 1, PAL.o)
px(ctx, x, y + 1, 14, 10, PAL.o)
px(ctx, x + 1, y + 2, 12, 8, '#e8f2fc')
px(ctx, x + 6, y + 5, 2, 2, TEAM.radar)
const a = (t || 0) * 2.2
for (let i = 1; i < 6; i++) px(ctx, x + 7 + Math.round(Math.cos(a) * i), y + 6 + Math.round(Math.sin(a) * i * 0.7), 1, 1, TEAM.radar)
const blink = Math.floor((t || 0) * 3) % 3
if (blink === 0) px(ctx, x + 3, y + 3, 1, 1, PAL.red)
if (blink === 1) px(ctx, x + 10, y + 7, 1, 1, PAL.red)
px(ctx, x + 6, y + 11, 2, 3, PAL.o)
px(ctx, x + 3, y + 14, 8, 1, PAL.o)
}
// Briefkasten des Pults; das rote Abzeichen mit „!“ steht nur bei count > 0
export function drawMailbox(ctx, x, y, count) {
px(ctx, x, y + 2, 12, 8, TEAM.freigabe)
px(ctx, x + 1, y, 10, 2, TEAM.freigabe)
px(ctx, x + 2, y + 4, 8, 2, HAT.overseer[1])
px(ctx, x + 5, y + 10, 2, 5, PAL.o)
if (count > 0) {
px(ctx, x + 9, y - 3, 5, 5, PAL.red)
px(ctx, x + 11, y - 2, 1, 3, PAL.w)
}
}
export function drawVault(ctx, x, y, t) {
px(ctx, x, y, 22, 20, PAL.metal2)
px(ctx, x + 1, y + 1, 20, 18, PAL.metal)
px(ctx, x + 7, y + 6, 8, 8, PAL.metal2)
px(ctx, x + 8, y + 7, 6, 6, PAL.metal)
const a = (t || 0) * 0.8
px(ctx, x + 11 + Math.round(Math.cos(a) * 2), y + 10 + Math.round(Math.sin(a) * 2), 1, 1, PAL.o)
px(ctx, x + 18, y + 9, 2, 3, PAL.metal2)
px(ctx, x, y + 20, 22, 1, PAL.shadow)
}
export function drawScale(ctx, x, y, tilt) {
const d = Math.round(tilt || 0)
px(ctx, x + 7, y + 2, 2, 12, PAL.o)
px(ctx, x + 3, y + 14, 10, 2, PAL.o)
px(ctx, x, y + 2 - d, 16, 1, PAL.o)
px(ctx, x - 1, y + 3 - d, 5, 2, TEAM.bewertung)
px(ctx, x + 12, y + 3 + d, 5, 2, TEAM.bewertung)
}
// Weißes Schild mit dunklem Rand, 8 hoch; (x, y) = linke obere Ecke, Text zentriert (z. B. „noch nicht angeschlossen“)
export function drawSign(ctx, x, y, w, text) {
px(ctx, x, y, w, 8, PAL.o)
px(ctx, x + 1, y + 1, w - 2, 6, PAL.w)
label(ctx, text, x + w / 2, y + 2.5, { size: 9, color: PAL.ink, align: 'center' })
}
// ---------- Branchen-Icons (7 × 7) ----------
// '.' leer, 'x' Linie in der Teamfarbe, 'a' Akzent in Gold. Schlüssel = die acht Such-Räume (SEARCH_ROOMS)
const icon = (...rows) => Object.freeze(rows)
export const ICONS = Object.freeze({
netz: icon( // Funkwellen
'.xxxxx.',
'x.....x',
'..xxx..',
'.x...x.',
'.......',
'...a...',
'...a...',
),
kleinanzeigen: icon( // Umschlag mit @-Punkt
'xxxxxxx',
'xx...xx',
'x.x.x.x',
'x..x..x',
'x.....x',
'x..a..x',
'xxxxxxx',
),
portale: icon( // Zeilenliste
'xxxxxxx',
'.......',
'a.xxxx.',
'.......',
'a.xxxx.',
'.......',
'a.xxxx.',
),
foren: icon( // Sprechblase
'.xxxxx.',
'x.....x',
'x.a.a.x',
'x.....x',
'.xxxxx.',
'..xx...',
'.x.....',
),
aerzte: icon( // Kreuz
'..xxx..',
'..xxx..',
'xxxxxxx',
'xxxxxxx',
'xxxxxxx',
'..xxx..',
'..xxx..',
),
handwerk: icon( // Hammer
'.xxxxx.',
'xxxxxxx',
'.xxxxx.',
'...a...',
'...a...',
'...a...',
'...a...',
),
selbst: icon( // Person
'..xxx..',
'.xxxxx.',
'..xxx..',
'.......',
'.xxxxx.',
'xxxxxxx',
'xxxxxxx',
),
laden: icon( // Markise
'.xxxxx.',
'xaxaxax',
'xaxaxax',
'x.x.x.x',
'.......',
'x.....x',
'xxxxxxx',
),
})
// Zeichnet das Icon `name` mit der linken oberen Ecke bei (x, y): 'x' in `color`, 'a' in Gold.
// Gleiche Zeichen einer Zeile werden zu einem Rechteck zusammengefasst; ein unbekannter Name zeichnet nichts.
export function drawIcon(ctx, name, x, y, color) {
const rows = typeof name === 'string' ? ICONS[name] : null
if (!Array.isArray(rows)) return
for (let r = 0; r < rows.length; r++) {
const row = rows[r]
let c = 0
while (c < row.length) {
const ch = row[c]
let end = c + 1
while (end < row.length && row[end] === ch) end++
if (ch === 'x') px(ctx, x + c, y + r, end - c, 1, color)
else if (ch === 'a') px(ctx, x + c, y + r, end - c, 1, PAL.gold)
c = end
}
}
}
-329
View File
@@ -1,329 +0,0 @@
// Räume des Hustler-Hochhauses: eine Zeichnung je Raumart (kind), Pokal-Seiten, graue Räume und die Anzeigewerte `look`.
// Portiert aus entwuerfe/scene-turm.js (drawBridge ... drawArchive, Z. 182-314) auf die Raumtabelle des Layouts: alle Maße
// relativ zu room.x und zum Boden der Etage, Schriften nur über label(). Rückwand und Boden zeichnet building.js davor.
// Kein React, kein Appwrite, kein window/document.
import { px, tint, label, drawPaper, PAL, TEAM } from './sprites.js'
import { drawDesk, drawMonitor, drawCabinet, drawPlant, drawTray, drawTrophy, drawStamp, drawRadar, drawMailbox, drawVault, drawScale, drawSign, drawIcon } from './props.js'
const TROPHIES_PER_PAGE = 8
const TINT_ALPHA = 0.10 // Teamfarbton über jedem Raum
const GREY_ALPHA = 0.45
const GREY_TINT = '#9aa3b2'
const DIVIDER = '#e1e5ec'
const SIGN_TEXT = 'noch nicht angeschlossen'
const SIGN_MAX_W = 60
const TITLE_HALO = 'rgba(255, 255, 255, 0.85)' // Rand der Raumtitel
const STEP_COLORS = [TEAM.radar, TEAM.bewertung, TEAM.werkstatt, TEAM.pruefung, TEAM.freigabe] // Stationen der Pipeline-Tafel
const FEED_ROWS = [8, 5, 9] // Breiten der durchlaufenden Zeilen im Feed-Schirm
const MAX_PAGE_DOTS = 19
// Anzeigewerte der Räume. Die Regie (director.js) schreibt sie, die Zeichnung liest sie nur.
// greyRooms: nicht angeschlossene Räume, busyRooms: Räume, deren Worker gerade arbeitet (dort laufen die Monitore).
export function createLook() {
return {
greyRooms: new Set(),
busyRooms: new Set(),
pultOpen: 0,
dealsMonth: 0,
botLeads: 0,
trophyPage: 0, // gezeigte Pokal-Seite
pageT: 0, // Zeit seit der letzten Blätterung (Sekunden)
trophyPop: 0, // Restzeit des Aufblinkens des neuesten Pokals
vaultFlash: 0, // Restzeit des Tresor-Blitzes
paused: false,
}
}
// Pokal-Seiten: perPage je Seite, die Seite wird auf 0..pages-1 geklemmt; ungültige Werte zählen als 0
export function trophyPaging(total, page, perPage = TROPHIES_PER_PAGE) {
const size = Number.isFinite(perPage) && perPage >= 1 ? Math.floor(perPage) : TROPHIES_PER_PAGE
const n = whole(total)
const pages = Math.max(1, Math.ceil(n / size))
const p = Number.isFinite(page) ? Math.min(pages - 1, Math.max(0, Math.floor(page))) : 0
const from = p * size
return { shown: Math.min(size, n - from), page: p, pages, from }
}
// Zähler als ganze Zahl >= 0
function whole(value) {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
// ---------- Hilfen für die Raumzeichnungen ----------
// r = { room, x, w, top, g, tp, busy, grey, color, look }: g = Boden der Etage, tp = Zeit mit Versatz je Raum (Monitore blinken nicht im Gleichschritt)
const deskAt = (ctx, r, dx, w) => drawDesk(ctx, r.x + dx, r.g - 11, w, r.color)
const monitorAt = (ctx, r, dx, color = r.color) => drawMonitor(ctx, r.x + dx, r.g - 21, r.tp, color, r.busy)
const hop = (r) => (r.busy && Math.floor(r.tp * 2) % 2 === 0 ? 3 : 0) // Stempel springt im Takt, solange gearbeitet wird
// Monitor mit Schreib-Animation: erste Zeile wächst, dann die zweite, dazu eine blinkende Schreibmarke
function drawTypingMonitor(ctx, r, dx) {
const x = r.x + dx
const y = r.g - 21
drawMonitor(ctx, x, y, r.tp, r.color, false)
if (!r.busy) return
const n = Math.floor(r.tp * 4) % 9
const first = Math.min(6, n + 1)
const second = Math.max(0, n - 5)
px(ctx, x + 2, y + 2, first, 1, r.color)
if (second > 0) px(ctx, x + 2, y + 4, second, 1, r.color)
if (n % 2 === 0) px(ctx, x + 2 + (second > 0 ? second : first), y + (second > 0 ? 4 : 2), 1, 1, PAL.w)
}
// Pappkarton 15 × 8 mit Etikett
function drawBox(ctx, x, y) {
px(ctx, x, y, 15, 8, PAL.wood)
px(ctx, x, y, 15, 1, PAL.wood2)
px(ctx, x + 5, y + 3, 5, 2, '#efe3d2')
}
// ---------- Raumarten ----------
export const ROOM_DRAWERS = Object.freeze({
// Kommandobrücke: Konsole mit 2 Monitoren, Pipeline-Tafel, Fenster in die Nacht, Pflanze
bruecke(ctx, r) {
const { x, top, g, tp } = r
px(ctx, x + 62, top + 3, 104, 22, PAL.metal2)
px(ctx, x + 63, top + 4, 102, 20, '#e8ebf9')
px(ctx, x + 63, top + 16, 102, 8, '#dfe3f5')
for (const [dx, dy, w, h] of [[66, 18, 6, 6], [74, 14, 5, 10], [82, 17, 8, 7], [128, 15, 6, 9], [138, 18, 9, 6], [152, 13, 5, 11]]) px(ctx, x + dx, top + dy, w, h, '#cdd3ee')
const stars = [[70, 6], [86, 9], [99, 5], [130, 7], [144, 5], [156, 10], [108, 8], [78, 16]]
stars.forEach(([dx, dy], i) => {
if ((Math.floor(tp * 1.3) + i) % 3 !== 0) px(ctx, x + dx, top + dy, 1, 1, '#b3bbec')
})
px(ctx, x + 150, top + 7, 5, 5, '#f6dc7a')
px(ctx, x + 152, top + 6, 4, 4, '#e8ebf9')
px(ctx, x + 113, top + 4, 2, 20, PAL.metal2)
px(ctx, x + 60, top + 25, 108, 2, '#aab2c0')
// Konsole
drawDesk(ctx, x + 6, g - 11, 30, r.color)
drawMonitor(ctx, x + 10, g - 21, tp, '#b7a4f0', r.busy)
drawMonitor(ctx, x + 24, g - 21, tp + 1, '#7fb2ff', r.busy)
// Pipeline-Tafel
px(ctx, x + 42, top + 5, 18, 14, PAL.metal2)
px(ctx, x + 43, top + 6, 16, 12, PAL.w)
px(ctx, x + 45, top + 10, 12, 1, PAL.metal)
STEP_COLORS.forEach((color, i) => px(ctx, x + 44 + 3 * i, top + 9, 2, 2, color))
px(ctx, x + 45, top + 13, 3, 3, '#ffe28a')
px(ctx, x + 50, top + 13, 3, 3, '#bde5c9')
px(ctx, x + 55, top + 13, 3, 3, '#f6b9b9')
drawPlant(ctx, x + 174, g - 9)
},
// Netz-Radar: Radar auf dem Boden, Tisch daneben
radar(ctx, r) {
deskAt(ctx, r, 2, 14)
drawRadar(ctx, r.x + 31, r.g - 15, r.busy ? r.tp : 0)
},
// Kleinanzeigen: Tisch mit Monitor, Eingangskorb auf dem Wandbrett (voll, solange gearbeitet wird)
inbox(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
px(ctx, r.x + 31, r.g - 11, 12, 1, PAL.wood2)
drawTray(ctx, r.x + 32, r.g - 15, r.busy)
},
// Portale: Tisch mit Monitor, Wandschirm mit durchlaufenden Zeilen
feed(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
const sx = r.x + 31
const sy = r.g - 18
px(ctx, sx, sy, 14, 10, PAL.o)
px(ctx, sx + 1, sy + 1, 12, 8, PAL.screen)
if (!r.busy) return
const shift = Math.floor(r.tp * 3) % FEED_ROWS.length
for (let i = 0; i < FEED_ROWS.length; i++) px(ctx, sx + 2, sy + 2 + i * 2, FEED_ROWS[(i + shift) % FEED_ROWS.length], 1, r.color)
},
// Foren: Tisch mit Monitor, Tafel mit zwei Sprechblasen
board(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
const bx = r.x + 31
const by = r.g - 19
px(ctx, bx, by, 14, 11, PAL.wood2)
px(ctx, bx + 1, by + 1, 12, 9, '#eadfcf')
px(ctx, bx + 2, by + 2, 6, 3, PAL.w)
px(ctx, bx + 3, by + 5, 1, 1, PAL.w)
px(ctx, bx + 6, by + 6, 6, 3, r.color)
px(ctx, bx + 10, by + 9, 1, 1, r.color)
},
// Branchen-Scout: Tisch in der Teamfarbe, Monitor, Branchen-Icon an der Wand (Icon = Raumkennung)
scout(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
drawIcon(ctx, r.room.id, r.x + r.w - 10, r.top + 8, r.color)
},
// Waage: Waage links der Figur (schwankt beim Bewerten), Tisch mit Papieren rechts
waage(ctx, r) {
drawScale(ctx, r.x + 62, r.g - 16, r.busy ? Math.round(Math.sin(r.tp * 6) * 2.4) : 0)
deskAt(ctx, r, 104, 22)
drawPaper(ctx, r.x + 109, r.g - 18)
drawPaper(ctx, r.x + 117, r.g - 18)
},
// Mail-Stube: Tisch, Monitor, Briefstapel auf einem Beistelltisch
mail(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
px(ctx, r.x + 31, r.g - 7, 12, 1, PAL.wood2)
px(ctx, r.x + 32, r.g - 6, 1, 6, PAL.wood2)
px(ctx, r.x + 41, r.g - 6, 1, 6, PAL.wood2)
for (let i = 0; i < 3; i++) {
px(ctx, r.x + 32, r.g - 9 - i * 2, 10, 2, PAL.w)
px(ctx, r.x + 32, r.g - 9 - i * 2, 10, 1, PAL.g)
}
},
// Forum-Stube: Tisch, Monitor mit Schreib-Animation
schreiber(ctx, r) {
deskAt(ctx, r, 2, 14)
drawTypingMonitor(ctx, r, 4)
},
// Posteingang: Tisch, Regal mit drei Ablagekörben (Eingang voll, Mitte beim Sortieren, Ausgang leer)
sortier(ctx, r) {
deskAt(ctx, r, 2, 14)
const rx = r.x + 31
px(ctx, rx, r.g - 20, 1, 18, PAL.wood2)
px(ctx, rx + 11, r.g - 20, 1, 18, PAL.wood2)
const filled = [true, r.busy, false]
filled.forEach((full, i) => drawTray(ctx, rx + 1, r.g - 19 + i * 6, full))
},
// Abschluss: Tisch, Vertrag, Stempel (springt beim Arbeiten)
vertrag(ctx, r) {
deskAt(ctx, r, 2, 14)
drawPaper(ctx, r.x + 3, r.g - 18)
drawStamp(ctx, r.x + 10, r.g - 16 - hop(r), true)
},
// Werkbank: Tisch, Monitor
tisch(ctx, r) {
deskAt(ctx, r, 6, 18)
monitorAt(ctx, r, 10)
},
// Prüftisch: Tisch mit zwei Stempeln, Handy auf einem Sockel (zeigt beim Prüfen wechselnde Bilder)
pruefung(ctx, r) {
deskAt(ctx, r, 60, 22)
drawStamp(ctx, r.x + 66, r.g - 16, false)
drawStamp(ctx, r.x + 73, r.g - 16 - hop(r), true)
const sx = r.x + 108
px(ctx, sx, r.g - 5, 10, 5, PAL.wood)
px(ctx, sx, r.g - 5, 10, 1, PAL.wood2)
px(ctx, sx + 2, r.g - 15, 6, 10, PAL.o)
const shot = Math.floor(r.tp * 2) % 2 === 0 ? '#cfe3ff' : '#bde5c9'
px(ctx, sx + 3, r.g - 14, 4, 7, r.busy ? shot : PAL.screen)
px(ctx, sx + 4, r.g - 7, 2, 1, PAL.mute)
},
// Dein Pult: der Briefkasten in der Mitte; Zahl der offenen Einträge daneben (keine bei 0)
pult(ctx, r) {
const open = whole(r.look.pultOpen)
const cx = r.x + r.w / 2
drawMailbox(ctx, cx - 6, r.g - 15, open)
if (open > 0) label(ctx, open > 99 ? '99+' : String(open), cx + 15, r.g - 19, { size: 11, color: PAL.w, weight: 700, align: 'center', bg: PAL.red })
},
// Tresor: Pokalbrett (eine Seite zu 8), Tresortür, Zähler der Deals dieses Monats
tresor(ctx, r) {
const { look } = r
const total = whole(look.dealsMonth)
const pg = trophyPaging(total, look.trophyPage)
const shelf = r.g - 9
px(ctx, r.x + 24, shelf, 78, 2, PAL.wood2)
px(ctx, r.x + 26, shelf + 2, 2, 3, PAL.wood2)
px(ctx, r.x + 98, shelf + 2, 2, 3, PAL.wood2)
for (let i = 0; i < pg.shown; i++) {
const newest = pg.from + i === total - 1
if (newest && look.trophyPop > 0 && Math.floor(look.trophyPop * 10) % 2) continue
drawTrophy(ctx, r.x + 26 + 9 * i, shelf - 9)
}
// Seitenpunkte, wenn mehr als eine Seite da ist
for (let p = 0; p < Math.min(pg.pages, MAX_PAGE_DOTS); p++) px(ctx, r.x + 24 + p * 4, shelf + 6, 2, 2, p === pg.page ? TEAM.tresor : PAL.metal)
drawVault(ctx, r.x + 110, r.g - 21, r.tp)
if (look.vaultFlash > 0) tint(ctx, r.x + 110, r.g - 21, 22, 20, PAL.gold, 0.3 * Math.min(1, look.vaultFlash * 2))
label(ctx, `${total} Deals`, r.x + 138, r.g - 15, { size: 11, color: PAL.ink, weight: 600 })
},
// Archiv & Register: Kartons links, vier Aktenschränke rechts der Figur (beim Arbeiten springt je eine Schublade auf), Zähler der Bot-Leads.
// Der erste Schrank (x + 100 ... + 112) trägt den Effektort layout.spots.archiv (Mitte, oberste Schublade): dort verschwindet das Blatt.
archiv(ctx, r) {
drawBox(ctx, r.x + 14, r.g - 9)
drawBox(ctx, r.x + 31, r.g - 9)
drawBox(ctx, r.x + 22, r.g - 17)
const open = Math.floor(r.tp * 1.5) % 4
for (let i = 0; i < 4; i++) {
const x = r.x + 100 + 14 * i
const y = r.g - 20
drawCabinet(ctx, x, y, 3)
if (r.busy && i === open) {
px(ctx, x + 1, y + 7, 10, 5, '#fafbfd')
px(ctx, x + 2, y + 9, 8, 2, PAL.p)
px(ctx, x + 3, y + 6, 6, 1, PAL.w)
}
}
label(ctx, `${whole(r.look.botLeads)} Bot-Leads`, r.x + r.w - 2, r.g - 15, { size: 11, color: PAL.ink, weight: 600, align: 'right' })
},
})
// ---------- Zeichnen ----------
const isGrey = (room, look) => room.workerId != null && look.greyRooms.has(room.id)
function drawRoomSign(ctx, room, floor) {
const w = Math.min(room.w - 2, SIGN_MAX_W)
drawSign(ctx, room.x + (room.w - w) / 2, floor.top + 1, w, SIGN_TEXT)
}
function drawRoom(ctx, room, floor, index, t, look, signs) {
// Räume ohne Worker (pult, tresor) werden nie grau
const grey = isGrey(room, look)
const r = {
room,
x: room.x,
w: room.w,
top: floor.top,
g: floor.floorY,
tp: t + room.x * 0.05,
busy: !grey && look.busyRooms.has(room.id),
grey,
color: TEAM[room.team] ?? PAL.metal2,
look,
}
// Teamfarbton hat jeder Raum, auch ein grauer (die Grautönung kommt darüber)
tint(ctx, room.x, floor.top, room.w, floor.h, r.color, TINT_ALPHA)
if (index > 0) px(ctx, room.x, floor.top, 1, floor.h, DIVIDER)
if (Object.hasOwn(ROOM_DRAWERS, room.kind)) ROOM_DRAWERS[room.kind](ctx, r)
// Der Titel steht in der obersten Zeile des Raums, über den Möbeln (ein Monitor deckt sonst den Anfang zu) und mit hellem
// Rand, damit er auch über einem dunklen Bildschirm lesbar bleibt. Im grauen Raum nimmt das Schild (44 breit, 8 hoch) diese
// Zeile ein: über dem Hut der schlafenden Figur sind nur 6 Zeilen frei, Titel (3,3) und Schild (8) passen in den 46 breiten
// Such-Räumen, den einzigen, die grau sein können, nicht übereinander. Name und Branche zeigen dort Möbel und Icon.
if (!grey) label(ctx, room.title, room.x + 2, floor.top + 1, { size: 10, color: PAL.mute, weight: 500, halo: TITLE_HALO })
if (grey) {
tint(ctx, room.x, floor.top, room.w, floor.h, GREY_TINT, GREY_ALPHA)
if (signs) drawRoomSign(ctx, room, floor)
}
}
// Alle Räume aller Etagen. Vorher: drawOutside, drawTower. Nachher: Aufzug hinten, Figuren, Aufzug vorn.
// signs = false lässt die Schilder der grauen Räume aus: die Szene zeichnet sie mit drawSigns erst nach den Figuren,
// sonst deckt der Hut der schlafenden Figur die untere Zeile des Schildtexts zu.
export function drawRooms(ctx, layout, t, look, { signs = true } = {}) {
for (const floor of layout.floors) floor.rooms.forEach((room, index) => drawRoom(ctx, room, floor, index, t, look, signs))
}
// Die Schilder „noch nicht angeschlossen“ der grauen Räume (Gegenstück zu drawRooms mit signs: false)
export function drawSigns(ctx, layout, look) {
for (const floor of layout.floors) {
for (const room of floor.rooms) {
if (isGrey(room, look)) drawRoomSign(ctx, room, floor)
}
}
}
-395
View File
@@ -1,395 +0,0 @@
// Szene des Hustler-Hochhauses: mountTower hängt die Engine an einen Canvas.
// Größe (Breite · dpr, Höhe 4/5), Schleife (höchstens 30 Bilder je Sekunde, dt gekappt), Eingabe (Klick, Ziehen, Strg+Rad),
// Kamera und Lebenszyklus (destroy, StrictMode: mount, destroy, mount). Portiert aus entwuerfe/scene-turm.js (Schleife, Eingabe).
// Pause bei verstecktem Tab und der Schalter „weniger Bewegung“ kommen von außen: setPaused(bool) und die Option reducedMotion
// (die Bühne in React verdrahtet visibilitychange und prefers-reduced-motion). Kein React, kein Appwrite, kein window/document:
// raf, caf und now sind Optionen und fallen erst beim Aufruf von mountTower auf die globalen Funktionen zurück.
import { PAL, setGrid } from './sprites.js'
import { WORLD, buildLayout } from './layout.js'
import { createCamera, fitZoom, viewToWorld, worldToView, zoomAt, panBy, centerOn, pickAgent } from './camera.js'
import { createLift } from './lift.js'
import { createFx } from './fx.js'
import { createLook, drawRooms, drawSigns } from './rooms.js'
import { drawOutside, drawTower, drawPauseVeil } from './building.js'
import { createDirector, drawAgents } from './director.js'
export const FRAME_MS = 33 // zwischen zwei Bildern mindestens so viele Millisekunden (höchstens 30 Bilder je Sekunde)
export const DT_MAX = 0.05 // so viel Zeit rückt die Regie je Bild höchstens vor (nach einem Stillstand springt nichts)
export const CANVAS_MAX_W = 2400 // Breite der Zeichenfläche in Geräte-Pixeln, höchstens
export const ZOOM_STEP = 1.4 // Faktor eines Zoom-Schritts (Knopf „+“, ein Rastenschritt des Rads)
export const DRAG_PX = 4 // so weit darf der Zeiger wandern, ohne dass es ein Ziehen wird (CSS-Pixel)
const MIN_CSS_W = 10 // schmaler gilt die Bühne als nicht sichtbar, dann wird nicht gemessen
const TOWER_MID_X = 122 // Mitte des Turms (x 14..230): der Startausschnitt auf schmalen Bühnen zeigt den ganzen Turm
const PAD_FINE = 4 // Treffer-Toleranz um eine Figur in CSS-Pixeln: Maus
const PAD_COARSE = 10 // … Finger
const WHEEL_UNIT = 100 // deltaY, das beim Strg+Rad einem ZOOM_STEP entspricht
const WHEEL_LINE_PX = 33 // eine Zeile (deltaMode 1) zählt so viele Pixel
const WHEEL_STEPS_MAX = 2 // ein Rad-Ereignis zoomt höchstens um so viele Schritte
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
const bindGlobal = (name) => (typeof globalThis[name] === 'function' ? globalThis[name].bind(globalThis) : null)
// Handle für einen Canvas ohne 2D-Kontext: alles ist ein Nichts, damit die Seite nicht abstürzt
function inertHandle() {
const nop = () => {}
return {
destroy: nop, setState: nop, pushEvents: nop, select: nop, setPaused: nop, flush: nop, resize: nop, zoomBy: nop, resetView: nop,
locate: () => null,
stats: () => ({ frames: 0, avgDrawMs: 0, zoom: 1 }),
debug: () => ({ pending: 0, agents: [], lift: 'idle', fx: 0 }),
}
}
/* mountTower(canvas, { onSelect, reducedMotion = false, coarsePointer = false, fontsReady, dpr = 1, raf, caf, now })
→ { destroy(), setState(state), pushEvents(events), select(workerId|null), setPaused(bool), flush(), resize(), zoomBy(faktor),
resetView(), locate(workerId): { x, y } | null, stats(): { frames, avgDrawMs, zoom }, debug() }
Rasterweite je Bild: k = Canvas-Breite / (320 / zoom). Reihenfolge je Bild: Weiß, Außenwelt, Turm, Räume, Aufzug hinten, Figuren,
Schilder der grauen Räume, Aufzug vorn, Effekte, Pausenschleier. onSelect(id | null) kommt nur bei Klicks des Nutzers, nie bei select().
reducedMotion (und fehlendes raf): keine Schleife; setState, pushEvents, select, resize und die Kamera zeichnen je ein Standbild,
pushEvents spielt die Regie sofort zu Ende. fontsReady (Promise): nach dem Laden der Schrift wird einmal neu gezeichnet.
flush() beruhigt die Regie auf einen Schlag (alles zu Ende gespielt, alle Figuren heim, Aufzug und Effekte leer): die Bühne ruft es
beim Zurückkehren in den Tab, damit nichts aus der Zeit davor nachläuft.
locate liefert die Mitte der Trefferfläche in CSS-Pixeln relativ zur Leinwand (null: unbekannt oder außerhalb der Ansicht).
Nach destroy() ist jeder Aufruf wirkungslos. */
export function mountTower(canvas, options = {}) {
const opts = options || {}
const ctx = canvas && typeof canvas.getContext === 'function' ? canvas.getContext('2d') : null
if (!ctx) return inertHandle()
const { onSelect, coarsePointer = false, fontsReady } = opts
const dpr = Number.isFinite(opts.dpr) && opts.dpr > 0 ? opts.dpr : 1
const raf = opts.raf ?? bindGlobal('requestAnimationFrame')
const caf = opts.caf ?? bindGlobal('cancelAnimationFrame')
const now = opts.now ?? (typeof globalThis.performance?.now === 'function' ? () => globalThis.performance.now() : () => Date.now())
const still = !!opts.reducedMotion || typeof raf !== 'function'
const layout = buildLayout()
const look = createLook()
const lift = createLift(layout)
const fx = createFx()
const director = createDirector({ layout, lift, fx, look })
const cam = createCamera()
let destroyed = false
let paused = false
let selectedId = null // Markierung auf einer Figur
let clickedId = null // von einem Klick gesetzt: das folgende select() derselben Kennung verschiebt die Ansicht nicht
let touched = false // der Nutzer hat die Kamera bewegt: dann zieht resize() den Ausschnitt nicht mehr nach
let sized = false // die Zeichenfläche hat ihre Größe von der Bühne: vorher (Bühne unsichtbar, Breite unter 10) wird nichts gezeichnet
let time = 0 // Szenenzeit in Sekunden (steht bei Pause und im Standbild)
let lastT = 0 // Zeitpunkt des letzten Schleifenbildes
let frames = 0
let drawMs = 0
let loopToken = 0 // gilt nur der Rückruf mit der neuesten Nummer (auch ohne caf läuft nie eine zweite Schleife)
let rafHandle = null
let drag = null // { sx, sy, lx, ly, moved } zwischen pointerdown und pointerup
let suppressClick = false // ein Ziehen hat stattgefunden: der nächste click gilt nicht als Auswahl
const looping = () => !still && !paused && !destroyed
// ---------- Zeichnen ----------
function render() {
const k = canvas.width / (WORLD.w / cam.zoom)
setGrid(ctx, { k, ox: -cam.x * k, oy: -cam.y * k })
ctx.fillStyle = PAL.w
ctx.fillRect(0, 0, canvas.width, canvas.height)
drawOutside(ctx)
drawTower(ctx, layout, time, lift)
drawRooms(ctx, layout, time, look, { signs: false })
lift.drawBack(ctx)
drawAgents(ctx, director, selectedId, time)
drawSigns(ctx, layout, look)
lift.drawFront(ctx)
fx.draw(ctx)
drawPauseVeil(ctx, look)
}
// Ein Bild zeichnen und messen (Schleife, Standbild, sofortige Bilder nach setState und pushEvents)
function draw() {
if (destroyed || !sized) return
const t0 = now()
render()
frames += 1
drawMs += Math.max(0, now() - t0)
}
// Nach einer Änderung von Kamera oder Markierung: die laufende Schleife zeichnet von selbst, sonst kommt ein Standbild
function refresh() {
if (!looping()) draw()
}
// Im Standbild gibt es keine Zeit: kurze Aufleuchten (neuer Pokal, Tresor-Blitz) blieben sonst für immer stehen
function calm() {
look.trophyPop = 0
look.vaultFlash = 0
}
// ---------- Schleife ----------
function schedule() {
if (!looping()) return
loopToken += 1
const token = loopToken
rafHandle = raf(() => frame(token))
}
function stopLoop() {
loopToken += 1
if (rafHandle !== null && typeof caf === 'function') caf(rafHandle)
rafHandle = null
}
// Erst das nächste Bild bestellen, dann arbeiten: ein Fehler im Bild lässt die Schleife nicht sterben
function frame(token) {
if (token !== loopToken || !looping()) return
rafHandle = null
schedule()
const t = now()
if (t - lastT < FRAME_MS) return
const dt = Math.min(DT_MAX, Math.max(0, (t - lastT) / 1000))
lastT = t
time += dt
director.update(dt)
draw()
}
// ---------- Größe und Kamera ----------
function syncTouchAction() {
if (canvas.style) canvas.style.touchAction = cam.zoom > 1 ? 'none' : 'pan-y'
}
function resize() {
if (destroyed) return
const w = canvas.getBoundingClientRect().width
if (!(w >= MIN_CSS_W)) return
const pxW = Math.min(CANVAS_MAX_W, Math.round(w * dpr))
const pxH = Math.round((pxW * WORLD.h) / WORLD.w)
if (canvas.width !== pxW) canvas.width = pxW
if (canvas.height !== pxH) canvas.height = pxH
sized = true
if (!touched) {
cam.zoom = fitZoom(w)
cam.x = 0
cam.y = 0
centerOn(cam, TOWER_MID_X, 0)
}
syncTouchAction()
draw()
}
function moved() {
touched = true
syncTouchAction()
refresh()
}
// Ort eines Zeigerereignisses als Bruchteile der Ansicht (null bei unbrauchbaren Werten)
function viewPoint(e) {
const r = canvas.getBoundingClientRect()
const vx = (e.clientX - r.left) / r.width
const vy = (e.clientY - r.top) / r.height
return Number.isFinite(vx) && Number.isFinite(vy) ? { r, vx, vy } : null
}
// ---------- Eingabe ----------
function onClick(e) {
if (destroyed) return
if (suppressClick) {
suppressClick = false
return
}
const p = viewPoint(e)
if (!p) return
const w = viewToWorld(cam, p.vx, p.vy)
const unitPx = p.r.width / (WORLD.w / cam.zoom) // CSS-Pixel je Welt-Einheit
const id = pickAgent(director.boxes(), w.x, w.y, (coarsePointer ? PAD_COARSE : PAD_FINE) / unitPx)
selectedId = id
clickedId = id
refresh()
if (typeof onSelect === 'function') onSelect(id)
}
function onPointerDown(e) {
if (destroyed || e.button > 0) return
suppressClick = false
drag = { sx: e.clientX, sy: e.clientY, lx: e.clientX, ly: e.clientY, moved: false }
try {
if (typeof canvas.setPointerCapture === 'function') canvas.setPointerCapture(e.pointerId)
} catch {
// ungültige Zeiger-Kennung: ohne Erfassung geht es auch
}
}
function onPointerMove(e) {
if (destroyed || !drag) return
if (!drag.moved) {
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) <= DRAG_PX) return
drag.moved = true
suppressClick = true
}
if (cam.zoom > 1) {
const r = canvas.getBoundingClientRect()
const dx = (e.clientX - drag.lx) / r.width
const dy = (e.clientY - drag.ly) / r.height
if (Number.isFinite(dx) && Number.isFinite(dy)) {
panBy(cam, -dx, -dy)
moved()
}
}
drag.lx = e.clientX
drag.ly = e.clientY
}
function onPointerEnd(e) {
if (!drag) return
drag = null
try {
if (typeof canvas.releasePointerCapture === 'function') canvas.releasePointerCapture(e.pointerId)
} catch {
// schon freigegeben
}
}
// Nur mit Strg (Pinch auf dem Trackpad meldet Strg+Rad); ohne Strg scrollt die Seite wie gewohnt
function onWheel(e) {
if (destroyed || !e.ctrlKey) return
e.preventDefault()
const p = viewPoint(e)
const dy = (e.deltaMode === 1 ? WHEEL_LINE_PX : 1) * e.deltaY
const steps = clamp(-dy / WHEEL_UNIT, -WHEEL_STEPS_MAX, WHEEL_STEPS_MAX)
zoomAt(cam, Math.pow(ZOOM_STEP, steps), p ? p.vx : 0.5, p ? p.vy : 0.5)
moved()
}
const listeners = [
['click', onClick],
['pointerdown', onPointerDown],
['pointermove', onPointerMove],
['pointerup', onPointerEnd],
['pointercancel', onPointerEnd],
['wheel', onWheel, { passive: false }],
]
for (const [type, fn, listenerOptions] of listeners) canvas.addEventListener(type, fn, listenerOptions)
// ---------- Handle ----------
const handle = {
destroy() {
if (destroyed) return
destroyed = true
stopLoop()
for (const [type, fn] of listeners) canvas.removeEventListener(type, fn)
drag = null
},
setState(state) {
if (destroyed) return
director.applyState(state)
if (still) calm()
draw()
},
pushEvents(events) {
if (destroyed) return
director.pushEvents(events)
if (still) {
director.flush()
calm()
}
draw()
},
// Markierung setzen (Auswahl von außen, ohne onSelect); bei Zoom > 1 in die Mitte holen, außer der Klick selbst hat sie gesetzt
select(workerId) {
if (destroyed) return
const a = typeof workerId === 'string' && Object.hasOwn(director.byId, workerId) ? director.byId[workerId] : null
selectedId = a ? a.id : null
const byClick = a !== null && clickedId === a.id
clickedId = null
if (a && cam.zoom > 1 && !byClick) {
// in die Mitte der Trefferfläche (Figur samt Hut); vor dem ersten Bild gibt es sie noch nicht
const b = a.box
centerOn(cam, b ? b.x + b.w / 2 : a.x + 6 * a.scale, b ? b.y + b.h / 2 : a.y + 7 * a.scale)
moved()
} else {
refresh()
}
},
flush() {
if (destroyed) return
director.flush()
if (still) calm()
draw()
},
setPaused(value) {
if (destroyed) return
const next = !!value
if (next === paused) return
paused = next
if (paused) {
stopLoop()
} else {
lastT = now()
schedule()
}
},
resize,
zoomBy(factor) {
if (destroyed) return
zoomAt(cam, factor, 0.5, 0.5)
moved()
},
resetView() {
if (destroyed) return
cam.zoom = 1
cam.x = 0
cam.y = 0
moved()
},
locate(workerId) {
if (destroyed || typeof workerId !== 'string' || !Object.hasOwn(director.byId, workerId)) return null
const box = director.byId[workerId].box
if (!box) return null
const v = worldToView(cam, box.x + box.w / 2, box.y + box.h / 2)
if (!(v.vx >= 0 && v.vx <= 1 && v.vy >= 0 && v.vy <= 1)) return null
const r = canvas.getBoundingClientRect()
return { x: v.vx * r.width, y: v.vy * r.height }
},
stats() {
return { frames, avgDrawMs: frames > 0 ? drawMs / frames : 0, zoom: cam.zoom }
},
debug() {
return {
pending: director.pending(),
agents: director.agents.map((a) => ({ id: a.id, x: a.x, y: a.y, floor: a.floor, pose: a.pose, grey: a.grey })),
lift: lift.state,
fx: fx.size(),
}
},
}
// ---------- Start ----------
resize()
if (looping()) {
lastT = now()
schedule()
}
if (fontsReady && typeof fontsReady.then === 'function') {
fontsReady.then(() => draw(), () => {})
}
return handle
}
-329
View File
@@ -1,329 +0,0 @@
// Engine-Grundlagen des Hustler-Hochhauses: Palette, Raster, Figuren mit Posen, Text, Sprechblasen.
// Portiert aus entwuerfe/sprites.js (ES5-IIFE mit fester Skala S = 3). Neu: Raster je Kontext statt fester Skala,
// Posen (stand, type, coffee, sleep), Graustufe für nicht angeschlossene Worker, Warnzeichen.
// Kein React, kein Appwrite, kein window/document: alles Zeichnen geht über den übergebenen Canvas-Kontext.
export const PAL = Object.freeze({
o: '#3a2f3d', // Umriss (dunkles Pflaume-Grau statt Schwarz)
b: '#d98a66', // Körper (Ton)
s: '#b8694a', // Körper Schatten
h: '#f2b897', // Körper Licht
e: '#2b2230', // Augen
w: '#ffffff',
g: '#9aa3b2', // Papier-Umriss
p: '#c3cad6', // Papier-Zeilen
ink: '#1a1f36',
mute: '#697386',
line: '#ebeef1',
floor: '#f6f7f9',
floor2: '#eef0f4',
wood: '#c9a27e', wood2: '#a8825f',
metal: '#c3c8d2', metal2: '#8c93a3',
screen: '#1f2a44',
red: '#d6455d', green: '#3aa66b', gold: '#e8b730', gold2: '#b98a12',
shadow: 'rgba(26,31,54,0.12)',
})
// Hut-Farben zeigen das Modell: Opus = Krone, Sonnet = Kappe, Haiku = Mütze, Overseer = Kommandomütze
export const HAT = Object.freeze({
opus: Object.freeze(['#e8b730', '#b98a12']),
sonnet: Object.freeze(['#4a7fd6', '#2f5ea8']),
haiku: Object.freeze(['#5cbf8a', '#3a9466']),
overseer: Object.freeze(['#7a5cd6', '#54399f']),
})
// Ein Farbton pro Station/Team
export const TEAM = Object.freeze({
radar: '#4a90d9', // Anfragen-Radar
aerzte: '#3fb6b2', // Ärzte & Praxen
handwerk: '#e8873a', // Handwerk
selbst: '#c66bd1', // Selbstständige
laden: '#8fb83a', // Läden & Gastro
bewertung: '#e0b13a',
werkstatt: '#d98a66',
pruefung: '#d6455d',
freigabe: '#7a5cd6',
archiv: '#8c93a3',
tresor: '#3aa66b',
})
export const FONT_FAMILY = '"Pixelify Sans", ui-monospace, Menlo, monospace'
// ---------- Raster ----------
// Raster je Kontext: k = Geräte-Pixel je logischer Einheit, (ox, oy) = Versatz in Geräte-Pixeln.
// Ohne setGrid gilt k = 3 (die Skala der Vorlage). Ein WeakMap hält den Zustand außerhalb des Kontexts.
const DEFAULT_GRID = Object.freeze({ k: 3, ox: 0, oy: 0 })
const grids = new WeakMap()
const gridOf = (ctx) => grids.get(ctx) || DEFAULT_GRID
export function setGrid(ctx, { k, ox = 0, oy = 0 }) {
grids.set(ctx, { k, ox, oy })
}
// Rechteck in logischen Einheiten. Beide Kanten werden einzeln gerundet (die rechte Kante eines Rechtecks ist die
// linke des nächsten: keine Nähte), die Größe beträgt mindestens ein Geräte-Pixel.
export function px(ctx, x, y, w, h, color) {
const { k, ox, oy } = gridOf(ctx)
const left = Math.round(x * k + ox)
const top = Math.round(y * k + oy)
ctx.fillStyle = color
ctx.fillRect(
left,
top,
Math.max(1, Math.round((x + w) * k + ox) - left),
Math.max(1, Math.round((y + h) * k + oy) - top),
)
}
// Fläche mit Deckkraft (save/restore gleichen sich aus)
export function tint(ctx, x, y, w, h, color, alpha) {
ctx.save()
ctx.globalAlpha = alpha
px(ctx, x, y, w, h, color)
ctx.restore()
}
// ---------- Sprite-Karten ----------
const BODY = [
'....oooo....',
'..oohhhboo..',
'.ohhbbbbbbo.',
'.ohbbbbbbbo.',
'obbebbbbebso',
'obbebbbbebso',
'obbbbbbbbbso',
'obbbbbbbbbso',
'.obbbbbbbso.',
'.ossbbbbsso.',
'..oossssoo..',
]
// Schlafen: die obere Augenzeile wird zu Körperfarbe, nur die Zeile darunter bleibt (zwei Augenpunkte)
const BODY_SLEEP = BODY.map((row, i) => (i === 4 ? row.replace(/e/g, 'b') : row))
const LEGS = [
['...oo..oo...', '...oo..oo...'], // stehen
['..oo....oo..', '.oo......oo.'], // Schritt A
['....oooo....', '....oooo....'], // Schritt B
]
const HATS = {
opus: ['...H.HH.H...', '...HHHHHH...', '...SSSSSS...'],
sonnet: ['....HHHH....', '...HHHHHH...', '...SSSSSSSS.'],
haiku: ['.....HH.....', '...HHHHHH...', '...HSHSHSH..'],
overseer: ['...HHHHHH...', '..HHHHwHHH..', '..SSSSSSSS..'],
}
const VISOR = ['obVwVVVVVVso', 'obVVVVVVVVso'] // ersetzt die Augenzeilen 4–5 beim Overseer
const PAPER = ['gggggg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gggggg']
const ARROW = ['ooooo', '.ooo.', '..o..']
const ALERT = ['rrr', 'rrr', 'rrr', '.r.', '.r.', '...', '.r.'] // 3 × 7: „!“
// Umriss um eine Karte: jede leere Zelle (auch diagonal) neben einer belegten wird 'o'; die Karte wächst um 1 je Seite
function outlined(map) {
const h = map.length
const w = map[0].length
const at = (r, c) => (r >= 0 && r < h && c >= 0 && c < w ? map[r][c] : '.')
const nextToInk = (r, c) => [-1, 0, 1].some((dr) => [-1, 0, 1].some((dc) => at(r + dr, c + dc) !== '.'))
const rows = []
for (let r = -1; r <= h; r++) {
let row = ''
for (let c = -1; c <= w; c++) row += at(r, c) === '.' && nextToInk(r, c) ? 'o' : at(r, c)
rows.push(row)
}
return rows
}
const ALERT_OUTLINED = outlined(ALERT)
// Grautöne für nicht angeschlossene Worker (Umriss, Augen und Papier bleiben)
const GREY = { b: '#aeb3bd', s: '#8a909c', h: '#c9cdd5', H: '#9ca2ae', S: '#7f8693' }
function workerColors(hat, grey) {
const hc = HAT[hat] || HAT.haiku
if (grey) return { o: PAL.o, b: GREY.b, s: GREY.s, h: GREY.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: GREY.H, S: GREY.S, V: GREY.S }
return { o: PAL.o, b: PAL.b, s: PAL.s, h: PAL.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: hc[0], S: hc[1], V: HAT.overseer[1] }
}
// Zeichnet eine Karte zellenweise: (x, y) = linke obere Ecke in logischen Einheiten, sc = Zellgröße, flip spiegelt
function drawMap(ctx, map, x, y, colors, flip, sc) {
for (let r = 0; r < map.length; r++) {
const row = map[r]
for (let c = 0; c < row.length; c++) {
const ch = row[c]
if (ch === '.') continue
const col = colors[ch]
if (!col) continue
const cx = flip ? row.length - 1 - c : c
px(ctx, x + cx * sc, y + r * sc, sc, sc, col)
}
}
}
function drawSelect(ctx, x, y, sc, t) {
const hop = Math.round(Math.sin(t * 4)) * sc
drawMap(ctx, ARROW, x, y + hop, { o: PAL.ink }, false, sc)
}
// Kaffeetasse an der Hand (3 × 3) mit einem Dampfpixel, der mit t flackert
function drawCup(ctx, x, y, sc, flip, t) {
const cx = x + (flip ? -1 : 10) * sc
const cy = y + 6 * sc
px(ctx, cx, cy, 3 * sc, 3 * sc, PAL.metal2)
px(ctx, cx, cy, 3 * sc, sc, PAL.wood2)
if (Math.floor(t * 3) % 2 === 0) px(ctx, cx + sc, cy - 2 * sc, sc, sc, PAL.g)
}
// Kleines „z“ aus drei Rechtecken über dem Hut, steigt in ganzen Pixeln mit t (alle 2,5 s von vorn)
function drawSleepZ(ctx, x, y, sc, flip, t) {
const rise = Math.floor((Math.max(0, t) * 2) % 5)
const zx = x + (flip ? 0 : 9) * sc
const zy = y - (6 + rise) * sc
px(ctx, zx, zy, 3 * sc, sc, PAL.mute)
px(ctx, zx + sc, zy + sc, sc, sc, PAL.mute)
px(ctx, zx, zy + 2 * sc, 3 * sc, sc, PAL.mute)
}
/* Zeichnet einen kleinen Worker. (x, y) = linke obere Ecke des Körpers (ohne Hut), logische Einheiten.
hat: 'opus' | 'sonnet' | 'haiku' | 'overseer'; frame: 0 stehen, 1/2 laufen; facing: 1 rechts, -1 links;
carrying: Dokument in der Hand; scale: 1 normal, 2 groß; selected: Pfeil über dem Hut;
pose: 'stand' und 'type' sehen gleich aus (das Wippen macht die Regie), 'coffee' mit Tasse, 'sleep' mit z;
grey: halb durchsichtig in Grautönen (nicht angeschlossen), t: Zeit in Sekunden für z, Dampf und Pfeil.
Rückgabe: Trefferfläche { x, y, w, h } in logischen Einheiten (bei Graustufe unverändert). */
export function drawWorker(ctx, x, y, { hat, frame = 0, facing = 1, carrying = false, scale = 1, selected = false, pose = 'stand', grey = false, t = 0 } = {}) {
const sc = scale
const flip = facing === -1
const bob = frame === 1 ? -1 * sc : 0
const colors = workerColors(hat, grey)
if (grey) {
ctx.save()
ctx.globalAlpha = 0.55
}
px(ctx, x + 2 * sc, y + 13 * sc, 8 * sc, 1 * sc, PAL.shadow)
drawMap(ctx, LEGS[frame] || LEGS[0], x, y + 11 * sc, colors, flip, sc)
drawMap(ctx, pose === 'sleep' ? BODY_SLEEP : BODY, x, y + bob, colors, flip, sc)
if (hat === 'overseer') drawMap(ctx, VISOR, x, y + bob + 4 * sc, colors, flip, sc)
drawMap(ctx, HATS[hat] || HATS.haiku, x, y + bob - 2 * sc, colors, flip, sc)
if (carrying) drawMap(ctx, PAPER, x + (flip ? -2 : 8) * sc, y + bob + 6 * sc, colors, false, sc)
if (pose === 'coffee') drawCup(ctx, x, y + bob, sc, flip, t)
else if (pose === 'sleep') drawSleepZ(ctx, x, y + bob, sc, flip, t)
if (grey) ctx.restore()
if (selected) drawSelect(ctx, x + 3.5 * sc, y + bob - 7 * sc, sc, t)
return { x: x - (carrying && flip ? 2 * sc : 0), y: y - 2 * sc, w: (carrying ? 14 : 12) * sc, h: 16 * sc }
}
export function drawPaper(ctx, x, y) {
drawMap(ctx, PAPER, x, y, { g: PAL.g, w: PAL.w, p: PAL.p }, false, 1)
}
// Rotes „!“ mit Umriss (bleibt stehen, solange ein Worker den Status fehler hat).
// (x, y) = Mitte der Unterkante, wie die Spitze der Sprechblase: das Zeichen steht über diesem Punkt.
export function drawAlertMark(ctx, x, y) {
const cols = ALERT_OUTLINED[0].length
drawMap(ctx, ALERT_OUTLINED, x - cols / 2, y - ALERT_OUTLINED.length, { r: PAL.red, o: PAL.o }, false, 1)
}
// ---------- Text (Pixel-Schrift, Größe wächst mit dem Raster) ----------
const fontOf = (weight, devPx) => `${weight} ${Math.round(devPx * 100) / 100}px ${FONT_FAMILY}`
/* Text an (x, y) in logischen Einheiten (y = Oberkante der Zeile). Schrift = size * k / 3 Geräte-Pixel,
bei k = 3 also size px. bg: Farbe eines Kastens hinter dem Text; halo: Farbe eines dünnen Rands um die Buchstaben
(lesbar auch über dunklen Flächen wie einem Monitor); align: 'left' | 'center' | 'right'. */
export function label(ctx, text, x, y, { size = 12, color = PAL.ink, weight = 500, align = 'left', bg, halo } = {}) {
const { k, ox, oy } = gridOf(ctx)
const f = k / 3
const str = String(text ?? '')
const dx = Math.round(x * k + ox)
const dy = Math.round(y * k + oy)
ctx.font = fontOf(weight, size * f)
ctx.textBaseline = 'top'
ctx.textAlign = align
if (bg) {
const tw = ctx.measureText(str).width
const left = align === 'center' ? dx - tw / 2 : align === 'right' ? dx - tw : dx
ctx.fillStyle = bg
ctx.fillRect(Math.round(left - 4 * f), Math.round(dy - 2 * f), Math.round(tw + 8 * f), Math.round(size * f + 5 * f))
}
if (halo) {
ctx.lineJoin = 'round'
ctx.lineWidth = Math.max(2, Math.round(3 * f))
ctx.strokeStyle = halo
ctx.strokeText(str, dx, dy)
}
ctx.fillStyle = color
ctx.fillText(str, dx, dy)
ctx.textAlign = 'left'
}
function deviceRect(ctx, x, y, w, h, color) {
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
// Breite der Zeichenfläche in Geräte-Pixeln (ohne Canvas 960, wie die Welt bei Raster 3)
const canvasWidth = (ctx) => (ctx.canvas && ctx.canvas.width > 0 ? ctx.canvas.width : 960)
/* Sprechblase über einem Worker: (x, y) = Spitze der Blase (logisch). Maße wachsen mit dem Raster; die Blase
wird in Geräte-Pixeln in die Breite von ctx.canvas geklemmt (Rand 2 bei k = 3, ohne Canvas 960). */
export function drawBubble(ctx, x, y, text) {
const str = String(text ?? '')
if (str === '') return
const { k, ox, oy } = gridOf(ctx)
const f = k / 3
const u = (n) => Math.max(1, Math.round(n * f))
ctx.font = fontOf(500, 12 * f)
ctx.textBaseline = 'top'
ctx.textAlign = 'left'
const pad = u(7)
const edge = u(2)
const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad
const bh = u(22)
const tipX = Math.round(x * k + ox)
const by = Math.round(y * k + oy) - bh - u(6)
const maxW = canvasWidth(ctx)
const bx = Math.max(edge, Math.min(maxW - bw - edge, Math.round(tipX - bw / 2)))
// Die Spitze bleibt am Kasten, auch wenn der Kasten wegen des Rands verschoben wurde
const tailW = u(6)
const tx = Math.max(bx + Math.round(tailW / 2), Math.min(bx + bw - Math.round(tailW / 2), tipX))
deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o)
deviceRect(ctx, bx, by, bw, bh, PAL.w)
deviceRect(ctx, tx - Math.round(tailW / 2), by + bh + edge, tailW, u(3), PAL.o)
deviceRect(ctx, tx - u(1), by + bh + edge + u(3), u(2), u(2), PAL.o)
ctx.fillStyle = PAL.ink
ctx.fillText(str, bx + pad, by + u(5))
}
/* Sprechblase neben einem Worker, für Otto an der Oberkante der Welt, wo über dem Kopf kein Platz ist (Vorlage: sideBubble).
leftX/rightX = linke und rechte Körperkante (logisch), y = Oberkante der Blase. Die Blase steht links vom Körper und ihre
Spitze zeigt nach rechts auf leftX; fehlt links der Platz (Rand 4), steht sie rechts davon mit der Spitze auf rightX,
in die Breite von ctx.canvas geklemmt. Maße und Schrift wachsen mit dem Raster. */
export function drawSideBubble(ctx, text, { leftX, rightX, y }) {
const str = String(text ?? '')
if (str === '') return
const { k, ox, oy } = gridOf(ctx)
const f = k / 3
const u = (n) => Math.max(1, Math.round(n * f))
ctx.font = fontOf(500, 12 * f)
ctx.textBaseline = 'top'
ctx.textAlign = 'left'
const pad = u(7)
const edge = u(2)
const gap = u(9) // Blasenrand bis Körperkante: Rahmen, Spitze (8) und ein Pixel Luft
const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad
const bh = u(22)
const by = Math.round(y * k + oy)
let bx = Math.round(leftX * k + ox) - gap - bw
const onLeft = bx >= u(4)
if (!onLeft) bx = Math.max(edge, Math.min(canvasWidth(ctx) - bw - edge, Math.round(rightX * k + ox) + gap))
deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o)
deviceRect(ctx, bx, by, bw, bh, PAL.w)
if (onLeft) {
deviceRect(ctx, bx + bw + edge, by + u(7), u(4), u(4), PAL.o)
deviceRect(ctx, bx + bw + u(6), by + u(8), u(2), u(2), PAL.o)
} else {
deviceRect(ctx, bx - u(6), by + u(7), u(4), u(4), PAL.o)
deviceRect(ctx, bx - u(8), by + u(8), u(2), u(2), PAL.o)
}
ctx.fillStyle = PAL.ink
ctx.fillText(str, bx + pad, by + u(5))
}
+11 -2
View File
@@ -1,5 +1,5 @@
/* Press Start 2P (SIL OFL 1.1, Lizenztext: public/fonts/OFL-PressStart2P.txt) für Zahlen und Überschriften der Hustler-Oberfläche.
Selbst gehostet, kein Abruf bei Google Fonts. Pixelify Sans ist schon in workers.css eingebunden. */
/* Die zwei Schriften der Hustler-Oberfläche, beide selbst gehostet (SIL OFL 1.1, Lizenztexte neben den Dateien in public/fonts/), kein Abruf bei Google Fonts:
Press Start 2P für Zahlen und Überschriften (OFL-PressStart2P.txt) und Pixelify Sans für Fließtext, Namen und Sprechblasen (OFL-PixelifySans.txt). */
@font-face {
font-family: "Press Start 2P";
src: url("/fonts/PressStart2P-Regular.woff2") format("woff2");
@@ -7,3 +7,12 @@
font-style: normal;
font-display: swap;
}
/* nur der Latin-Schnitt, variabel mit allen Gewichten */
@font-face {
font-family: "Pixelify Sans";
src: url("/fonts/PixelifySans-Variable.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
+4 -260
View File
@@ -1,152 +1,7 @@
/* Hustler (Workers P2): Hochhaus, Kennzahlen, Worker-Karte, Liste, Einstellungen. Weißes Theme: Haarlinien statt Karten, nur Tokens aus global.css. */
/* Schrift selbst gehostet (SIL OFL 1.1, Lizenztext liegt daneben), nur der Latin-Schnitt mit allen Gewichten */
@font-face {
font-family: "Pixelify Sans";
src: url("/fonts/PixelifySans-Variable.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
.wk-page .page-head h1 { font-family: "Pixelify Sans", var(--font); letter-spacing: 0; }
/* ---------- Kopfzeile mit Pausenschalter ---------- */
.wk-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
margin-bottom: 20px;
}
.wk-status { color: var(--text-2); }
/* Meldungen zwischen Kopfzeile und Kennzahlen (Notbremse, Fehler) halten Abstand zu den Kennzahlen */
.wk-toolbar ~ .msg { margin-bottom: 16px; }
/* ---------- Kennzahlen ---------- */
.wk-kpis.stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 16px 32px;
margin: 0 0 24px;
}
.wk-kpis .stat { min-width: 0; font-variant-numeric: tabular-nums; }
.wk-kpis .stat .sub { min-height: 16px; }
/* ---------- Hochhaus und Seitenspalte ---------- */
.wk-main {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 32px;
align-items: start;
}
.wk-side {
min-width: 0;
padding-left: 24px;
border-left: 1px solid var(--line);
}
.wk-stage { position: relative; min-width: 0; }
.wk-canvas {
display: block;
width: 100%;
aspect-ratio: 5 / 4;
image-rendering: pixelated;
cursor: pointer;
touch-action: pan-y;
}
/* unten rechts: oben rechts läge er über dem Schild der Kommandobrücke */
.wk-zoom {
position: absolute;
bottom: 8px;
right: 8px;
display: flex;
gap: 4px;
}
.wk-zoom .btn { min-width: 32px; }
/* ---------- Worker-Karte ---------- */
.wk-card { position: relative; padding-bottom: 20px; scroll-margin-top: 12px; }
.wk-card > .btn-quiet { position: absolute; top: 0; right: 0; }
.wk-card h3 { margin: 20px 0 8px; font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-2); }
.wk-portrait {
display: block;
width: 72px;
height: 96px;
margin-bottom: 8px;
image-rendering: pixelated;
}
.wk-name { font-family: "Pixelify Sans", var(--font); font-size: 20px; line-height: 26px; font-weight: 600; overflow-wrap: anywhere; }
.wk-role { color: var(--text-2); margin-bottom: 8px; }
.wk-hint { color: var(--text-2); font-size: 13px; }
.wk-chip {
display: inline-block;
margin-right: 8px;
padding: 0 6px;
border-radius: var(--radius);
font-size: 12px;
line-height: 20px;
font-weight: 500;
background: var(--grey-bg);
color: var(--grey-fg);
}
.wk-chip-opus { background: var(--orange-bg); color: var(--orange-fg); }
.wk-chip-sonnet { background: var(--blue-bg); color: var(--blue-fg); }
.wk-chip-haiku { background: var(--green-bg); color: var(--green-fg); }
.wk-chip-overseer { background: var(--violet-bg); color: var(--violet-fg); }
.wk-facts, .wk-counters dl { margin: 12px 0 0; }
.wk-facts > div, .wk-counters dl > div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-top: 1px solid var(--line);
font-variant-numeric: tabular-nums;
}
.wk-facts dt, .wk-counters dt { color: var(--text-2); flex: none; }
.wk-facts dd, .wk-counters dd { text-align: right; min-width: 0; overflow-wrap: anywhere; }
.wk-trace { list-style: none; margin: 0; padding: 0; }
.wk-trace li { padding: 6px 0; border-top: 1px solid var(--line); font-size: 13px; line-height: 18px; overflow-wrap: anywhere; }
.wk-trace-time { color: var(--text-3); font-variant-numeric: tabular-nums; }
/* ---------- Liste aller Worker ---------- */
.wk-list { margin-top: 8px; border-top: 1px solid var(--line); }
.wk-list ul { list-style: none; margin: 0; padding: 0; }
.wk-list-floor { margin: 16px 0 4px; font-size: 12px; line-height: 16px; font-weight: 600; color: var(--text-3); }
.wk-list button {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 4px 8px;
border-radius: var(--radius);
text-align: left;
transition: background-color 0.12s;
}
.wk-list button:hover { background: var(--bg-hover); }
.wk-list button[aria-pressed="true"] { background: var(--accent-soft); font-weight: 600; }
.wk-list button > span:last-child { color: var(--text-2); font-size: 12px; font-weight: 400; }
.wk-list button:focus-visible { outline-offset: -2px; }
.wk-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
.wk-dot-arbeitet { background: var(--green-fg); }
.wk-dot-wartet { background: var(--nav-dot); }
.wk-dot-leerlauf { background: var(--text-3); }
.wk-dot-grau { background: transparent; box-shadow: inset 0 0 0 1.5px var(--line-strong); }
.wk-dot-fehler { background: var(--danger); }
/* Hustler: die weißen Bausteine, die in der Pixelwelt weiter gebraucht werden. Hochhaus, Pult und Keller stehen in styles/hustler/;
hier liegen nur noch der Einstellungen-Dialog (WorkersSettingsDialog) und das Interessenten-Panel (ProspectPanel), beide im weißen
Dialog-Stil der übrigen Seiten: Haarlinien statt Karten, nur Tokens aus global.css. Jede Regel beginnt mit einer wk-Klasse; die Schrift
„Pixelify Sans“ steht in styles/hustler/schrift.css. */
/* ---------- Einstellungen (Dialog) ---------- */
@@ -171,52 +26,6 @@
}
.wk-halt .wk-set-title { color: var(--red-fg); }
/* ---------- Freigabe-Pult ---------- */
.wk-pult-filters { margin-bottom: 16px; }
.wk-pult-filters ~ .msg { margin-bottom: 12px; }
.wk-pult-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.wk-pult-row { min-width: 0; padding: 16px 0; border-bottom: 1px solid var(--line); }
.wk-pult-row .msg { margin-top: 8px; }
.wk-pult-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
.wk-pult-title { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.wk-pult-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 16px;
margin: 4px 0 12px;
font-size: 13px;
color: var(--text-2);
}
.wk-pult-original { margin: 0 0 12px; }
.wk-pult-original summary { cursor: pointer; color: var(--text-2); font-size: 13px; }
.wk-pult-original-text { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
/* Aufgeklappte Felder unter den Aktionen: „Als Mensch antworten“ (reply) und „Antwort des Kunden einfügen“ (paste). Haarlinie darüber, volle Breite. */
.wk-pult-row .wk-pult-reply, .wk-pult-row .wk-pult-paste {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.wk-pult-reply .wk-hint, .wk-pult-paste .wk-hint { margin: 0; }
.wk-pult-reply .textarea, .wk-pult-paste .textarea { display: block; width: 100%; min-height: 140px; }
.wk-pult-panel-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
/* „Abbrechen“ links vom Hauptknopf */
.wk-pult-panel-btns { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.wk-pult-count { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; }
.wk-pult-count.over { color: var(--red-fg); }
/* ---------- Interessenten-Panel (Dialog) ---------- */
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
@@ -235,76 +44,11 @@
.wk-prospect-msg { min-width: 0; }
.wk-prospect-msg-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; margin-bottom: 4px; font-size: 13px; color: var(--text-2); }
/* ---------- Bot-Leads ---------- */
.wk-bl { min-width: 0; }
.wk-bl > .wk-hint { margin: 0 0 12px; }
.wk-bl .search { max-width: 100%; }
.wk-bl-single { gap: 8px 16px; }
/* Die Tabelle scrollt auf dem Handy waagerecht in ihrem Rahmen, nicht die Seite */
.wk-bl-scroll { max-width: 100%; overflow-x: auto; }
.wk-bl-table { min-width: 760px; }
/* break-word statt anywhere: anywhere ließe die Spalten auf ein Zeichen schrumpfen („Handwer-k“); lange Namen brechen, der Rahmen scrollt */
.wk-bl-table td { overflow-wrap: break-word; }
/* Der Ort steht in .cell-sub, und global.css hat `.table td .cell-sub { overflow-wrap: anywhere }` (0,2,1): die Regel sitzt auf dem Element selbst und schlägt das
break-word der Zelle. Mit anywhere schrumpft die Spalte Betrieb unter das Wort („Kaiserslau-tern“, besonders in der Einzelansicht am Handy). Hier drei Klassen
(0,3,1): streng größer, nicht gleich, damit nicht die Reihenfolge der Stylesheets im Bündel entscheidet. global.css bleibt, andere Tabellen brauchen anywhere. */
.wk-bl .wk-bl-table td .cell-sub { overflow-wrap: break-word; }
.wk-bl-nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
/* Gekürzte Einträge (Daten nach der Frist entfernt) bleiben als graue Zeile stehen */
.wk-bl-cut td { color: var(--text-2); } /* --text-3 (#8a93a3) hätte nur etwa 3:1; --text-2 (#5e6678) hat mehr als 5:1 auf Weiß und auf dem Zeilengrund */
.wk-bl-tag {
display: inline-block;
padding: 0 6px;
border-radius: var(--radius);
font-size: 12px;
line-height: 20px;
font-weight: 500;
background: var(--grey-bg);
color: var(--grey-fg);
}
.wk-bl-pager { display: flex; gap: 8px; }
/* „Erneut versuchen“ unter der Fehlermeldung (die Meldung selbst hat keinen Abstand nach unten) */
.wk-bl-retry { margin-top: 8px; }
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {
.wk-kpis.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.wk-main { grid-template-columns: minmax(0, 1fr); gap: 24px; }
.wk-side { padding-left: 0; border-left: 0; padding-top: 24px; border-top: 1px solid var(--line); }
}
@media (max-width: 860px) {
/* unter der festen Leiste der Handy-Ansicht (52 px) */
.wk-card { scroll-margin-top: 64px; }
.wk-kpis.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; }
.wk-zoom { gap: 6px; }
.wk-zoom .btn { min-width: 36px; min-height: 36px; }
.wk-toolbar .btn { min-height: 36px; }
/* Pult: Aktionen untereinander, Schaltflächen über die volle Breite */
.wk-pult-row { padding: 12px 0; }
.wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
.wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
/* Felder: Zähler und Knopf untereinander, der Knopf über die volle Breite */
.wk-pult-panel-foot { flex-direction: column; align-items: stretch; }
/* Hauptknopf oben, „Abbrechen“ darunter */
.wk-pult-panel-btns { flex-direction: column-reverse; align-items: stretch; }
.wk-pult-panel-foot .btn { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
/* „Schließen“ im Fuß des Interessenten-Panels: `.dialog-foot .btn` (global.css, 32 px) gilt für alle Dialoge und bleibt; hier nur der eigene Knopf auf 40 px.
Der Dialog hängt nicht unter einer wk-Klasse, die Regel beginnt deshalb mit der Klasse des Knopfs. Mit (0,3,0) (Klasse, Klasse, Attribut) gewinnt sie
gegen (0,2,0), gleich in welcher Reihenfolge die Dateien geladen werden. */
.wk-prospect-close.btn[type="button"] { width: 100%; min-height: 40px; justify-content: center; }
/* Bot-Leads: Blätterknöpfe, „Alle anzeigen“ der Einzelansicht und „Erneut versuchen“ mit 40 px Tippfläche wie die Knöpfe des Pults; min-height schlägt height: 32px von .btn, und mit
zwei Klassen (0,2,0) gegen (0,1,0) braucht es kein !important. Keine globale .btn-Regel: Dialoge und andere Seiten behalten ihre Höhe. Die Zeile darf umbrechen */
.wk-bl-pager .btn { min-height: 40px; }
.wk-bl-single .btn { min-height: 40px; }
.wk-bl-retry .btn { min-height: 40px; }
.wk-bl .table-foot { flex-wrap: wrap; gap: 8px 16px; }
}
/* ---------- Weniger Bewegung ---------- */
@media (prefers-reduced-motion: reduce) {
.wk-list button, .wk-zoom .btn, .wk-toolbar .btn { transition: none; }
}