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:
1 parent
4be9750f36
commit
ba007a325a
35 files changed
+277
-6495
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user