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.
|
/* 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:
|
||||||
Selbst gehostet, kein Abruf bei Google Fonts. Pixelify Sans ist schon in workers.css eingebunden. */
|
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-face {
|
||||||
font-family: "Press Start 2P";
|
font-family: "Press Start 2P";
|
||||||
src: url("/fonts/PressStart2P-Regular.woff2") format("woff2");
|
src: url("/fonts/PressStart2P-Regular.woff2") format("woff2");
|
||||||
@@ -7,3 +7,12 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
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. */
|
/* 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
|
||||||
/* Schrift selbst gehostet (SIL OFL 1.1, Lizenztext liegt daneben), nur der Latin-Schnitt mit allen Gewichten */
|
Dialog-Stil der übrigen Seiten: Haarlinien statt Karten, nur Tokens aus global.css. Jede Regel beginnt mit einer wk-Klasse; die Schrift
|
||||||
@font-face {
|
„Pixelify Sans“ steht in styles/hustler/schrift.css. */
|
||||||
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); }
|
|
||||||
|
|
||||||
/* ---------- Einstellungen (Dialog) ---------- */
|
/* ---------- Einstellungen (Dialog) ---------- */
|
||||||
|
|
||||||
@@ -171,52 +26,6 @@
|
|||||||
}
|
}
|
||||||
.wk-halt .wk-set-title { color: var(--red-fg); }
|
.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) ---------- */
|
/* ---------- Interessenten-Panel (Dialog) ---------- */
|
||||||
|
|
||||||
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
|
/* 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 { 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); }
|
.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 ---------- */
|
/* ---------- 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) {
|
@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.
|
/* „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
|
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. */
|
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; }
|
.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; }
|
|
||||||
}
|
}
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
// vm-Sandbox für die Szene des Hustler-Hochhauses (Muster: entwuerfe/smoke.js, nur für Tests).
|
|
||||||
// scene.js wird mit esbuild zu einem IIFE gebündelt und in einem vm-Kontext ohne window/document ausgeführt:
|
|
||||||
// der Kontext kennt nur console, Math, Date und JSON. Canvas, Zeichenkontext und Bildtakt sind Attrappen.
|
|
||||||
|
|
||||||
import vm from 'node:vm'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import esbuild from 'esbuild'
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
|
|
||||||
const SCENE = path.join(repoRoot, 'src/components/workers/engine/scene.js')
|
|
||||||
const STEP_MS = 1000 / 60
|
|
||||||
|
|
||||||
// Das Bündel ist für alle Sandboxen gleich: einmal bauen
|
|
||||||
let bundle = null
|
|
||||||
function bundleScene() {
|
|
||||||
bundle ??= esbuild
|
|
||||||
.build({ entryPoints: [SCENE], bundle: true, format: 'iife', globalName: 'WKEngine', platform: 'neutral', write: false })
|
|
||||||
.then((result) => result.outputFiles[0].text)
|
|
||||||
return bundle
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* createEngineSandbox({ cssWidth = 960, dpr = 1, record = false })
|
|
||||||
* → { mountTower, engine, canvas, counts, seenTexts, rects, texts, listeners, listenerOptions,
|
|
||||||
* opts(extra), tick(seconds), jump(seconds), rafPending(), setCssWidth(w),
|
|
||||||
* click(cssX, cssY), pointer(type, cssX, cssY, extra), wheel(cssX, cssY, deltaY, { ctrlKey }) }
|
|
||||||
* counts: { fillRect, fillText } zählt nur (es werden keine Aufrufe gespeichert, sonst sprengen tausende Bilder den Speicher)
|
|
||||||
* seenTexts: Set der verschiedenen gezeichneten Texte
|
|
||||||
* rects/texts: nur mit record: true, jedes fillRect { n, x, y, w, h, fill, alpha } und jedes fillText { n, text, x, y, font }
|
|
||||||
* (n = laufende Nummer über beide Listen, damit die Reihenfolge der Aufrufe vergleichbar ist; alpha folgt save/restore)
|
|
||||||
* listeners: Typ -> Liste der Rückrufe am Canvas; listenerOptions: Map Rückruf -> Optionen von addEventListener
|
|
||||||
* tick(s): rückt die Uhr in Schritten von 1000/60 ms vor und führt fällige raf-Rückrufe aus; jump(s) rückt die Uhr ohne Rückrufe
|
|
||||||
* (ein Stillstand); click/pointer/wheel schicken Ereignisse in CSS-Pixeln relativ zur Leinwand (Rand 0, Breite cssWidth, Höhe 4/5)
|
|
||||||
*/
|
|
||||||
export async function createEngineSandbox({ cssWidth = 960, dpr = 1, record = false } = {}) {
|
|
||||||
const code = await bundleScene()
|
|
||||||
|
|
||||||
const counts = { fillRect: 0, fillText: 0 }
|
|
||||||
const seenTexts = new Set()
|
|
||||||
const rects = []
|
|
||||||
const texts = []
|
|
||||||
const listeners = {}
|
|
||||||
const listenerOptions = new Map()
|
|
||||||
const alphaStack = []
|
|
||||||
let seq = 0
|
|
||||||
let width = cssWidth
|
|
||||||
|
|
||||||
const target = { fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1, textAlign: 'left', textBaseline: 'alphabetic' }
|
|
||||||
const ctx = new Proxy(target, {
|
|
||||||
get(t, prop) {
|
|
||||||
if (typeof prop === 'symbol') return undefined
|
|
||||||
if (prop === 'save') return () => alphaStack.push(t.globalAlpha)
|
|
||||||
if (prop === 'restore') return () => { if (alphaStack.length > 0) t.globalAlpha = alphaStack.pop() }
|
|
||||||
if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 })
|
|
||||||
if (prop === 'fillRect') {
|
|
||||||
return (x, y, w, h) => {
|
|
||||||
counts.fillRect += 1
|
|
||||||
if (record) rects.push({ n: seq++, x, y, w, h, fill: t.fillStyle, alpha: t.globalAlpha })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prop === 'fillText') {
|
|
||||||
return (text, x, y) => {
|
|
||||||
if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text)
|
|
||||||
counts.fillText += 1
|
|
||||||
seenTexts.add(text)
|
|
||||||
if (record) texts.push({ n: seq++, text, x, y, font: t.font })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} })
|
|
||||||
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) })
|
|
||||||
if (prop in t) return t[prop]
|
|
||||||
return () => {}
|
|
||||||
},
|
|
||||||
set(t, prop, value) {
|
|
||||||
t[prop] = value
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const canvas = {
|
|
||||||
width: 300,
|
|
||||||
height: 150,
|
|
||||||
style: {},
|
|
||||||
getContext: () => ctx,
|
|
||||||
addEventListener(type, fn, options) {
|
|
||||||
listeners[type] = [...(listeners[type] ?? []), fn]
|
|
||||||
if (options !== undefined) listenerOptions.set(fn, options)
|
|
||||||
},
|
|
||||||
removeEventListener(type, fn) {
|
|
||||||
listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn)
|
|
||||||
},
|
|
||||||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height: width * 0.8 }),
|
|
||||||
}
|
|
||||||
target.canvas = canvas
|
|
||||||
|
|
||||||
// Bildtakt: raf/caf führen Buch, now ist die Uhr der Sandbox
|
|
||||||
let clock = 0
|
|
||||||
let nextId = 0
|
|
||||||
const queue = new Map()
|
|
||||||
const raf = (cb) => {
|
|
||||||
nextId += 1
|
|
||||||
queue.set(nextId, cb)
|
|
||||||
return nextId
|
|
||||||
}
|
|
||||||
const caf = (id) => {
|
|
||||||
queue.delete(id)
|
|
||||||
}
|
|
||||||
const now = () => clock
|
|
||||||
|
|
||||||
function tick(seconds) {
|
|
||||||
const steps = Math.round(seconds * 60)
|
|
||||||
for (let i = 0; i < steps; i++) {
|
|
||||||
clock += STEP_MS
|
|
||||||
for (const id of [...queue.keys()]) {
|
|
||||||
const cb = queue.get(id)
|
|
||||||
if (!cb) continue
|
|
||||||
queue.delete(id)
|
|
||||||
cb(clock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sandbox = { console, Math, Date, JSON }
|
|
||||||
vm.createContext(sandbox)
|
|
||||||
vm.runInContext(code, sandbox, { filename: 'scene.bundle.js' })
|
|
||||||
const engine = sandbox.WKEngine
|
|
||||||
|
|
||||||
const fire = (type, event) => {
|
|
||||||
for (const fn of [...(listeners[type] ?? [])]) fn(event)
|
|
||||||
}
|
|
||||||
const makeEvent = (x, y, extra = {}) => ({
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
button: 0,
|
|
||||||
pointerId: 1,
|
|
||||||
ctrlKey: false,
|
|
||||||
deltaY: 0,
|
|
||||||
deltaMode: 0,
|
|
||||||
defaultPrevented: false,
|
|
||||||
preventDefault() {
|
|
||||||
this.defaultPrevented = true
|
|
||||||
},
|
|
||||||
stopPropagation() {},
|
|
||||||
...extra,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
mountTower: engine.mountTower,
|
|
||||||
engine,
|
|
||||||
canvas,
|
|
||||||
counts,
|
|
||||||
seenTexts,
|
|
||||||
rects,
|
|
||||||
texts,
|
|
||||||
listeners,
|
|
||||||
listenerOptions,
|
|
||||||
opts: (extra = {}) => ({ raf, caf, now, dpr, ...extra }),
|
|
||||||
tick,
|
|
||||||
jump(seconds) {
|
|
||||||
clock += seconds * 1000
|
|
||||||
},
|
|
||||||
rafPending: () => queue.size,
|
|
||||||
setCssWidth(w) {
|
|
||||||
width = w
|
|
||||||
},
|
|
||||||
click(cssX, cssY) {
|
|
||||||
fire('click', makeEvent(cssX, cssY))
|
|
||||||
},
|
|
||||||
pointer(type, cssX, cssY, extra = {}) {
|
|
||||||
fire(type, makeEvent(cssX, cssY, extra))
|
|
||||||
},
|
|
||||||
wheel(cssX, cssY, deltaY, { ctrlKey = false } = {}) {
|
|
||||||
const event = makeEvent(cssX, cssY, { deltaY, ctrlKey })
|
|
||||||
fire('wheel', event)
|
|
||||||
return event
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// Aufzeichnender Canvas-Kontext für die Engine-Tests (Muster: Proxy aus entwuerfe/smoke.js).
|
|
||||||
// fillRect/fillText werden mit dem aktuellen fillStyle/font notiert, alles Übrige ist ein No-op.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* makeCtx({ width, height }) → { ctx, rects, texts, strokes, counts, alphas }
|
|
||||||
* rects: [{ x, y, w, h, fill }] jedes fillRect
|
|
||||||
* texts: [{ text, x, y, font }] jedes fillText (Nicht-String wirft wie im Smoke-Test)
|
|
||||||
* strokes: [{ text, x, y, style, width, rects, texts }] jedes strokeText (style/width = strokeStyle/lineWidth, rects/texts =
|
|
||||||
* Anzahl der bis dahin gezeichneten fillRect/fillText: so ist die Reihenfolge prüfbar)
|
|
||||||
* counts: { save, restore } Aufrufe von ctx.save()/ctx.restore()
|
|
||||||
* alphas: [] jede Zuweisung an ctx.globalAlpha
|
|
||||||
*/
|
|
||||||
export function makeCtx({ width = 960, height = 768 } = {}) {
|
|
||||||
const rects = []
|
|
||||||
const texts = []
|
|
||||||
const strokes = []
|
|
||||||
const counts = { save: 0, restore: 0 }
|
|
||||||
const alphas = []
|
|
||||||
const target = { canvas: { width, height }, fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1 }
|
|
||||||
const ctx = new Proxy(target, {
|
|
||||||
get(t, prop) {
|
|
||||||
if (typeof prop === 'symbol') return undefined
|
|
||||||
if (prop === 'save') return () => { counts.save++ }
|
|
||||||
if (prop === 'restore') return () => { counts.restore++ }
|
|
||||||
if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 })
|
|
||||||
if (prop === 'fillRect') return (x, y, w, h) => { rects.push({ x, y, w, h, fill: t.fillStyle }) }
|
|
||||||
if (prop === 'fillText') {
|
|
||||||
return (text, x, y) => {
|
|
||||||
if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text)
|
|
||||||
texts.push({ text, x, y, font: t.font })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prop === 'strokeText') {
|
|
||||||
return (text, x, y) => {
|
|
||||||
if (typeof text !== 'string') throw new Error('strokeText mit Nicht-String: ' + text)
|
|
||||||
strokes.push({ text, x, y, style: t.strokeStyle, width: t.lineWidth, rects: rects.length, texts: texts.length })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} })
|
|
||||||
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) })
|
|
||||||
if (prop in t) return t[prop]
|
|
||||||
return () => {}
|
|
||||||
},
|
|
||||||
set(t, prop, value) {
|
|
||||||
if (prop === 'globalAlpha') alphas.push(value)
|
|
||||||
t[prop] = value
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { ctx, rects, texts, strokes, counts, alphas }
|
|
||||||
}
|
|
||||||
@@ -214,7 +214,8 @@ test('schrift.css bindet Press Start 2P selbst gehostet ein (Datei vorhanden, OF
|
|||||||
// lateinischer Schnitt aus @fontsource/press-start-2p@5.2.7 (files/press-start-2p-latin-400-normal.woff2): Ziffern, Umlaute, ß, € (kein U+2212)
|
// lateinischer Schnitt aus @fontsource/press-start-2p@5.2.7 (files/press-start-2p-latin-400-normal.woff2): Ziffern, Umlaute, ß, € (kein U+2212)
|
||||||
assert.equal(createHash('sha256').update(readFileSync(datei)).digest('hex'), 'afec86997fdaf54af1f59358fa2c1e2a0f1d04146edad18e5cd141d0384a7548')
|
assert.equal(createHash('sha256').update(readFileSync(datei)).digest('hex'), 'afec86997fdaf54af1f59358fa2c1e2a0f1d04146edad18e5cd141d0384a7548')
|
||||||
assert.ok(!/googleapis|gstatic/.test(schrift))
|
assert.ok(!/googleapis|gstatic/.test(schrift))
|
||||||
assert.ok(!/Pixelify/i.test(schrift), 'Pixelify Sans ist schon in workers.css eingebunden, nicht doppelt')
|
// Seit T11 steht auch Pixelify Sans hier (nicht mehr in workers.css): genau eine Einbindung; Datei und Lizenz prüft workersFont.test.mjs
|
||||||
|
assert.equal((schrift.match(/font-family:\s*["']Pixelify Sans["']/g) ?? []).length, 1, 'Pixelify Sans genau einmal')
|
||||||
const lizenz = lies(path.join(REPO, 'public/fonts/OFL-PressStart2P.txt'))
|
const lizenz = lies(path.join(REPO, 'public/fonts/OFL-PressStart2P.txt'))
|
||||||
assert.match(lizenz, /^Copyright 2012 The Press Start 2P Project Authors \(cody@zone38\.net\), with Reserved Font Name "Press Start 2P"\.?$/m)
|
assert.match(lizenz, /^Copyright 2012 The Press Start 2P Project Authors \(cody@zone38\.net\), with Reserved Font Name "Press Start 2P"\.?$/m)
|
||||||
assert.match(lizenz, /SIL OPEN FONT LICENSE Version 1\.1/)
|
assert.match(lizenz, /SIL OPEN FONT LICENSE Version 1\.1/)
|
||||||
|
|||||||
@@ -621,6 +621,19 @@ test('Komponente: kein innerHTML, kein CSS- oder Bild-Import, Links nur über sa
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Übernommen aus der Prüfung der früheren Worker-Karte (Review Focus 5, PF-08): Ziele nur aus dem safeLink-Ergebnis, extern mit noopener, jeder Knopf mit type.
|
||||||
|
test('Komponente: jedes Link-Ziel (href, to) kommt aus dem safeLink-Ergebnis, externe Links mit rel noopener, jeder Knopf type="button"', () => {
|
||||||
|
const quelle = fs.readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HustlerDialog.jsx'), 'utf8')
|
||||||
|
const src = quelle.replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||||
|
assert.match(src, /safeLink\(/)
|
||||||
|
assert.match(src, /<a href=\{safe\.href\} target="_blank" rel="noopener noreferrer">/); assert.match(src, /<Link to=\{safe\.href\}>/)
|
||||||
|
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/) // kein anderes Ziel, nie aus dem Ereignis selbst
|
||||||
|
assert.equal((src.match(/<a\b/g) || []).length, 1); assert.equal((src.match(/<Link\b/g) || []).length, 1)
|
||||||
|
const knoepfe = (src.match(/<button\b/g) || []).length
|
||||||
|
assert.ok(knoepfe >= 4, 'Hintergrund, Schließen, ◂ und ▸')
|
||||||
|
assert.equal((src.match(/<button type="button"/g) || []).length, knoepfe)
|
||||||
|
})
|
||||||
|
|
||||||
test('hustler-detail.css: Kopfkommentar, alle Regeln unter .hk-hochhaus, alle Klassen mit hk-, Handy-Regeln unter 720 px', () => {
|
test('hustler-detail.css: Kopfkommentar, alle Regeln unter .hk-hochhaus, alle Klassen mit hk-, Handy-Regeln unter 720 px', () => {
|
||||||
const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8')
|
const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8')
|
||||||
assert.ok(css.trimStart().startsWith('/*'), 'Kopfkommentar')
|
assert.ok(css.trimStart().startsWith('/*'), 'Kopfkommentar')
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ async function hochhaus(feed, extra = {}) {
|
|||||||
const props = { api: { name: 'api' }, isAdmin: false, workers: {}, regieRef: { current: null }, onOpen() {}, ...extra, feed }
|
const props = { api: { name: 'api' }, isAdmin: false, workers: {}, regieRef: { current: null }, onOpen() {}, ...extra, feed }
|
||||||
const mounted = __mount(jsx(() => jsx(Hochhaus, props), {}))
|
const mounted = __mount(jsx(() => jsx(Hochhaus, props), {}))
|
||||||
await settle()
|
await settle()
|
||||||
return { props, hh: globalThis.__hh, tree: () => mounted.tree(), end: () => mounted.unmount() }
|
// setFeed: der Feed ändert sich zwischendurch (wie der Hook der Seite), das Hochhaus bleibt am selben Einhängepunkt
|
||||||
|
return { props, hh: globalThis.__hh, tree: () => mounted.tree(), setFeed(next) { props.feed = { ...props.feed, ...next }; mounted.rerender() }, end: () => mounted.unmount() }
|
||||||
}
|
}
|
||||||
const counting = (n = { refresh: 0 }) => ({ n, refresh: () => { n.refresh += 1 } })
|
const counting = (n = { refresh: 0 }) => ({ n, refresh: () => { n.refresh += 1 } })
|
||||||
const teil = (tree, name) => nodes(tree, (n) => n.props && n.props['data-teil'] === name)
|
const teil = (tree, name) => nodes(tree, (n) => n.props && n.props['data-teil'] === name)
|
||||||
@@ -174,6 +175,28 @@ test('Hochhaus: ohne Zustand gilt auch ein geladen-Flag nicht als bereit (wie bi
|
|||||||
} finally { view.end() }
|
} finally { view.end() }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Frühere Prüfung des Boards (Hooks vor der frühen Rückgabe): Laden, Leerzustand und Welt wechseln am selben Einhängepunkt, die Auswahl überlebt Verbindungsfehler.
|
||||||
|
test('Hochhaus: Laden -> Dienst nicht erreicht -> bereit -> Verbindungsfehler -> wieder gut, ohne Neueinhängen; die offene Detailansicht überlebt den Fehler', async () => {
|
||||||
|
const c = counting()
|
||||||
|
const view = await hochhaus({ state: null, error: null, loaded: false, refresh: c.refresh })
|
||||||
|
try {
|
||||||
|
assert.equal(byClass(view.tree(), 'hk-laden').length, 1)
|
||||||
|
view.setFeed({ error: 'weg' })
|
||||||
|
assert.equal(byClass(view.tree(), 'hk-leer').length, 1); assert.equal(byClass(view.tree(), 'hk-laden').length, 0)
|
||||||
|
view.setFeed({ state: makeState(), error: null, loaded: true })
|
||||||
|
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(byClass(view.tree(), 'hk-leer').length, 0)
|
||||||
|
view.hh.welt.onZeige('lea')
|
||||||
|
await settle()
|
||||||
|
assert.equal(teil(view.tree(), 'dialog').length, 1)
|
||||||
|
view.setFeed({ error: 'Verbindung weg' })
|
||||||
|
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(teil(view.tree(), 'dialog').length, 1)
|
||||||
|
assert.equal(view.hh.dialog.workerId, 'lea'); assert.equal(view.hh.welt.gesperrt, true)
|
||||||
|
view.setFeed({ error: null })
|
||||||
|
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(view.hh.dialog.workerId, 'lea')
|
||||||
|
assert.equal(c.n.refresh, 0)
|
||||||
|
} finally { view.end() }
|
||||||
|
})
|
||||||
|
|
||||||
// ---------- WorkersPage ----------
|
// ---------- WorkersPage ----------
|
||||||
|
|
||||||
// Attrappen: alle Zustände und Aufrufe laufen über globalThis.__hk, das der Test je Lauf anlegt
|
// Attrappen: alle Zustände und Aufrufe laufen über globalThis.__hk, das der Test je Lauf anlegt
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
|
|
||||||
// Kamera: Zoom, Verschieben, Umkehr-Transformation, Figuren-Treffer (Ansicht = Welt/Zoom, Bruchteile 0..1)
|
|
||||||
const C = await loadModule('src/components/workers/engine/camera.js')
|
|
||||||
const close = (a, b) => Math.abs(a - b) < 1e-9
|
|
||||||
|
|
||||||
test('fitZoom: ab 560 px 1, darunter wachsend bis 2,2', () => {
|
|
||||||
assert.equal(C.fitZoom(1280), 1)
|
|
||||||
assert.equal(C.fitZoom(600), 1)
|
|
||||||
assert.equal(C.fitZoom(560), 1)
|
|
||||||
assert.ok(close(C.fitZoom(390), 560 / 390))
|
|
||||||
assert.equal(C.fitZoom(200), 2.2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('fitZoom: ungültige Breite gibt 1, nie NaN', () => {
|
|
||||||
for (const w of [0, -5, NaN, undefined, null, Infinity]) assert.equal(C.fitZoom(w), 1, String(w))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Zoomgrenzen und Startkamera', () => {
|
|
||||||
assert.deepEqual([C.ZOOM_MIN, C.ZOOM_MAX], [1, 3])
|
|
||||||
assert.deepEqual(C.createCamera(), { zoom: 1, x: 0, y: 0 })
|
|
||||||
assert.notEqual(C.createCamera(), C.createCamera())
|
|
||||||
})
|
|
||||||
|
|
||||||
test('zoomAt hält den Punkt unter dem Zeiger fest, klemmt Zoom und Rand', () => {
|
|
||||||
const p0 = C.viewToWorld(C.createCamera(), 0.25, 0.75)
|
|
||||||
const cam = C.zoomAt(C.createCamera(), 2, 0.25, 0.75)
|
|
||||||
assert.equal(cam.zoom, 2)
|
|
||||||
const p1 = C.viewToWorld(cam, 0.25, 0.75)
|
|
||||||
assert.ok(Math.abs(p1.x - p0.x) < 1e-9 && Math.abs(p1.y - p0.y) < 1e-9)
|
|
||||||
assert.equal(C.zoomAt(C.createCamera(), 10, 0.5, 0.5).zoom, 3)
|
|
||||||
assert.equal(C.zoomAt(C.createCamera(), 0.1, 0.5, 0.5).zoom, 1)
|
|
||||||
const edge = C.panBy({ zoom: 2, x: 0, y: 0 }, 5, 5)
|
|
||||||
assert.deepEqual([edge.x, edge.y], [160, 128])
|
|
||||||
const back = C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5)
|
|
||||||
assert.deepEqual([back.x, back.y], [0, 0])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('viewToWorld und worldToView sind zueinander invers; centerOn', () => {
|
|
||||||
const cam = { zoom: 2.5, x: 40, y: 60 }
|
|
||||||
const w = C.viewToWorld(cam, 0.3, 0.6)
|
|
||||||
const v = C.worldToView(cam, w.x, w.y)
|
|
||||||
assert.ok(Math.abs(v.vx - 0.3) < 1e-9 && Math.abs(v.vy - 0.6) < 1e-9)
|
|
||||||
const c = C.centerOn({ zoom: 2, x: 0, y: 0 }, 160, 128)
|
|
||||||
assert.deepEqual([c.x, c.y], [80, 64])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pickAgent: oberste Figur gewinnt, Polster vergrößert die Fläche, Treffer nur im Kasten', () => {
|
|
||||||
const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'b', box: { x: 16, y: 10, w: 12, h: 16 } }]
|
|
||||||
assert.equal(C.pickAgent(boxes, 18, 14), 'b')
|
|
||||||
assert.equal(C.pickAgent(boxes, 11, 14), 'a')
|
|
||||||
assert.equal(C.pickAgent(boxes, 40, 14), null)
|
|
||||||
assert.equal(C.pickAgent(boxes, 30, 14, 0), null)
|
|
||||||
assert.equal(C.pickAgent(boxes, 30, 14, 3), 'b')
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Ergänzungen: Verhalten, das die Tests oben nicht berühren ----
|
|
||||||
|
|
||||||
test('Sichtfeld ist Welt/Zoom: bei Zoom 1 gibt es nichts zu verschieben', () => {
|
|
||||||
const cam = C.panBy(C.createCamera(), 0.4, -0.3)
|
|
||||||
assert.deepEqual([cam.zoom, cam.x, cam.y], [1, 0, 0])
|
|
||||||
assert.deepEqual(C.viewToWorld(C.createCamera(), 1, 1), { x: 320, y: 256 })
|
|
||||||
assert.deepEqual(C.viewToWorld({ zoom: 2, x: 160, y: 128 }, 1, 1), { x: 320, y: 256 })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('panBy verschiebt in Bruchteilen der Ansicht und klemmt am Rand', () => {
|
|
||||||
const cam = C.panBy({ zoom: 2, x: 40, y: 30 }, 0.25, 0.5)
|
|
||||||
assert.deepEqual([cam.x, cam.y], [80, 94])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('zoomAt zurück auf 1 setzt die Kamera auf den Ursprung zurück (Rand wird geklemmt)', () => {
|
|
||||||
const cam = C.zoomAt({ zoom: 3, x: 100, y: 80 }, 0.01, 0.5, 0.5)
|
|
||||||
assert.deepEqual([cam.zoom, cam.x, cam.y], [1, 0, 0])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('zoomAt am Rand: der Punkt unter dem Zeiger bleibt, solange der Rand nicht klemmt', () => {
|
|
||||||
const start = { zoom: 2, x: 80, y: 64 }
|
|
||||||
const p0 = C.viewToWorld(start, 0.6, 0.4)
|
|
||||||
const cam = C.zoomAt({ ...start }, 1.5, 0.6, 0.4)
|
|
||||||
assert.equal(cam.zoom, 3)
|
|
||||||
const p1 = C.viewToWorld(cam, 0.6, 0.4)
|
|
||||||
assert.ok(close(p1.x, p0.x) && close(p1.y, p0.y))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('clampCamera klemmt Zoom und Lage, auch mit eigener Welt; Aufrufer bekommt dieselbe Kamera zurück', () => {
|
|
||||||
const cam = { zoom: 5, x: -10, y: 900 }
|
|
||||||
assert.equal(C.clampCamera(cam), cam)
|
|
||||||
assert.deepEqual([cam.zoom, cam.x, cam.y], [3, 0, 256 - 256 / 3])
|
|
||||||
const klein = C.clampCamera({ zoom: 2, x: 99, y: 99 }, { w: 100, h: 50 })
|
|
||||||
assert.deepEqual([klein.x, klein.y], [50, 25])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('centerOn klemmt in den Ecken', () => {
|
|
||||||
const c = C.centerOn({ zoom: 2, x: 80, y: 64 }, 0, 0)
|
|
||||||
assert.deepEqual([c.x, c.y], [0, 0])
|
|
||||||
const d = C.centerOn({ zoom: 2, x: 0, y: 0 }, 320, 256)
|
|
||||||
assert.deepEqual([d.x, d.y], [160, 128])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ungültige Eingaben (NaN, Infinity) machen die Kamera nie kaputt', () => {
|
|
||||||
const a = C.zoomAt({ zoom: 2, x: 20, y: 10 }, NaN, 0.5, 0.5)
|
|
||||||
assert.deepEqual([a.zoom, a.x, a.y], [2, 20, 10])
|
|
||||||
const b = C.panBy({ zoom: 2, x: 20, y: 10 }, NaN, Infinity)
|
|
||||||
assert.ok(Number.isFinite(b.x) && Number.isFinite(b.y))
|
|
||||||
assert.equal(b.x, 20)
|
|
||||||
const c = C.clampCamera({ zoom: NaN, x: NaN, y: NaN })
|
|
||||||
assert.deepEqual([c.zoom, c.x, c.y], [1, 0, 0])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pickAgent: leere Liste, kaputte Einträge und Rand des Kastens', () => {
|
|
||||||
assert.equal(C.pickAgent([], 5, 5), null)
|
|
||||||
assert.equal(C.pickAgent(undefined, 5, 5), null)
|
|
||||||
const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'kaputt' }, null]
|
|
||||||
assert.equal(C.pickAgent(boxes, 22, 26), 'a')
|
|
||||||
assert.equal(C.pickAgent(boxes, 23, 26), null)
|
|
||||||
assert.equal(C.pickAgent(boxes, 23, 26, 1), 'a')
|
|
||||||
})
|
|
||||||
@@ -1,29 +1,141 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
// Die React-Teile der Hustler-Seite sind unter Node nicht ladbar (JSX, Canvas, Router): geprüft wird der Quelltext
|
// Regeln für alle React-Teile der Hustler-Seite, geprüft am Quelltext (Muster adminRoute.test.mjs): src/components/workers/** mit hochhaus/, pult/ und keller/.
|
||||||
// (Muster adminRoute.test.mjs). Pfade relativ zu src/components/workers/.
|
// Das Verhalten der einzelnen Bildschirme prüfen die Komponententests (hustlerKopf, hustlerDialog, hustlerHochhausWelt, hustlerSeite, hustlerPult, hustlerKeller ...).
|
||||||
const read = (p) => readFileSync(new URL('../../src/components/workers/' + p, import.meta.url), 'utf8')
|
// Seit T11 gibt es die alte helle Oberfläche und die Kanvas-Engine nicht mehr; ihre Zusicherungen sind in diese Tests gewandert (Tabelle im Übergabebericht von T11).
|
||||||
|
const COMPONENTS = fileURLToPath(new URL('../../src/components/workers/', import.meta.url))
|
||||||
|
const SRC = fileURLToPath(new URL('../../src/', import.meta.url))
|
||||||
|
const read = (p) => readFileSync(path.join(COMPONENTS, p), 'utf8')
|
||||||
|
const readSrc = (p) => readFileSync(path.join(SRC, p), 'utf8')
|
||||||
|
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
|
||||||
|
const komponenten = () => walk(COMPONENTS).filter((f) => /\.(jsx?|mjs)$/.test(f))
|
||||||
|
const rel = (f) => path.relative(COMPONENTS, f)
|
||||||
|
// Kommentare weg: Block- und Zeilenkommentare (// nur am Zeilenanfang oder nach Leerraum, damit https:// in Zeichenketten bleibt)
|
||||||
|
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||||
|
|
||||||
test('WorkerCard: Links nur über safeLink, extern mit rel noopener; deutsche Beschriftungen (Review Focus 5)', () => {
|
// Öffnende Tags `<name …>` samt Attributen. Klammern {…} und Zeichenketten werden übersprungen, damit das `=>` eines onClick das Tag nicht beendet.
|
||||||
const src = read('WorkerCard.jsx')
|
function oeffnendeTags(src, name) {
|
||||||
assert.match(src, /safeLink\(/); assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
|
const tags = []
|
||||||
for (const t of ['Macht gerade', 'Tokens heute', 'Kosten heute', 'Letzte Aktionen']) assert.ok(src.includes(t), t)
|
const re = new RegExp(`<${name}(?=[\\s/>])`, 'g')
|
||||||
assert.match(src, /MODEL_IDS/); assert.match(src, /wk-chip-/)
|
for (let m; (m = re.exec(src)) !== null;) {
|
||||||
// jeder href und jedes to kommt aus dem safeLink-Ergebnis, nie aus dem Ereignis selbst
|
let tiefe = 0
|
||||||
assert.match(src, /href=\{safe\.href\}/); assert.match(src, /to=\{safe\.href\}/)
|
let quote = ''
|
||||||
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/)
|
let i = m.index + m[0].length
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
const c = src[i]
|
||||||
|
if (quote) {
|
||||||
|
if (c === '\\') i += 1
|
||||||
|
else if (c === quote) quote = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === '`') quote = c
|
||||||
|
else if (c === '{') tiefe += 1
|
||||||
|
else if (c === '}') tiefe -= 1
|
||||||
|
else if (c === '>' && tiefe === 0) break
|
||||||
|
}
|
||||||
|
tags.push(src.slice(m.index, i + 1))
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wert eines Attributs im Tag: Zeichenkette (mit Anführungszeichen) oder der Ausdruck in {…} ohne Klammern; null, wenn es das Attribut nicht gibt
|
||||||
|
function attribut(tag, name) {
|
||||||
|
const m = new RegExp(`(?<![\\w-])${name}=`).exec(tag)
|
||||||
|
if (!m) return null
|
||||||
|
const start = m.index + m[0].length
|
||||||
|
if (tag[start] === '"') return tag.slice(start, tag.indexOf('"', start + 1) + 1)
|
||||||
|
if (tag[start] !== '{') return null
|
||||||
|
let tiefe = 0
|
||||||
|
for (let i = start; i < tag.length; i++) {
|
||||||
|
if (tag[i] === '{') tiefe += 1
|
||||||
|
else if (tag[i] === '}') {
|
||||||
|
tiefe -= 1
|
||||||
|
if (tiefe === 0) return tag.slice(start + 1, i).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
test('kein innerHTML und keine Kanvas-Zeichnung in den Komponenten unter src/components/workers/** (rekursiv, Kommentare ignoriert)', () => {
|
||||||
|
const dateien = komponenten()
|
||||||
|
for (const dir of ['hochhaus', 'pult', 'keller']) assert.ok(dateien.some((f) => rel(f).startsWith(dir + path.sep)), `${dir}/ wird mitgelesen`)
|
||||||
|
assert.ok(dateien.length >= 15, `nur ${dateien.length} Dateien gelesen`)
|
||||||
|
for (const f of dateien) {
|
||||||
|
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
||||||
|
assert.doesNotMatch(code, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write/, rel(f))
|
||||||
|
assert.doesNotMatch(code, /<canvas\b|getContext\s*\(/, `${rel(f)}: die Pixelwelt ist DOM + CSS, kein Kanvas`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
test('kein innerHTML in Komponenten und Engine', () => {
|
|
||||||
for (const dir of ['', 'engine/']) for (const f of readdirSync(new URL('../../src/components/workers/' + dir, import.meta.url))) { if (!/\.(jsx|js)$/.test(f)) continue; const s = read(dir + f); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/, dir + f) }
|
// Frühere Prüfung des Boards: Rechte und Nutzer kommen als Props von der Seite (isAdmin, me), die Bildschirme greifen nie selbst auf Anmeldung oder Appwrite zu.
|
||||||
|
test('Komponenten ohne Anmeldungsbezug: kein useAuth und kein appwrite unter src/components/workers/**', () => {
|
||||||
|
for (const f of komponenten()) assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), /useAuth|appwrite/i, rel(f))
|
||||||
})
|
})
|
||||||
test('WorkersKpis nutzt Stat und kpiTiles; WorkerList: Knopf je Worker mit data-wk-worker, aria-pressed und Statustext', () => {
|
|
||||||
assert.match(read('WorkersKpis.jsx'), /kpiTiles\(/); assert.match(read('WorkersKpis.jsx'), /<Stat /)
|
test('die alte Oberfläche und die Kanvas-Engine sind weg, und nichts unter src/ verweist noch darauf (T11)', () => {
|
||||||
const l = read('WorkerList.jsx'); assert.match(l, /data-wk-worker=/); assert.match(l, /aria-pressed=/); assert.match(l, /aria-label=/); assert.match(l, /FLOOR_TITLES/)
|
for (const alt of ['engine', 'WorkersStage.jsx', 'WorkersBoard.jsx', 'WorkerCard.jsx', 'WorkerList.jsx', 'WorkersKpis.jsx']) assert.equal(existsSync(path.join(COMPONENTS, alt)), false, alt)
|
||||||
// die Liste enthält keine Links: ein Klick wählt nur aus
|
const verweis = /['"][^'"\n]*(?:\/engine\/[\w.-]+|\/(?:WorkersStage|WorkersBoard|WorkerCard|WorkerList|WorkersKpis))(?:\.jsx?)?['"]/
|
||||||
assert.doesNotMatch(l, /<a[\s>]|<Link[\s>]|href=/)
|
let gelesen = 0
|
||||||
|
for (const f of walk(SRC).filter((x) => /\.jsx?$/.test(x))) {
|
||||||
|
gelesen += 1
|
||||||
|
assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), verweis, path.relative(SRC, f))
|
||||||
|
}
|
||||||
|
assert.ok(gelesen > 100, `nur ${gelesen} Dateien gelesen`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Frühere Prüfung der Bühne und des Boards (PF-08): der WOMS-Button hat keinen Standard-type, ein <button> ohne type schickt in einem Formular ab.
|
||||||
|
test('jeder Knopf (<button>, <Button>) hat ein ausdrückliches type="button"; „submit“ gibt es nur im Formular der Einstellungen', () => {
|
||||||
|
let anzahl = 0
|
||||||
|
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
|
||||||
|
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
||||||
|
for (const name of ['button', 'Button']) {
|
||||||
|
const tags = oeffnendeTags(code, name)
|
||||||
|
assert.equal(tags.length, (code.match(new RegExp(`<${name}(?=[\\s/>])`, 'g')) || []).length, `${rel(f)}: ${name}-Tags vollständig gelesen`)
|
||||||
|
for (const tag of tags) {
|
||||||
|
anzahl += 1
|
||||||
|
const erlaubt = rel(f) === 'WorkersSettingsDialog.jsx' ? /\btype="(?:button|submit)"/ : /\btype="button"/
|
||||||
|
assert.match(tag, erlaubt, `${rel(f)}: ${tag.replace(/\s+/g, ' ').slice(0, 90)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(anzahl >= 35, `nur ${anzahl} Knöpfe gelesen`)
|
||||||
|
// „submit“ kommt genau einmal vor: „Speichern“ im Formular
|
||||||
|
assert.equal((ohneKommentare(read('WorkersSettingsDialog.jsx')).match(/type="submit"/g) || []).length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Frühere Prüfung der Worker-Karte (Review Focus 5): Ziele von Links kommen nur aus geprüften Feldern, externe öffnen mit noopener.
|
||||||
|
test('Links: jedes Ziel (href, to) kommt aus einem geprüften Feld oder ist fest; externe Ziele öffnen im neuen Tab mit rel="noopener noreferrer"', () => {
|
||||||
|
// safeLink (Spur im Dialog) · sourceHref/previewHref (workersPult, botleadsView, workersProspect über safeExternalUrl/safeHref/previewUrl) · mailtoHref · feste Pfade der Etagen
|
||||||
|
const ERLAUBT = new Set(['safe.href', 'row.sourceHref', 'row.previewHref', 'view.sourceHref', 'e.sourceHref', 'mail', '`/hustler?tab=${link.tab}`'])
|
||||||
|
let links = 0
|
||||||
|
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
|
||||||
|
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
||||||
|
for (const [name, ziel] of [['a', 'href'], ['Link', 'to']]) {
|
||||||
|
for (const tag of oeffnendeTags(code, name)) {
|
||||||
|
links += 1
|
||||||
|
const wert = attribut(tag, ziel)
|
||||||
|
assert.ok(wert !== null, `${rel(f)}: ${name} ohne ${ziel}: ${tag.slice(0, 80)}`)
|
||||||
|
assert.ok(wert.startsWith('"') || ERLAUBT.has(wert), `${rel(f)}: ${ziel}={${wert}} ist kein geprüftes Ziel`)
|
||||||
|
if (attribut(tag, 'target') === '"_blank"') assert.equal(attribut(tag, 'rel'), '"noopener noreferrer"', `${rel(f)}: ${tag.slice(0, 80)}`)
|
||||||
|
if (wert.startsWith('"')) assert.doesNotMatch(wert, /javascript:|data:/i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(links >= 8, `nur ${links} Links gelesen`)
|
||||||
|
// die Felder selbst entstehen in den Ansichten nur über die Prüfer
|
||||||
|
assert.match(readSrc('lib/workersPult.js'), /sourceHref: freigabe \? null : safeExternalUrl\(item\.sourceUrl\)/)
|
||||||
|
assert.match(readSrc('lib/workersPult.js'), /previewHref: freigabe \? previewUrl\(item\.sourceUrl\) : null/)
|
||||||
|
assert.match(readSrc('lib/workersProspect.js'), /sourceHref: safeExternalUrl\(prospect\.sourceUrl\)/)
|
||||||
|
assert.match(readSrc('lib/botleadsView.js'), /sourceHref: safeHref\(it\.sourceUrl\)/)
|
||||||
|
assert.match(readSrc('lib/workersPult.js'), /return `mailto:\$\{to\}/)
|
||||||
|
// die Etagen-Links des Hochhauses sind feste Pfade
|
||||||
|
assert.match(read('hochhaus/HochhausWelt.jsx'), /tab: 'freigabe'[\s\S]*tab: 'botleads'/)
|
||||||
|
})
|
||||||
|
|
||||||
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
|
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
|
||||||
const s = read('WorkersSettingsDialog.jsx')
|
const s = read('WorkersSettingsDialog.jsx')
|
||||||
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
|
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
|
||||||
@@ -35,57 +147,8 @@ test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Be
|
|||||||
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
|
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
|
||||||
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
|
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
|
||||||
})
|
})
|
||||||
test('WorkersStage: Aufräumen, Sichtbarkeit, Bewegungsreduktion', () => {
|
|
||||||
const s = read('WorkersStage.jsx')
|
|
||||||
assert.match(s, /\.destroy\(\)/); assert.match(s, /visibilitychange/); assert.match(s, /removeEventListener\('visibilitychange'/); assert.match(s, /prefers-reduced-motion: reduce/)
|
|
||||||
assert.match(s, /ResizeObserver/); assert.match(s, /disconnect\(\)/); assert.match(s, /fontsReady/); assert.match(s, /forwardRef/)
|
|
||||||
// Controller-Beschluss: Sichtbarkeit steuert die Szene (setPaused), beim Zurückkehren wird die Regie beruhigt (flush)
|
|
||||||
assert.match(s, /setPaused\(/); assert.match(s, /\.flush\(\)/); assert.match(s, /mountTower\(/); assert.match(s, /engineKey/)
|
|
||||||
assert.match(s, /className="wk-stage"/); assert.match(s, /className="wk-canvas"/); assert.match(s, /className="wk-zoom"/)
|
|
||||||
assert.ok(s.includes('Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste.'))
|
|
||||||
for (const t of ['+', '−', 'Ganz']) assert.ok(s.includes(`>${t}<`), t)
|
|
||||||
// jeder Knopf der Bühne ist ein type="button" (WOMS-Button hat keinen Standard-type, PF-08)
|
|
||||||
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
|
||||||
})
|
|
||||||
test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => {
|
|
||||||
const s = read('WorkersBoard.jsx')
|
|
||||||
assert.match(s, /Der Hustler-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/)
|
|
||||||
assert.match(s, /isAdmin &&/); assert.match(s, /stageRef\s*(\?\?|\|\|)/); assert.match(s, /useRef\(/); assert.doesNotMatch(s, /useAuth|appwrite/)
|
|
||||||
// H4/A21: Notbremsen-Zeile für alle, Grund als Textknoten
|
|
||||||
assert.match(s, /state\.sendingHalted/); assert.match(s, /state\.sendingHaltedReason/); assert.match(s, /className="msg err"/); assert.match(s, /role="alert"/)
|
|
||||||
assert.ok(s.includes('Versand gestoppt: ')); assert.ok(s.includes('Ein Admin kann ihn in den Einstellungen freigeben.')); assert.ok(s.includes('Grund nicht angegeben'))
|
|
||||||
assert.match(s, /onSaved=\{/); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
|
|
||||||
// Controller-Beschluss T19: Tabelle nach workerId an Karte und Liste, Karte bekommt Eintrag und Spur, Zustand kommt aus indexWorkers
|
|
||||||
assert.match(s, /indexWorkers\(/); assert.match(s, /useWorkersFeed\(/); assert.match(s, /useWorkerTrace\(/); assert.match(s, /api\.pause\(/)
|
|
||||||
assert.match(s, /<WorkerCard\b[^>]*\bworker=\{/); assert.match(s, /<WorkerCard\b[^>]*\btrace=\{/); assert.match(s, /<WorkerList\b[^>]*\bworkers=\{workers\}/); assert.match(s, /<WorkersKpis\b/)
|
|
||||||
// Links entstehen nur in der Karte über safeLink, das Board hat keine
|
|
||||||
assert.doesNotMatch(s, /href=|<a[\s>]|<Link[\s>]/)
|
|
||||||
assert.match(s, /className="wk-toolbar"/); assert.match(s, /className="wk-main"/); assert.match(s, /className="loading"/)
|
|
||||||
assert.ok(s.includes('Angehalten: Es wird nichts gesendet und nichts gebaut.')); assert.ok(s.includes('Neu laden')); assert.ok(s.includes('Grund nicht angegeben'))
|
|
||||||
// PF-08: jeder Knopf ein type="button" (WOMS-Button hat keinen Standard-type)
|
|
||||||
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
|
||||||
})
|
|
||||||
test('Karte und Board: Auswahl setzt den Fokus auf den Namen und holt die Karte ins Bild, Schließen gibt den Fokus zurück (Endprüfung P2, Handy/Tablet)', () => {
|
|
||||||
const card = read('WorkerCard.jsx')
|
|
||||||
// Namen fokussierbar machen (tabIndex -1), Fokus ohne eigenes Springen, danach gezielt an den oberen Rand holen, wenn der Kartenanfang nicht im Fenster liegt
|
|
||||||
assert.match(card, /<h2 className="wk-name" ref=\{headingRef\} tabIndex=\{-1\}>/)
|
|
||||||
assert.match(card, /ref=\{sectionRef\}/)
|
|
||||||
assert.match(card, /heading\.focus\(\{ preventScroll: true \}\)/)
|
|
||||||
assert.match(card, /section\.scrollIntoView\(\{ block: 'start' \}\)/)
|
|
||||||
assert.match(card, /scrollMarginTop/)
|
|
||||||
assert.match(card, /\}, \[workerId\]\)/)
|
|
||||||
// Hooks stehen vor der frühen Rückgabe ohne Auswahl (Regeln der Hooks)
|
|
||||||
assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') > 0)
|
|
||||||
assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') < card.indexOf(' if (!workerId) {\n return ('))
|
|
||||||
const board = read('WorkersBoard.jsx')
|
|
||||||
assert.match(board, /const sideRef = useRef\(null\)/); assert.match(board, /<div className="wk-side" ref=\{sideRef\}>/)
|
|
||||||
assert.match(board, /onClose=\{closeCard\}/); assert.match(board, /data-wk-worker/); assert.match(board, /button\.focus\(\)/)
|
|
||||||
assert.doesNotMatch(board, /onClose=\{\(\) => setSelectedId\(null\)\}/)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- P3-T21: Abmelden setzt das JWT zurück, „Alles anhalten“ fragt vorher nach ----
|
// ---- P3-T21: Abmelden setzt das JWT zurück ----
|
||||||
|
|
||||||
const readSrc = (p) => readFileSync(new URL('../../src/' + p, import.meta.url), 'utf8')
|
|
||||||
|
|
||||||
test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-Cache und Sperre zurück (P3-T21)', () => {
|
test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-Cache und Sperre zurück (P3-T21)', () => {
|
||||||
const auth = readSrc('context/AuthContext.jsx')
|
const auth = readSrc('context/AuthContext.jsx')
|
||||||
@@ -101,50 +164,3 @@ test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-
|
|||||||
const fn = client.slice(client.indexOf('export function resetWorkersJwt()'))
|
const fn = client.slice(client.indexOf('export function resetWorkersJwt()'))
|
||||||
assert.match(fn, /jwtCache\.invalidate\(\)/); assert.match(fn, /workersApi\.resetAuth\(\)/)
|
assert.match(fn, /jwtCache\.invalidate\(\)/); assert.match(fn, /workersApi\.resetAuth\(\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('WorkersBoard: „Alles anhalten“ öffnet erst einen Bestätigungsdialog, „Fortsetzen“ geht sofort (P3-T21)', () => {
|
|
||||||
const s = read('WorkersBoard.jsx')
|
|
||||||
assert.match(s, /import Dialog from '\.\.\/ui\/Dialog'/)
|
|
||||||
assert.match(s, /<Dialog\b[^>]*title="Alles anhalten\?"/)
|
|
||||||
assert.ok(s.includes('Die Worker hören sofort auf, bis jemand fortsetzt.'))
|
|
||||||
// beide Knöpfe des Dialogs: type="button", Beschriftung „Anhalten“ und „Abbrechen“
|
|
||||||
assert.match(s, /<Button type="button" onClick=\{[^}]*\}[^>]*>Abbrechen<\/Button>/)
|
|
||||||
assert.match(s, /<Button variant="primary" type="button" onClick=\{confirmHalt\}[^>]*>Anhalten<\/Button>/)
|
|
||||||
// Der Knopf der Leiste fragt nur beim Anhalten nach; Fortsetzen ruft den Dienst ohne Dialog
|
|
||||||
assert.match(s, /<Button type="button" onClick=\{requestPause\}/)
|
|
||||||
assert.match(s, /const requestPause = \(\) => \{\s*if \(paused\) setPause\(false\)\s*else setConfirmOpen\(true\)\s*\}/)
|
|
||||||
// Bestätigen schließt den Dialog und hält an; der Zielwert steht fest (nicht !paused: sonst setzte ein veralteter Dialog die Arbeit wieder fort)
|
|
||||||
assert.match(s, /const confirmHalt = \(\) => \{\s*setConfirmOpen\(false\)\s*setPause\(true\)\s*\}/)
|
|
||||||
assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/)
|
|
||||||
// der Dialog steht nur offen, solange nicht schon angehalten ist (hat jemand anderes angehalten, entfällt die Frage)
|
|
||||||
assert.match(s, /\{confirmOpen && !paused && \(/)
|
|
||||||
// und die Frage taucht nach einem späteren Fortsetzen nicht von selbst wieder auf
|
|
||||||
assert.match(s, /useEffect\(\(\) => \{\s*if \(haltedByState\) setConfirmOpen\(false\)\s*\}, \[haltedByState\]\)/)
|
|
||||||
// Hooks vor der frühen Rückgabe
|
|
||||||
assert.ok(s.indexOf('useState(false)') < s.indexOf('if (!loaded || !state)') && s.indexOf('const [confirmOpen') < s.indexOf('if (!loaded || !state)'))
|
|
||||||
// PF-08 gilt weiter: jeder Knopf ein type="button"
|
|
||||||
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
|
||||||
assert.doesNotMatch(s, /window\.confirm|confirm\(/)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Endprüfung P3 (W3): Nach „Anhalten“ schließt der Dialog im selben Takt, in dem pausing beginnt und den Knopf der Leiste sperrt;
|
|
||||||
// useDialogFocus kann den Fokus dann nicht zurückgeben (focus() auf einem gesperrten Knopf tut nichts) und er bliebe auf <body>.
|
|
||||||
// Das Board gibt ihn deshalb selbst zurück, sobald pausing endet. Das echte Verhalten prüft der Prüfstand (shot.cjs, Szenario I).
|
|
||||||
test('WorkersBoard: der Fokus kehrt nach dem Umschalten auf den Knopf der Leiste zurück, sobald er wieder bedienbar ist (W3)', () => {
|
|
||||||
const s = read('WorkersBoard.jsx')
|
|
||||||
assert.match(s, /const toolbarRef = useRef\(null\)/); assert.match(s, /const refocusPauseRef = useRef\(false\)/)
|
|
||||||
assert.match(s, /<div className="wk-toolbar" ref=\{toolbarRef\}>/)
|
|
||||||
assert.match(s, /<Button type="button" onClick=\{requestPause\} disabled=\{pausing\} data-wk-pause>/)
|
|
||||||
// die Markierung wird vor setPausing(true) gesetzt, der Fokus erst im Effekt nach dem Ende von pausing (sonst wäre der Knopf noch gesperrt)
|
|
||||||
assert.match(s, /if \(pausing\) return\s*refocusPauseRef\.current = true\s*setPausing\(true\)/)
|
|
||||||
const effect = s.match(/useEffect\(\(\) => \{\s*if \(pausing \|\| !refocusPauseRef\.current\) return([\s\S]*?)\}, \[pausing\]\)/)
|
|
||||||
assert.ok(effect, 'Effekt mit [pausing]')
|
|
||||||
assert.match(effect[1], /refocusPauseRef\.current = false/)
|
|
||||||
// nur wenn der Fokus verloren ist (auf <body> oder nirgends): wer inzwischen woanders ist, wird nicht gestört
|
|
||||||
assert.match(effect[1], /const active = document\.activeElement\s*if \(active && active !== document\.body\) return/)
|
|
||||||
assert.match(effect[1], /toolbarRef\.current\?\.querySelector\('\[data-wk-pause\]'\)/); assert.match(effect[1], /button\.focus\(\)/)
|
|
||||||
// Hook vor der frühen Rückgabe
|
|
||||||
assert.ok(s.indexOf('[data-wk-pause]') < s.indexOf('if (!loaded || !state)'))
|
|
||||||
// das Gesperrt-Sein des Knopfes bleibt (kein Doppelklick während der Anfrage)
|
|
||||||
assert.match(s, /disabled=\{pausing\}/)
|
|
||||||
})
|
|
||||||
@@ -1,32 +1,63 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
test('workers.css: nur wk-Klassen, @font-face, keine Hex-Farben, reduzierte Bewegung', () => {
|
// workers.css ist seit T11 (Pixelwelt) auf die weißen Bausteine gekürzt, die noch gebraucht werden: Einstellungen-Dialog (WorkersSettingsDialog)
|
||||||
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
|
// und Interessenten-Panel (ProspectPanel). Hochhaus, Pult und Keller haben ihre Stile in src/styles/hustler/ (eigene Tests: hustler*.test.mjs).
|
||||||
for (const m of css.matchAll(/([^{}]+)\{/g)) { const pre = m[1].trim(); if (pre.startsWith('@')) continue; for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) } // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
|
const stylesDir = fileURLToPath(new URL('../../src/styles/', import.meta.url))
|
||||||
assert.match(css, /@font-face\s*\{[^}]*font-family:\s*"Pixelify Sans"[^}]*PixelifySans-Variable\.woff2[^}]*font-display:\s*swap/s); assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
|
const srcDir = fileURLToPath(new URL('../../src/', import.meta.url))
|
||||||
assert.match(css, /prefers-reduced-motion/); assert.match(css, /@media \(max-width: 1100px\)/); assert.match(css, /@media \(max-width: 860px\)/)
|
const stripped = (file) => readFileSync(path.join(stylesDir, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
|
||||||
|
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
|
||||||
|
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||||
|
|
||||||
|
// Klassen der Regeln (jede Regel, auch in @media) und Klassen im Code (ohne Kommentare; `wk-set-${name}` ist eine Kennung, keine Klasse;
|
||||||
|
// das Attribut data-wk-worker hat ein Zeichen davor und zählt nicht)
|
||||||
|
const definierteKlassen = (css) => new Set([...css.matchAll(/\.(wk-[a-z0-9-]+)/g)].map((m) => m[1]))
|
||||||
|
const benutzteKlassen = () => {
|
||||||
|
const out = new Map()
|
||||||
|
for (const f of walk(srcDir).filter((x) => /\.jsx?$/.test(x))) {
|
||||||
|
for (const m of ohneKommentare(readFileSync(f, 'utf8')).matchAll(/(?<![\w-])wk-[a-z0-9-]*[a-z0-9](?![\w-])/g)) {
|
||||||
|
if (!out.has(m[0])) out.set(m[0], new Set())
|
||||||
|
out.get(m[0]).add(path.relative(srcDir, f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
test('workers.css: nur wk-Klassen, keine Hex-Farben, keine Schrift, Bilder oder Importe, ein Handy-Block', () => {
|
||||||
|
const css = stripped('workers.css')
|
||||||
|
let regeln = 0
|
||||||
|
for (const m of css.matchAll(/([^{}]+)\{/g)) {
|
||||||
|
const pre = m[1].trim()
|
||||||
|
if (pre.startsWith('@')) continue
|
||||||
|
regeln += 1
|
||||||
|
for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
|
||||||
|
}
|
||||||
|
assert.ok(regeln >= 15, `nur ${regeln} Regeln gelesen`)
|
||||||
|
assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
|
||||||
|
assert.doesNotMatch(css, /@font-face|@import|url\(/) // die Schrift steht in hustler/schrift.css (workersFont.test.mjs)
|
||||||
|
assert.match(css, /@media \(max-width: 860px\)/)
|
||||||
|
assert.doesNotMatch(css, /@media \(max-width: 1100px\)/)
|
||||||
|
// keine Bewegung in dieser Datei, also braucht sie auch kein prefers-reduced-motion
|
||||||
|
assert.doesNotMatch(css, /\b(?:animation|transition)\s*:|@keyframes/)
|
||||||
})
|
})
|
||||||
test('jede im JSX verwendete wk-Klasse ist definiert', () => {
|
|
||||||
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8'); const dir = new URL('../../src/components/workers/', import.meta.url)
|
test('jede wk-Klasse im Code hat eine Regel in workers.css, und keine Regel steht ohne Nutzer', () => {
|
||||||
for (const f of readdirSync(dir).filter((x) => x.endsWith('.jsx'))) for (const m of readFileSync(new URL(f, dir), 'utf8').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) // Lookbehind: das Attribut data-wk-worker ist keine Klasse
|
const definiert = definierteKlassen(stripped('workers.css'))
|
||||||
})
|
const benutzt = benutzteKlassen()
|
||||||
test('workers.css: Zoomknöpfe unten rechts (oben läge die Reihe über dem Schild der Kommandobrücke), Meldungen halten Abstand zu den Kennzahlen', () => {
|
assert.ok(definiert.size >= 10 && benutzt.size >= 10, `${definiert.size} Regel-Klassen, ${benutzt.size} benutzte Klassen`)
|
||||||
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
for (const [klasse, dateien] of benutzt) assert.ok(definiert.has(klasse), `${klasse} (${[...dateien].join(', ')}) hat keine Regel`)
|
||||||
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
|
for (const klasse of definiert) assert.ok(benutzt.has(klasse), `.${klasse} wird nirgends im Code benutzt`)
|
||||||
assert.match(rule('.wk-zoom'), /bottom:\s*8px/); assert.match(rule('.wk-zoom'), /right:\s*8px/); assert.doesNotMatch(rule('.wk-zoom'), /\btop:/)
|
// die Nutzer sind genau die zwei weißen Dialoge
|
||||||
assert.match(rule('.wk-toolbar ~ .msg'), /margin-bottom:\s*16px/)
|
const nutzer = new Set([...benutzt.values()].flatMap((s) => [...s]))
|
||||||
})
|
assert.deepEqual([...nutzer].sort(), ['components/workers/ProspectPanel.jsx', 'components/workers/WorkersSettingsDialog.jsx'])
|
||||||
test('workers.css: die Karte bleibt beim Anspringen unter der festen Leiste der Handy-Ansicht (scroll-margin-top)', () => {
|
|
||||||
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
||||||
assert.match(css, /\.wk-card\s*\{[^}]*scroll-margin-top:\s*12px/)
|
|
||||||
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
|
|
||||||
assert.match(phone, /\.wk-card\s*\{\s*scroll-margin-top:\s*64px/) // 52 px Leiste + Luft
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workers.css: die globale Regel .page section + section (36 px) verlängert den Abstand der Panel-Abschnitte nicht (W4)', () => {
|
test('workers.css: die globale Regel .page section + section (36 px) verlängert den Abstand der Panel-Abschnitte nicht (W4)', () => {
|
||||||
const global = readFileSync(new URL('../../src/styles/global.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
const global = stripped('global.css')
|
||||||
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
const css = stripped('workers.css')
|
||||||
assert.match(global, /\.page section \+ section\s*\{\s*margin-top:\s*36px/) // die Regel, gegen die das Panel sich behaupten muss: Spezifität (0,1,2)
|
assert.match(global, /\.page section \+ section\s*\{\s*margin-top:\s*36px/) // die Regel, gegen die das Panel sich behaupten muss: Spezifität (0,1,2)
|
||||||
const m = css.match(/(\.wk-prospect [^{}]*\.wk-prospect-section \+ \.wk-prospect-section)\s*\{([^}]*)\}/)
|
const m = css.match(/(\.wk-prospect [^{}]*\.wk-prospect-section \+ \.wk-prospect-section)\s*\{([^}]*)\}/)
|
||||||
assert.ok(m, 'Regel für zwei benachbarte Abschnitte im Panel')
|
assert.ok(m, 'Regel für zwei benachbarte Abschnitte im Panel')
|
||||||
@@ -38,60 +69,13 @@ test('workers.css: die globale Regel .page section + section (36 px) verlängert
|
|||||||
assert.doesNotMatch(css.match(/\.wk-prospect-section\s*\{([^}]*)\}/)[1], /margin-top/)
|
assert.doesNotMatch(css.match(/\.wk-prospect-section\s*\{([^}]*)\}/)[1], /margin-top/)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Bot-Leads (Endprüfung P6: C5, W1, W2) ----
|
test('workers.css: Einstellungen-Dialog behält Trennlinien und den roten Block „Versand gestoppt“ (Tokens aus global.css)', () => {
|
||||||
const stripped = (file) => readFileSync(new URL('../../src/styles/' + file, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
||||||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
// Rumpf der Regel mit genau diesem Selektor am Anfang der Regel (weder Teil eines längeren Selektors noch einer Gruppe mit Komma)
|
|
||||||
const body = (css, selector) => css.match(new RegExp(`(?:^|[{}])\\s*${escapeRe(selector)}\\s*\\{([^}]*)\\}`))?.[1] ?? null
|
|
||||||
const decl = (rule, prop) => rule?.match(new RegExp(`(?:^|[;\\s])${prop}\\s*:\\s*([^;]+)`))?.[1].trim() ?? null
|
|
||||||
// Spezifität (ids, Klassen/Attribute/Pseudoklassen, Elemente); Vergleich von links nach rechts
|
|
||||||
const specificity = (selector) => {
|
|
||||||
const s = selector.replace(/\[[^\]]*\]/g, '.a')
|
|
||||||
const ids = (s.match(/#[\w-]+/g) || []).length
|
|
||||||
const classes = (s.match(/\.[\w-]+|:(?!:)[\w-]+/g) || []).length
|
|
||||||
const elements = (s.replace(/\.[\w-]+|#[\w-]+|:+[\w-]+/g, ' ').match(/(?:^|[\s>+~])[a-z][\w-]*/gi) || []).length
|
|
||||||
return [ids, classes, elements]
|
|
||||||
}
|
|
||||||
const beats = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]; return false }
|
|
||||||
const luminance = (hex) => {
|
|
||||||
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
||||||
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
|
||||||
}
|
|
||||||
const contrast = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05) }
|
|
||||||
const token = (name) => stripped('global.css').match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`))?.[1]
|
|
||||||
|
|
||||||
test('workers.css: gekürzte Zeilen der Bot-Leads bleiben lesbar (Text >= 4,5:1 auf dem Seiten- und dem Zeilengrund), grau über --text-2 statt --text-3 (C5)', () => {
|
|
||||||
const css = stripped('workers.css')
|
|
||||||
const rule = body(css, '.wk-bl-cut td')
|
|
||||||
assert.ok(rule, 'Regel .wk-bl-cut td')
|
|
||||||
assert.equal(decl(rule, 'color'), 'var(--text-2)')
|
|
||||||
assert.doesNotMatch(css, /\.wk-bl-cut[^{]*\{[^}]*--text-3/) // keine Regel der gekürzten Zeile greift zu --text-3
|
|
||||||
for (const ground of ['--bg', '--bg-subtle']) {
|
|
||||||
const ratio = contrast(token('--text-2'), token(ground))
|
|
||||||
assert.ok(ratio >= 4.5, `${ground}: ${ratio.toFixed(2)}:1`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('workers.css: am Handy sind die Blätterknöpfe, „Alle anzeigen“ und „Erneut versuchen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => {
|
|
||||||
const css = stripped('workers.css')
|
|
||||||
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
|
|
||||||
for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn', '.wk-bl-retry .btn']) {
|
|
||||||
assert.equal(decl(body(phone, selector), 'min-height'), '40px', selector)
|
|
||||||
// min-height schlägt height: 32px der globalen .btn-Regel, und die Spezifität (0,2,0) schlägt .btn (0,1,0): kein !important nötig
|
|
||||||
assert.ok(beats(specificity(selector), specificity('.btn')), selector)
|
|
||||||
assert.doesNotMatch(body(phone, selector), /!important/)
|
|
||||||
}
|
|
||||||
assert.equal(decl(body(stripped('global.css'), '.btn'), 'height'), '32px') // das, wogegen die Knöpfe sich behaupten
|
|
||||||
assert.equal(body(css, '.btn'), null) // keine globale Änderung aus der Hustler-Datei
|
|
||||||
})
|
|
||||||
|
|
||||||
test('workers.css: der Ort in der Bot-Leads-Tabelle bricht nicht mitten im Wort (break-word schlägt die globale Regel mit anywhere) (W2)', () => {
|
|
||||||
const css = stripped('workers.css')
|
const css = stripped('workers.css')
|
||||||
const global = stripped('global.css')
|
const global = stripped('global.css')
|
||||||
const wanted = '.wk-bl .wk-bl-table td .cell-sub'
|
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
|
||||||
const globalSelector = '.table td .cell-sub'
|
assert.match(rule('.wk-set-group'), /border-top:\s*1px solid var\(--line\)/)
|
||||||
assert.equal(decl(body(global, globalSelector), 'overflow-wrap'), 'anywhere') // die globale Regel, die der Bot-Leads-Ort nicht erben soll
|
assert.match(rule('.wk-halt'), /border-top:\s*1px solid var\(--danger\)/); assert.match(rule('.wk-halt'), /border-bottom:\s*1px solid var\(--danger\)/)
|
||||||
assert.equal(decl(body(css, wanted), 'overflow-wrap'), 'break-word')
|
assert.match(rule('.wk-halt .wk-set-title'), /color:\s*var\(--red-fg\)/)
|
||||||
// streng größer, nicht gleich: bei Gleichstand entschiede die Reihenfolge der Stylesheets im Bündel
|
// jedes verwendete Token gibt es in global.css
|
||||||
assert.ok(beats(specificity(wanted), specificity(globalSelector)), `${wanted} ${specificity(wanted)} gegen ${specificity(globalSelector)}`)
|
for (const m of css.matchAll(/var\((--[\w-]+)\)/g)) assert.match(global, new RegExp(`${m[1]}\\s*:`), m[1])
|
||||||
})
|
})
|
||||||
@@ -1,640 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
import { makeCtx } from './helpers/fakeCtx.mjs'
|
|
||||||
import { IDS, makeState, ev } from './helpers/workersFixtures.mjs'
|
|
||||||
|
|
||||||
// Regie 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen; Regie 2: Schritte, Aufzugfahrten, Rückstau, Wachhund (Quelle: entwuerfe/scene-turm.js)
|
|
||||||
const D = await loadModule('src/components/workers/engine/director.js')
|
|
||||||
const { buildLayout, homeOf } = await loadModule('src/components/workers/engine/layout.js')
|
|
||||||
const { createLift, LIFT } = await loadModule('src/components/workers/engine/lift.js')
|
|
||||||
const { createFx } = await loadModule('src/components/workers/engine/fx.js')
|
|
||||||
const { createLook } = await loadModule('src/components/workers/engine/rooms.js')
|
|
||||||
const { pickAgent } = await loadModule('src/components/workers/engine/camera.js')
|
|
||||||
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
|
|
||||||
const { createDirector, drawAgents } = D
|
|
||||||
|
|
||||||
function setup() {
|
|
||||||
const layout = buildLayout(), lift = createLift(layout), fx = createFx(), look = createLook()
|
|
||||||
return { layout, lift, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) }
|
|
||||||
}
|
|
||||||
const step = (d, n, dt = 0.1) => { for (let i = 0; i < n; i++) d.update(dt) }
|
|
||||||
const rectsOf = (ctx, fill) => ctx.rects.filter((r) => r.fill === fill)
|
|
||||||
const draw = (d, selectedId = null, t = 1) => { const m = makeCtx(); drawAgents(m.ctx, d, selectedId, t); return m }
|
|
||||||
const depth = (a) => a.y + a.lane + a.bob + 13 * a.scale
|
|
||||||
|
|
||||||
test('erster Snapshot: alle 19 an ihrem Platz, Pose nach Status, „!“ bei Fehler', () => {
|
|
||||||
const { d, layout } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', rita: 'wartet', bea: 'fehler' } }))
|
|
||||||
assert.equal(d.agents.length, 19)
|
|
||||||
for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y], [h.x, h.y], a.id) }
|
|
||||||
assert.deepEqual([d.byId.kim.pose, d.byId.rita.pose, d.byId.otto.pose, d.byId.bea.alert], ['coffee', 'stand', 'type', true])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('erster Snapshot stellt Agenten auf den Platz, auch wenn sie vorher woanders standen', () => {
|
|
||||||
const { d, layout } = setup(); for (const a of d.agents) { a.x = 5; a.y = 7; a.carrying = true; a.frame = 1 }
|
|
||||||
d.applyState(makeState())
|
|
||||||
for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y, a.carrying, a.frame, a.floor], [h.x, h.y, false, 0, h.floor], a.id) }
|
|
||||||
assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('nicht angeschlossen oder im Zustand fehlend ⇒ grau, schläft, Raum in look.greyRooms (Review Focus 4)', () => {
|
|
||||||
const { d, look } = setup(); const st = makeState({ connected: { kim: false } }); st.workers = st.workers.filter((w) => w.workerId !== 'pia')
|
|
||||||
d.applyState(st)
|
|
||||||
assert.deepEqual([d.byId.kim.grey, d.byId.kim.pose, d.byId.pia.grey, d.byId.pia.pose, d.byId.bob.grey], [true, 'sleep', true, 'sleep', false])
|
|
||||||
assert.deepEqual([...look.greyRooms].sort(), ['kleinanzeigen', 'portale'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Kennzahlen landen in look; neuer Pokal blinkt nur bei steigender Zahl, nicht beim ersten Snapshot', () => {
|
|
||||||
const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } }))
|
|
||||||
assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
|
|
||||||
d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0)
|
|
||||||
d.applyState(makeState({ paused: true })); assert.equal(look.paused, true)
|
|
||||||
d.applyState(makeState({ sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces' })); assert.equal(look.paused, false) // Notbremse (H4) ist keine Pause: kein Schleier, die Meldung zeigt das Board
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => {
|
|
||||||
const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } }))
|
|
||||||
const step = (n) => { for (let i = 0; i < n; i++) d.update(0.1) }
|
|
||||||
step(70); assert.equal(look.trophyPage, 1) // 7 s: erste Blätterung bei 6 s
|
|
||||||
step(60); assert.equal(look.trophyPage, 0); assert.equal(d.byId.kim.pose, 'coffee') // 13 s: zweite Blätterung bei 12 s
|
|
||||||
step(140); assert.equal(d.byId.kim.pose, 'sleep') // 27 s > 25 s
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawAgents: Trefferflächen für alle 19, jeder ist anklickbar', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState({ connected: { kim: false }, statuses: { rita: 'fehler' } })); const { ctx } = makeCtx(); drawAgents(ctx, d, 'bob', 1)
|
|
||||||
const boxes = d.boxes(); assert.equal(boxes.length, 19)
|
|
||||||
for (const { id, box } of boxes) assert.equal(pickAgent(boxes, box.x + box.w / 2, box.y + box.h / 2), id, id)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Konstanten der Regie', () => {
|
|
||||||
assert.deepEqual([D.BACKLOG_KEEP, D.GROUPS_PER_AGENT, D.IDLE_COFFEE_S, D.STEP_TIMEOUT_S, D.BUBBLE_MAX], [6, 4, 25, 45, 3])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Agent hat alle Felder der Schnittstelle; Hut, Größe, Blickrichtung und Platz stimmen', () => {
|
|
||||||
const { d, layout } = setup(); d.applyState(makeState())
|
|
||||||
const felder = ['id', 'name', 'hat', 'scale', 'x', 'y', 'floor', 'facing', 'frame', 'pose', 'carrying', 'stackN', 'say', 'bubT', 'grey', 'alert', 'status', 'task', 'counters', 'lane', 'bob', 'idleT', 'cheer', 'q', 'cur', 'stepT', 'groups', 'box', 'home']
|
|
||||||
for (const a of d.agents) for (const f of felder) assert.ok(Object.hasOwn(a, f), `${a.id}.${f}`)
|
|
||||||
assert.deepEqual(d.agents.map((a) => a.id), [...IDS])
|
|
||||||
assert.deepEqual([d.byId.otto.hat, d.byId.otto.scale, d.byId.bob.hat, d.byId.paul.hat, d.byId.kim.hat, d.byId.kim.scale], ['overseer', 2, 'opus', 'sonnet', 'haiku', 1])
|
|
||||||
assert.deepEqual([d.byId.otto.name, d.byId.clara.name], ['Otto', 'Clara'])
|
|
||||||
assert.deepEqual(['ali', 'hanna', 'sven', 'lea', 'paul', 'clara'].map((id) => d.byId[id].facing), [-1, -1, -1, -1, -1, -1])
|
|
||||||
assert.deepEqual(['otto', 'rita', 'bea', 'arne'].map((id) => d.byId[id].facing), [1, 1, 1, 1])
|
|
||||||
assert.deepEqual(d.agents.map((a) => [a.floor, a.home]), d.agents.map((a) => [homeOf(layout, a.id).floor, homeOf(layout, a.id)]))
|
|
||||||
assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2])
|
|
||||||
assert.equal(d.byId.constructor, undefined)
|
|
||||||
assert.equal(d.pending(), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('spätere Snapshots bewegen nie; Status, Pose und Aufgabe nur bei Agenten ohne laufende Gruppe', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState()); const kim = d.byId.kim
|
|
||||||
kim.x = 77; kim.y = 66
|
|
||||||
const st1 = makeState({ statuses: { kim: 'leerlauf' } }); st1.workers.find((w) => w.workerId === 'kim').task = 'Pause'; st1.workers.find((w) => w.workerId === 'kim').counters = { gelesen: 4 }
|
|
||||||
d.applyState(st1)
|
|
||||||
assert.deepEqual([kim.x, kim.y, kim.status, kim.pose, kim.task, kim.counters], [77, 66, 'leerlauf', 'coffee', 'Pause', { gelesen: 4 }])
|
|
||||||
// laufende Gruppe: der Status gilt sofort, Pose und Aufgabe kommen erst nach dem Ende der Gruppe
|
|
||||||
kim.groups = 1; kim.pose = 'type'; kim.task = 'alt'
|
|
||||||
const st2 = makeState({ statuses: { kim: 'fehler' } }); st2.workers.find((w) => w.workerId === 'kim').task = 'neu'
|
|
||||||
d.applyState(st2)
|
|
||||||
assert.deepEqual([kim.status, kim.alert, kim.pose, kim.task], ['fehler', true, 'type', 'alt'])
|
|
||||||
d.update(0.05); assert.equal(kim.pose, 'type')
|
|
||||||
kim.groups = 0; d.update(0.05); assert.equal(kim.pose, 'stand')
|
|
||||||
d.applyState(st2); assert.equal(kim.task, 'neu')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unbrauchbarer Zustand wirft nicht: alle grau und schlafend, keine Pause', () => {
|
|
||||||
for (const bad of [null, undefined, {}, 'x', { workers: 'x' }, { workers: [null, 5, { workerId: 7 }] }]) {
|
|
||||||
const { d, look } = setup(); d.applyState(makeState({ paused: true })); d.applyState(bad)
|
|
||||||
assert.deepEqual([d.agents.every((a) => a.grey && a.pose === 'sleep' && a.status === 'grau'), look.paused, look.greyRooms.size], [true, false, 19], String(bad))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('busyRooms: Räume der arbeitenden Worker; graue und ruhende gehören nicht dazu', () => {
|
|
||||||
const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', bea: 'wartet', paul: 'fehler' }, connected: { pia: false } }))
|
|
||||||
assert.equal(look.busyRooms.has('bruecke'), true)
|
|
||||||
for (const room of ['kleinanzeigen', 'portale', 'waage', 'pruefung']) assert.equal(look.busyRooms.has(room), false, room)
|
|
||||||
assert.equal(look.busyRooms.size, 19 - 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Kennzahlen: fehlende Werte lassen look stehen und lösen kein Blinken aus; Rückgang blinkt nicht', () => {
|
|
||||||
const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } }))
|
|
||||||
const bare = makeState(); delete bare.kpis; d.applyState(bare)
|
|
||||||
assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
|
|
||||||
d.applyState(makeState({ kpis: { dealsMonth: 'x', pultOpen: NaN, botLeads: null } })); assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
|
|
||||||
d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(look.trophyPop, 0)
|
|
||||||
d.applyState(makeState({ kpis: { dealsMonth: 9.8, pultOpen: -3, botLeads: 112.9 } })); assert.deepEqual([look.dealsMonth, look.pultOpen, look.botLeads, look.trophyPop], [9, 0, 112, 0]) // ganze Zahlen, nie negativ
|
|
||||||
d.applyState(makeState({ kpis: { dealsMonth: 2 } })); assert.deepEqual([look.dealsMonth, look.trophyPop], [2, 0])
|
|
||||||
d.applyState(makeState({ kpis: { dealsMonth: 3 } })); assert.equal(look.trophyPop, 1.2)
|
|
||||||
// ein Zustand ohne Kennzahlen beim ersten Snapshot zählt nicht als erster Wert
|
|
||||||
const b = setup(); b.d.applyState(bare); b.d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(b.look.trophyPop, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('update: Pokal-Blinken und Tresor-Blitz zählen herunter; Pause friert alles ein', () => {
|
|
||||||
const { d, look, fx } = setup(); d.applyState(makeState({ kpis: { dealsMonth: 1 } })); d.applyState(makeState({ kpis: { dealsMonth: 2 } })); look.vaultFlash = 0.7
|
|
||||||
let ended = 0; fx.add(0.4, () => {}, () => { ended += 1 })
|
|
||||||
step(d, 5); assert.ok(look.trophyPop < 1.2 && look.trophyPop > 0.6); assert.ok(look.vaultFlash < 0.7 && look.vaultFlash > 0.1); assert.equal(ended, 1)
|
|
||||||
step(d, 20); assert.deepEqual([look.trophyPop, look.vaultFlash], [0, 0])
|
|
||||||
const kim = d.byId.kim; d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: true })); look.pageT = 0
|
|
||||||
const before = [kim.idleT, look.pageT]; step(d, 100); assert.deepEqual([kim.idleT, look.pageT], before)
|
|
||||||
d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: false })); step(d, 10); assert.ok(kim.idleT > 0.9)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('update: ungültiges dt wird ignoriert; der Schlaf kehrt mit dem Status zurück', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' } }))
|
|
||||||
for (const dt of [NaN, undefined, 0, -1, Infinity]) d.update(dt)
|
|
||||||
assert.equal(d.byId.kim.idleT, 0)
|
|
||||||
step(d, 260); assert.equal(d.byId.kim.pose, 'sleep')
|
|
||||||
d.applyState(makeState({ statuses: { kim: 'arbeitet' } })); d.update(0.1); assert.deepEqual([d.byId.kim.idleT, d.byId.kim.pose], [0, 'type'])
|
|
||||||
d.applyState(makeState({ statuses: { kim: 'leerlauf' } })); step(d, 10); assert.equal(d.byId.kim.pose, 'coffee')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Wippen: nur wer am Platz tippt, wippt um einen Pixel; Jubel hüpft um zwei; unterwegs gilt die Spur 0', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState()); const seen = new Set()
|
|
||||||
for (let i = 0; i < 40; i++) { d.update(0.1); seen.add(d.byId.bob.bob) }
|
|
||||||
assert.deepEqual([...seen].sort(), [-1, 0])
|
|
||||||
assert.equal(d.byId.arne.pose, 'type')
|
|
||||||
d.applyState(makeState({ statuses: { bob: 'wartet' } })); d.update(0.1); assert.equal(d.byId.bob.bob, 0)
|
|
||||||
d.byId.otto.cheer = 0.5; const jubel = new Set(); for (let i = 0; i < 4; i++) { d.update(0.1); jubel.add(d.byId.otto.bob) }
|
|
||||||
assert.ok(jubel.has(-2) && jubel.has(0)); step(d, 3); assert.equal(d.byId.otto.cheer, 0)
|
|
||||||
// Spur: am Platz -2 (außer Otto), unterwegs gleitet sie auf 0
|
|
||||||
const kim = d.byId.kim; assert.equal(kim.lane, -2); kim.x += 30; kim.frame = 1; const unterwegs = new Set(); for (let i = 0; i < 20; i++) { d.update(0.1); unterwegs.add(kim.bob) }
|
|
||||||
assert.deepEqual([kim.lane, [...unterwegs]], [0, [0]]); kim.x -= 30; kim.frame = 0; d.update(0.05); assert.ok(kim.lane < 0 && kim.lane > -1)
|
|
||||||
step(d, 3); assert.equal(kim.lane, -2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('bubble: auf 20 Zeichen gekürzt, nach 2,2 s weg, höchstens drei fremde gleichzeitig, leer und unbekannt ignoriert', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState())
|
|
||||||
assert.equal(d.bubble(d.byId.kim, 'Dies ist ein sehr langer Satz'), true); assert.equal(d.byId.kim.say, 'Dies ist ein sehr la')
|
|
||||||
assert.equal(d.bubble(d.byId.rita, 'Eins'), true); assert.equal(d.bubble(d.byId.pia, 'Zwei'), true)
|
|
||||||
assert.equal(d.bubble(d.byId.finn, 'Drei'), false); assert.equal(d.byId.finn.say, null)
|
|
||||||
assert.equal(d.bubble(d.byId.kim, 'Neu'), true); assert.equal(d.byId.kim.say, 'Neu') // der Sprecher selbst zählt nicht mit
|
|
||||||
assert.deepEqual([d.bubble(d.byId.kim, ''), d.bubble(d.byId.kim, 42), d.bubble(null, 'x')], [false, false, false])
|
|
||||||
step(d, 20); assert.equal(d.byId.kim.say, 'Neu'); step(d, 5); assert.deepEqual(d.agents.filter((a) => a.say), [])
|
|
||||||
assert.equal(d.bubble(d.byId.finn, 'Jetzt'), true)
|
|
||||||
d.bubble(d.byId.ali, 'Lang', 5); step(d, 30); assert.equal(d.byId.ali.say, 'Lang'); step(d, 25); assert.equal(d.byId.ali.say, null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawAgents: y-sortiert, jede Figur genau einmal, Trefferfläche aus der Zeichnung', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState()); const m = draw(d)
|
|
||||||
const tiefen = d.boxes().map(({ id }) => depth(d.byId[id]))
|
|
||||||
assert.deepEqual(tiefen, [...tiefen].sort((a, b) => a - b))
|
|
||||||
assert.equal(new Set(d.boxes().map((b) => b.id)).size, 19)
|
|
||||||
assert.ok(m.rects.length > 19 * 40)
|
|
||||||
const otto = d.byId.otto; assert.deepEqual([otto.box.w, otto.box.h], [24, 32])
|
|
||||||
const e = setup(); assert.deepEqual(e.d.boxes(), []) // vor dem ersten Zeichnen gibt es keine Flächen
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawAgents: gezeichnet wird von hinten nach vorn, nicht in Listenreihenfolge', () => {
|
|
||||||
const kleine = (m) => m.rects.map((r, i) => (r.w === 3 && r.h === 3 ? i : -1)).filter((i) => i >= 0) // Zellen der normalen Figuren (Raster 3)
|
|
||||||
const grosse = (m) => m.rects.map((r, i) => (r.w === 6 && r.h === 6 ? i : -1)).filter((i) => i >= 0) // Zellen von Otto (Maßstab 2)
|
|
||||||
const { d } = setup(); d.applyState(makeState())
|
|
||||||
const oben = draw(d); assert.ok(Math.max(...grosse(oben)) < Math.max(...kleine(oben)), 'Otto steht oben und wird zuerst gezeichnet')
|
|
||||||
d.byId.otto.y = 300
|
|
||||||
const unten = draw(d); assert.ok(Math.min(...grosse(unten)) > Math.max(...kleine(unten)), 'Otto steht jetzt vor allen anderen')
|
|
||||||
assert.equal(d.boxes().at(-1).id, 'otto')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawAgents: „!“ nur bei Fehler, Auswahlpfeil nur beim gewählten, Stapel nur bei mehreren Papieren', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState())
|
|
||||||
assert.equal(rectsOf(draw(d), PAL.red).length, 0)
|
|
||||||
assert.equal(rectsOf(draw(d), PAL.ink).length, 0)
|
|
||||||
d.applyState(makeState({ statuses: { bea: 'fehler' } })); assert.ok(rectsOf(draw(d), PAL.red).length > 0)
|
|
||||||
const rot = rectsOf(draw(d), PAL.red), bea = d.byId.bea
|
|
||||||
assert.equal(Math.max(...rot.map((r) => r.y + r.h)) + 3, (bea.y + bea.lane - 2) * 3, 'Unterkante des „!“ (mit Umriss) steht auf der Kopfoberkante')
|
|
||||||
assert.equal(rectsOf(draw(d, 'bob'), PAL.ink).length, 9)
|
|
||||||
const papier = (n, carrying = true) => { d.byId.kim.carrying = carrying; d.byId.kim.stackN = n; return draw(d).rects.filter((r) => r.fill === PAL.g).length }
|
|
||||||
const eins = papier(1); assert.ok(papier(2) > eins); assert.ok(papier(5) > papier(2)); assert.equal(papier(5, false), papier(1, false))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawAgents: graue Figuren halbdurchsichtig, die Zeichnung lässt save/restore im Gleichgewicht', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState({ connected: { kim: false, pia: false } })); const m = draw(d)
|
|
||||||
assert.equal(m.alphas.filter((a) => a === 0.55).length, 2); assert.equal(m.counts.save, m.counts.restore)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawAgents: Blasen zuletzt; Otto seitlich; außerhalb des Bildes keine Blase', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState()); d.bubble(d.byId.kim, 'Hallo'); d.bubble(d.byId.otto, 'Deal! Super!')
|
|
||||||
const m = draw(d); assert.deepEqual(m.texts.map((t) => t.text).sort(), ['Deal! Super!', 'Hallo'])
|
|
||||||
const weiss = m.rects.map((r, i) => [r, i]).filter(([r]) => r.fill === PAL.w && r.w > 20)
|
|
||||||
assert.equal(weiss.length, 2); for (const [, i] of weiss) assert.ok(m.rects.length - i <= 4 + 4, 'Blase liegt am Ende der Zeichnung')
|
|
||||||
// Otto: die Blase steht neben ihm, nicht über dem Kopf (Spitze liegt links von seiner linken Kante oder rechts von der rechten)
|
|
||||||
const otto = d.byId.otto, os = m.texts.find((t) => t.text === 'Deal! Super!'), kimText = m.texts.find((t) => t.text === 'Hallo')
|
|
||||||
assert.ok(os.x < otto.x * 3 - 20 || os.x > (otto.x + 24) * 3); assert.ok(os.y > otto.y * 3)
|
|
||||||
assert.ok(kimText.y < d.byId.kim.y * 3)
|
|
||||||
const kim = d.byId.kim, spitze = m.rects.at(-1); assert.equal(spitze.y + spitze.h, (kim.y + kim.lane - 2) * 3 + 1, 'Spitze steht auf der Kopfoberkante (Hut)')
|
|
||||||
d.byId.kim.x = -10; d.byId.rita.x = 330; d.bubble(d.byId.rita, 'Rechts'); const aus = draw(d); assert.deepEqual(aus.texts.map((t) => t.text).sort(), ['Deal! Super!'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawAgents: die Blase weicht dem „!“ und dem Auswahlpfeil aus', () => {
|
|
||||||
const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'fehler' } })); d.bubble(d.byId.kim, 'Kim'); d.bubble(d.byId.rita, 'Rita')
|
|
||||||
const textY = (m, text) => m.texts.find((t) => t.text === text).y
|
|
||||||
const m = draw(d)
|
|
||||||
assert.equal(d.byId.kim.y, d.byId.rita.y)
|
|
||||||
assert.ok(textY(m, 'Rita') - textY(m, 'Kim') >= 27) // das „!“ ist 9 Einheiten hoch (Raster 3)
|
|
||||||
assert.equal(textY(m, 'Rita') - textY(draw(d, 'rita'), 'Rita'), 18) // der Pfeil nimmt 6 Einheiten
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- Regie 2: Schritte, Aufzugfahrten, Rückstau, Wachhund (P2-T13) ----------
|
|
||||||
|
|
||||||
// Simulationszeit in Sekunden, bis keine Gruppe mehr läuft (höchstens max)
|
|
||||||
const run = (d, max = 60) => {
|
|
||||||
let t = 0
|
|
||||||
while (d.pending() > 0 && t < max) {
|
|
||||||
d.update(0.05)
|
|
||||||
t += 0.05
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
const at = (layout, id) => homeOf(layout, id)
|
|
||||||
|
|
||||||
test('Übergabe über zwei Etagen: Rita bringt Bea ein Dokument, fährt Aufzug, steht danach wieder zuhause', () => {
|
|
||||||
const { d, lift, layout } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt 2 Funde zu Bea' })])
|
|
||||||
assert.ok(d.pending() >= 1)
|
|
||||||
let ride = false
|
|
||||||
let t = 0
|
|
||||||
while (d.pending() > 0 && t < 60) {
|
|
||||||
d.update(0.05)
|
|
||||||
t += 0.05
|
|
||||||
if (lift.state !== 'idle') ride = true
|
|
||||||
}
|
|
||||||
assert.ok(ride)
|
|
||||||
assert.ok(t < 40, String(t))
|
|
||||||
const h = at(layout, 'rita')
|
|
||||||
assert.deepEqual([Math.round(d.byId.rita.x), Math.round(d.byId.rita.y), d.byId.rita.carrying, lift.state], [h.x, h.y, false, 'idle'])
|
|
||||||
assert.equal(d.byId.rita.task, 'Rita bringt 2 Funde zu Bea')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('gleiche Etage: kein Aufzug', () => {
|
|
||||||
const { d, lift } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })])
|
|
||||||
let ride = false
|
|
||||||
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
|
|
||||||
d.update(0.05)
|
|
||||||
if (lift.state !== 'idle') ride = true
|
|
||||||
}
|
|
||||||
assert.equal(ride, false)
|
|
||||||
assert.equal(d.pending(), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Rückstau: 300 Ereignisse ⇒ höchstens 6 Gruppen, Rest still, nach ≤ 60 s ruhig, letzte Aufgaben stehen (Review Focus 2)', () => {
|
|
||||||
const { d, lift } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents(Array.from({ length: 300 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 3) % 19], text: `Aufgabe ${i}` })))
|
|
||||||
assert.ok(d.pending() <= 6, String(d.pending()))
|
|
||||||
const t = run(d)
|
|
||||||
assert.ok(t < 60, String(t))
|
|
||||||
assert.equal(d.pending(), 0)
|
|
||||||
assert.equal(lift.state, 'idle')
|
|
||||||
for (let k = 0; k < 19; k++) {
|
|
||||||
const last = 299 - ((299 - k) % 19)
|
|
||||||
assert.equal(d.byId[IDS[k]].task, `Aufgabe ${last}`, IDS[k])
|
|
||||||
}
|
|
||||||
d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` })))
|
|
||||||
assert.ok(d.byId.kim.groups <= 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('graue, fremde und unbekannte Ereignisse: keine Arbeit, kein Wurf (Review Focus 4)', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState({ connected: { kim: false } }))
|
|
||||||
d.pushEvents([ev('gelesen', 'kim'), ev('fund', 'zed'), ev('neueArt', 'rita'), ev('uebergabe', 'kim', { to: 'bea' })])
|
|
||||||
assert.equal(d.byId.kim.say, null)
|
|
||||||
assert.equal(d.byId.kim.pose, 'sleep')
|
|
||||||
assert.equal(d.byId.kim.groups, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Blasen: höchstens drei gleichzeitig', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents(['kim', 'rita', 'pia', 'finn', 'ali'].map((w) => ev('fund', w)))
|
|
||||||
d.update(0.05)
|
|
||||||
assert.ok(d.agents.filter((a) => a.say).length <= 3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Pause friert ein, Fortsetzen läuft weiter', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState({ paused: true }))
|
|
||||||
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })])
|
|
||||||
const x0 = d.byId.kim.x
|
|
||||||
for (let i = 0; i < 400; i++) d.update(0.05)
|
|
||||||
assert.equal(d.byId.kim.x, x0)
|
|
||||||
d.applyState(makeState({ paused: false }))
|
|
||||||
for (let i = 0; i < 40; i++) d.update(0.05)
|
|
||||||
assert.notEqual(d.byId.kim.x, x0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Aufzug zurückgesetzt mitten in der Fahrt: Fahrgast erholt sich, Türzähler 0; hängender Aufzug: Wachhund nach 45 s', () => {
|
|
||||||
const a = setup()
|
|
||||||
a.d.applyState(makeState())
|
|
||||||
a.d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
|
|
||||||
let g = 0
|
|
||||||
while (a.lift.state !== 'riding' && g++ < 400) a.d.update(0.05)
|
|
||||||
assert.equal(a.lift.state, 'riding')
|
|
||||||
a.lift.reset()
|
|
||||||
assert.ok(run(a.d) < 10)
|
|
||||||
assert.deepEqual(a.d.debug().door, new Array(10).fill(0))
|
|
||||||
const layout = buildLayout()
|
|
||||||
const fx = createFx()
|
|
||||||
const look = createLook()
|
|
||||||
const stuck = { y: 0, state: 'idle', request: (ag, f, t) => ({ a: ag, from: f, to: t, st: 'queued' }), update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {} }
|
|
||||||
const d = createDirector({ layout, lift: stuck, fx, look })
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
|
|
||||||
const t = run(d, 80)
|
|
||||||
assert.ok(t >= 45 && t < 60, String(t))
|
|
||||||
assert.deepEqual(d.debug().door, new Array(10).fill(0))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('flush: alles sofort ruhig, alle zuhause; Deal löst Münzen und Jubel aus', () => {
|
|
||||||
const { d, lift, fx, layout } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents(Array.from({ length: 20 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 5) % 19] })))
|
|
||||||
d.flush()
|
|
||||||
assert.equal(d.pending(), 0)
|
|
||||||
assert.equal(lift.state, 'idle')
|
|
||||||
assert.equal(fx.size(), 0)
|
|
||||||
for (const a of d.agents) {
|
|
||||||
const h = at(layout, a.id)
|
|
||||||
assert.deepEqual([Math.round(a.x), Math.round(a.y)], [h.x, h.y], a.id)
|
|
||||||
}
|
|
||||||
d.pushEvents([ev('deal', 'system')])
|
|
||||||
d.update(0.1)
|
|
||||||
assert.ok(fx.size() >= 3)
|
|
||||||
assert.ok(d.byId.otto.cheer > 0)
|
|
||||||
run(d)
|
|
||||||
for (let i = 0; i < 100; i++) d.update(0.1)
|
|
||||||
assert.equal(fx.size(), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Aufzug-Attrappe: request liefert, was der Test vorgibt; alles Übrige tut nichts
|
|
||||||
const stubLift = (request, extra = {}) => ({ y: 0, state: 'idle', request, update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {}, ...extra })
|
|
||||||
const withLift = (lift) => {
|
|
||||||
const layout = buildLayout()
|
|
||||||
const fx = createFx()
|
|
||||||
const look = createLook()
|
|
||||||
return { layout, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) }
|
|
||||||
}
|
|
||||||
// Mittelpunkt aller gezeichneten Rechtecke in Welteinheiten (Raster 3)
|
|
||||||
const centerOf = (rects) => ({
|
|
||||||
x: rects.reduce((s, r) => s + r.x + r.w / 2, 0) / rects.length / 3,
|
|
||||||
y: rects.reduce((s, r) => s + r.y + r.h / 2, 0) / rects.length / 3,
|
|
||||||
})
|
|
||||||
const dist = (p, q) => Math.hypot(p.x - q.x, p.y - q.y)
|
|
||||||
|
|
||||||
test('Aufzug antwortet cancelled: Fahrt entfällt, der Agent steht sofort wieder zuhause, Türzähler 0', () => {
|
|
||||||
const calls = []
|
|
||||||
const lift = stubLift((a, from, to) => {
|
|
||||||
calls.push([a.id, from, to])
|
|
||||||
return { a, from, to, st: 'cancelled' }
|
|
||||||
})
|
|
||||||
const { d, layout } = withLift(lift)
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt Bea Funde' })])
|
|
||||||
assert.ok(run(d) < 5)
|
|
||||||
assert.deepEqual(calls, [['rita', 1, 3]])
|
|
||||||
const h = at(layout, 'rita')
|
|
||||||
assert.deepEqual([d.byId.rita.x, d.byId.rita.y, d.byId.rita.floor, d.byId.rita.carrying, d.byId.rita.frame], [h.x, h.y, h.floor, false, 0])
|
|
||||||
assert.deepEqual(d.debug().door, new Array(10).fill(0))
|
|
||||||
assert.deepEqual([d.byId.rita.task, d.byId.bea.say, d.pending()], ['Rita bringt Bea Funde', null, 0])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Wachhund: ruft lift.cancel für den Agenten, stellt ihn heim und beendet die Gruppe', () => {
|
|
||||||
const cancelled = []
|
|
||||||
const lift = stubLift((a, from, to) => ({ a, from, to, st: 'queued' }), { cancel: (a) => cancelled.push(a.id) })
|
|
||||||
const { d, layout } = withLift(lift)
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('fund', 'rita')])
|
|
||||||
assert.equal(d.byId.rita.groups, 2)
|
|
||||||
const t = run(d, 200)
|
|
||||||
assert.deepEqual(cancelled, ['rita'])
|
|
||||||
assert.ok(t >= 45 && t < 60, String(t))
|
|
||||||
const h = at(layout, 'rita')
|
|
||||||
assert.deepEqual([d.byId.rita.x, d.byId.rita.y, d.byId.rita.carrying, d.byId.rita.groups, d.byId.rita.cur], [h.x, h.y, false, 0, null])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Wartende vor dem Aufzug stehen nebeneinander (Türzähler zählt mit) und werden beim Einsteigen ausgetragen', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('uebergabe', 'kim', { to: 'max' })])
|
|
||||||
let most = 0
|
|
||||||
const slots = { rita: false, kim: false }
|
|
||||||
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
|
|
||||||
d.update(0.05)
|
|
||||||
most = Math.max(most, d.debug().door[1])
|
|
||||||
if (d.byId.rita.x === LIFT.slot0) slots.rita = true
|
|
||||||
if (d.byId.kim.x === LIFT.slot0 + LIFT.slotStep) slots.kim = true
|
|
||||||
}
|
|
||||||
assert.equal(most, 2)
|
|
||||||
assert.deepEqual(slots, { rita: true, kim: true })
|
|
||||||
assert.deepEqual(d.debug().door, new Array(10).fill(0))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Snapshot während einer Gruppe: Aufgabe und Pose kommen erst am Gruppenende aus der Tabelle', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia', text: 'Kim bringt Pia Funde' })])
|
|
||||||
for (let i = 0; i < 20; i++) d.update(0.05)
|
|
||||||
const st = makeState({ statuses: { kim: 'leerlauf' } })
|
|
||||||
st.workers.find((w) => w.workerId === 'kim').task = 'Snapshot-Aufgabe'
|
|
||||||
d.applyState(st)
|
|
||||||
assert.deepEqual([d.byId.kim.status, d.byId.kim.task], ['leerlauf', 'Kim bringt Pia Funde'])
|
|
||||||
run(d)
|
|
||||||
assert.deepEqual([d.byId.kim.task, d.byId.kim.pose, d.byId.kim.groups], ['Snapshot-Aufgabe', 'coffee', 0])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('stille Gruppe für einen Worker mit laufender Gruppe: seine Aufgabe kommt am Gruppenende aus der Tabelle', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia', text: 'Kim bringt Pia Funde' })])
|
|
||||||
d.update(0.05)
|
|
||||||
d.pushEvents([ev('gelesen', 'kim', { text: 'Liest Mails' }), ...Array.from({ length: 6 }, (_, i) => ev('fund', 'rita', { text: `R${i}` }))])
|
|
||||||
assert.equal(d.byId.kim.task, 'Kim bringt Pia Funde')
|
|
||||||
run(d)
|
|
||||||
assert.equal(d.byId.kim.task, 'Liest Mails')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pro Worker höchstens 4 wartende Gruppen: die neuesten bleiben animiert, die Aufgabe des neuesten gewinnt', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` })))
|
|
||||||
assert.equal(d.byId.kim.groups, 4)
|
|
||||||
assert.equal(d.byId.kim.task, 'K5')
|
|
||||||
d.pushEvents([ev('fund', 'kim', { text: 'K10' })])
|
|
||||||
assert.equal(d.byId.kim.groups, 4)
|
|
||||||
run(d)
|
|
||||||
assert.equal(d.byId.kim.task, 'K10')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pushEvents vor dem ersten Snapshot und mit kaputten Eingaben: nichts passiert, kein Wurf', () => {
|
|
||||||
const { d, look } = setup()
|
|
||||||
d.pushEvents([ev('fund', 'kim')])
|
|
||||||
assert.deepEqual([d.pending(), d.agents.some((a) => a.grey), look.greyRooms.size, d.byId.kim.status], [0, false, 0, 'wartet'])
|
|
||||||
d.applyState(makeState())
|
|
||||||
for (const bad of [null, undefined, 'x', 5, {}, [], [null, 5, 'x', {}, { kind: 'fund' }]]) d.pushEvents(bad)
|
|
||||||
assert.equal(d.pending(), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Ereignisse ändern den Status sofort (Fehler-Zeichen, Leerlauf, Räume), die Aufgabe setzt erst der Schritt', () => {
|
|
||||||
const { d, look } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('fehler', 'rita', { text: 'Suche kaputt' }), ev('leerlauf', 'kim', { text: 'Pause' })])
|
|
||||||
assert.deepEqual([d.byId.rita.status, d.byId.rita.alert, d.byId.kim.status], ['fehler', true, 'leerlauf'])
|
|
||||||
assert.deepEqual([look.busyRooms.has('netz'), look.busyRooms.has('kleinanzeigen'), look.busyRooms.has('portale')], [false, false, true])
|
|
||||||
assert.equal(d.byId.rita.task, '')
|
|
||||||
d.update(0.05)
|
|
||||||
assert.deepEqual([d.byId.rita.task, d.byId.kim.task, d.byId.kim.pose], ['Suche kaputt', 'Pause', 'coffee'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('wer schläft, wird für seine Gruppe wach; danach gilt wieder der Status', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState({ statuses: { kim: 'leerlauf' } }))
|
|
||||||
for (let i = 0; i < 300; i++) d.update(0.1)
|
|
||||||
assert.equal(d.byId.kim.pose, 'sleep')
|
|
||||||
d.pushEvents([ev('doppelt', 'kim')])
|
|
||||||
d.update(0.05)
|
|
||||||
assert.deepEqual([d.byId.kim.pose, d.byId.kim.say], ['type', 'Schon bekannt'])
|
|
||||||
run(d)
|
|
||||||
assert.equal(d.byId.kim.pose, 'type')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Übergabe an einen nicht angeschlossenen Worker entfällt: niemand läuft, nur die Aufgabe steht', () => {
|
|
||||||
const { d, lift } = setup()
|
|
||||||
d.applyState(makeState({ connected: { kim: false } }))
|
|
||||||
const x0 = d.byId.pia.x
|
|
||||||
d.pushEvents([ev('uebergabe', 'pia', { to: 'kim', text: 'Pia an Kim' })])
|
|
||||||
for (let i = 0; i < 20; i++) d.update(0.05)
|
|
||||||
assert.equal(d.pending(), 0)
|
|
||||||
assert.deepEqual([d.byId.pia.x, d.byId.pia.task, d.byId.kim.say, d.byId.kim.pose, lift.state], [x0, 'Pia an Kim', null, 'sleep', 'idle'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Übergabe auf einer Etage: Dokument in der Hand, Giver steht neben dem Empfänger (Abstand 12), blickt ihn an, Empfänger dankt', () => {
|
|
||||||
const { d, layout } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
const kim = d.byId.kim
|
|
||||||
const pia = d.byId.pia
|
|
||||||
const bea = d.byId.bea
|
|
||||||
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' }), ev('uebergabe', 'pia', { to: 'kim' })])
|
|
||||||
let carried = false
|
|
||||||
const seen = {}
|
|
||||||
for (let i = 0; i < 800 && d.pending() > 0; i++) {
|
|
||||||
d.update(0.05)
|
|
||||||
if (kim.carrying) carried = true
|
|
||||||
if (!seen.kim && pia.say === 'Danke!') seen.kim = { x: kim.x, facing: kim.facing, carrying: kim.carrying }
|
|
||||||
if (!seen.pia && kim.say === 'Danke!') seen.pia = { x: pia.x, facing: pia.facing, carrying: pia.carrying }
|
|
||||||
}
|
|
||||||
assert.equal(carried, true)
|
|
||||||
assert.deepEqual(seen.kim, { x: at(layout, 'pia').x - 12, facing: 1, carrying: false })
|
|
||||||
assert.deepEqual(seen.pia, { x: at(layout, 'kim').x + 12, facing: -1, carrying: false })
|
|
||||||
assert.equal(bea.say, null)
|
|
||||||
for (const a of [kim, pia]) assert.deepEqual([a.x, a.y, a.facing], [at(layout, a.id).x, at(layout, a.id).y, 1])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Effekte: Deal wirft drei Münzen von außen zum Tresor (Versatz 0, 0,4 und 0,8 s), der Tresor blitzt beim Ankommen, Otto jubelt', () => {
|
|
||||||
const calls = []
|
|
||||||
const fx = { add: (life, draw, onEnd, delay) => calls.push({ life, draw, onEnd, delay }), update() {}, clear() {}, size: () => calls.length }
|
|
||||||
const layout = buildLayout()
|
|
||||||
const look = createLook()
|
|
||||||
const d = createDirector({ layout, lift: createLift(layout), fx, look, rnd: () => 0.5 })
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('deal', 'system')])
|
|
||||||
d.update(0.05)
|
|
||||||
assert.deepEqual(calls.map((c) => c.delay), [0, 0.4, 0.8])
|
|
||||||
assert.deepEqual([look.vaultFlash, d.byId.otto.say], [0, 'Deal! Super!'])
|
|
||||||
calls[2].onEnd()
|
|
||||||
assert.equal(look.vaultFlash, 0.7)
|
|
||||||
const at0 = makeCtx()
|
|
||||||
calls[0].draw(at0.ctx, 0, { t: 0, life: calls[0].life })
|
|
||||||
const at1 = makeCtx()
|
|
||||||
calls[0].draw(at1.ctx, 1, { t: calls[0].life, life: calls[0].life })
|
|
||||||
assert.ok(dist(centerOf(at0.rects), layout.spots.aussen) < 4)
|
|
||||||
assert.ok(dist(centerOf(at1.rects), layout.spots.tresor) < 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Effekte: Ablegen ohne Ziel fliegt vom Kopf des Workers zum Archiv', () => {
|
|
||||||
const calls = []
|
|
||||||
const fx = { add: (life, draw, onEnd, delay) => calls.push({ life, draw, onEnd, delay }), update() {}, clear() {}, size: () => calls.length }
|
|
||||||
const layout = buildLayout()
|
|
||||||
const d = createDirector({ layout, lift: createLift(layout), fx, look: createLook(), rnd: () => 0.5 })
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('botlead', 'arne')])
|
|
||||||
for (let i = 0; i < 60 && calls.length === 0; i++) d.update(0.05)
|
|
||||||
assert.equal(calls.length, 1)
|
|
||||||
const arne = d.byId.arne
|
|
||||||
const start = makeCtx()
|
|
||||||
calls[0].draw(start.ctx, 0, { t: 0, life: calls[0].life })
|
|
||||||
const end = makeCtx()
|
|
||||||
calls[0].draw(end.ctx, 1, { t: calls[0].life, life: calls[0].life })
|
|
||||||
assert.ok(dist(centerOf(start.rects), { x: arne.x + 6, y: arne.y + arne.lane - 2 }) < 5)
|
|
||||||
assert.ok(dist(centerOf(end.rects), layout.spots.archiv) < 6)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('flush läuft auch bei Pause durch und lässt alle zuhause', () => {
|
|
||||||
const { d, layout, look } = setup()
|
|
||||||
d.applyState(makeState({ paused: true }))
|
|
||||||
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt Bea Funde' })])
|
|
||||||
assert.equal(d.pending(), 1)
|
|
||||||
d.flush()
|
|
||||||
const h = at(layout, 'rita')
|
|
||||||
assert.deepEqual([d.pending(), look.paused, d.byId.rita.x, d.byId.rita.y], [0, true, h.x, h.y])
|
|
||||||
assert.equal(d.byId.rita.task, 'Rita bringt Bea Funde') // die Schritte sind durchgelaufen, nicht nur verworfen
|
|
||||||
})
|
|
||||||
|
|
||||||
test('reset: Warteschlangen leer, Aufzug frei, Türzähler 0, Effekte und Blasen weg, alle zuhause', () => {
|
|
||||||
const { d, lift, fx, layout } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('fund', 'kim')])
|
|
||||||
let g = 0
|
|
||||||
while (lift.state !== 'riding' && g++ < 400) d.update(0.05)
|
|
||||||
assert.equal(lift.state, 'riding')
|
|
||||||
fx.add(5, () => {})
|
|
||||||
assert.ok(d.pending() > 0)
|
|
||||||
d.reset()
|
|
||||||
assert.deepEqual([d.pending(), lift.state, fx.size()], [0, 'idle', 0])
|
|
||||||
assert.deepEqual(d.debug(), { door: new Array(10).fill(0), groups: 0 })
|
|
||||||
for (const a of d.agents) {
|
|
||||||
const h = at(layout, a.id)
|
|
||||||
assert.deepEqual([a.x, a.y, a.carrying, a.say, a.q.length, a.cur], [h.x, h.y, false, null, 0, null], a.id)
|
|
||||||
}
|
|
||||||
const x = d.byId.rita.x
|
|
||||||
for (let i = 0; i < 100; i++) d.update(0.05)
|
|
||||||
assert.deepEqual([d.byId.rita.x, d.pending(), lift.state], [x, 0, 'idle'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('graue Worker bekommen keine vorgespielte Arbeit: weder Aufgabe noch Pose noch Blase noch Gruppe', () => {
|
|
||||||
const { d } = setup()
|
|
||||||
d.applyState(makeState({ connected: { kim: false } }))
|
|
||||||
d.pushEvents([ev('fund', 'kim', { text: 'Fund' }), ev('gelesen', 'kim', { text: 'Gelesen' }), ev('auftrag', 'otto', { to: 'kim', text: 'Kim, los' })])
|
|
||||||
for (let i = 0; i < 100; i++) d.update(0.05)
|
|
||||||
assert.deepEqual([d.byId.kim.task, d.byId.kim.pose, d.byId.kim.say, d.byId.kim.groups, d.byId.kim.status], ['', 'sleep', null, 0, 'grau'])
|
|
||||||
assert.equal(d.byId.otto.task, 'Kim, los')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Fahrgast steht beim Einsteigen schon in der Kabine, auch der hohe Otto, und kommt danach heim', () => {
|
|
||||||
const { d, lift, layout } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
const otto = d.byId.otto
|
|
||||||
d.pushEvents([ev('uebergabe', 'otto', { to: 'rita' })])
|
|
||||||
let boarded = null
|
|
||||||
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
|
|
||||||
d.update(0.05)
|
|
||||||
if (!boarded && lift.state === 'closing') boarded = [otto.x, otto.y, otto.y === lift.y + 7]
|
|
||||||
}
|
|
||||||
assert.deepEqual(boarded, [LIFT.cabWx, boarded[1], true])
|
|
||||||
const h = at(layout, 'otto')
|
|
||||||
assert.deepEqual([otto.x, otto.y, otto.floor, lift.state, d.pending()], [h.x, h.y, h.floor, 'idle', 0])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Übergabe vom Aufzug aus: der Giver blickt den Empfänger an, auch wenn er dafür zurücklaufen muss', () => {
|
|
||||||
const { d, layout } = setup()
|
|
||||||
d.applyState(makeState())
|
|
||||||
const bea = d.byId.bea
|
|
||||||
const rita = d.byId.rita
|
|
||||||
d.pushEvents([ev('uebergabe', 'bea', { to: 'rita' })])
|
|
||||||
let seen = null
|
|
||||||
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
|
|
||||||
d.update(0.05)
|
|
||||||
if (!seen && rita.say === 'Danke!') seen = [bea.x, bea.facing]
|
|
||||||
}
|
|
||||||
assert.deepEqual(seen, [at(layout, 'rita').x - 12, 1])
|
|
||||||
assert.ok(seen[0] < LIFT.exitX) // der Weg führt vom Ausgang des Schachts nach links zurück
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,38 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
test('Schrift: woff2-Magic-Bytes, plausible Größe, OFL-Lizenztext', () => {
|
test('Schrift: woff2-Magic-Bytes, plausible Größe, OFL-Lizenztext', () => {
|
||||||
const buf = readFileSync(new URL('../../public/fonts/PixelifySans-Variable.woff2', import.meta.url)); assert.equal(buf.subarray(0, 4).toString('latin1'), 'wOF2'); assert.ok(buf.length > 10000 && buf.length < 400000, String(buf.length))
|
const buf = readFileSync(new URL('../../public/fonts/PixelifySans-Variable.woff2', import.meta.url)); assert.equal(buf.subarray(0, 4).toString('latin1'), 'wOF2'); assert.ok(buf.length > 10000 && buf.length < 400000, String(buf.length))
|
||||||
const lic = readFileSync(new URL('../../public/fonts/OFL-PixelifySans.txt', import.meta.url), 'utf8'); assert.match(lic, /SIL OPEN FONT LICENSE Version 1\.1/); assert.match(lic, /Pixelify Sans/)
|
const lic = readFileSync(new URL('../../public/fonts/OFL-PixelifySans.txt', import.meta.url), 'utf8'); assert.match(lic, /SIL OPEN FONT LICENSE Version 1\.1/); assert.match(lic, /Pixelify Sans/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Seit T11 (Pixelwelt) steht die Einbindung von Pixelify Sans neben Press Start 2P in styles/hustler/schrift.css; workers.css hat keine Schrift mehr.
|
||||||
|
const stylesDir = fileURLToPath(new URL('../../src/styles/', import.meta.url))
|
||||||
|
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
|
||||||
|
const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
const fontFaces = () => walk(stylesDir)
|
||||||
|
.filter((f) => f.endsWith('.css'))
|
||||||
|
.flatMap((f) => [...ohneKommentare(readFileSync(f, 'utf8')).matchAll(/@font-face\s*\{[^}]*\}/g)].map((m) => ({ datei: path.relative(stylesDir, f), regel: m[0] })))
|
||||||
|
|
||||||
|
test('Pixelify Sans: genau eine @font-face-Regel in src/styles, in hustler/schrift.css (selbst gehostet, swap, Gewichte 400-700)', () => {
|
||||||
|
const pixelify = fontFaces().filter((r) => /font-family:\s*["']Pixelify Sans["']/.test(r.regel))
|
||||||
|
assert.equal(pixelify.length, 1, 'genau eine Einbindung')
|
||||||
|
assert.equal(pixelify[0].datei, 'hustler/schrift.css')
|
||||||
|
const regel = pixelify[0].regel
|
||||||
|
assert.match(regel, /src:\s*url\(["']?\/fonts\/PixelifySans-Variable\.woff2["']?\)\s*format\(["']woff2["']\)/)
|
||||||
|
assert.match(regel, /font-display:\s*swap/)
|
||||||
|
assert.match(regel, /font-weight:\s*400 700/)
|
||||||
|
assert.doesNotMatch(regel, /googleapis|gstatic|https?:/)
|
||||||
|
// workers.css bindet keine Schrift mehr ein
|
||||||
|
assert.doesNotMatch(ohneKommentare(readFileSync(path.join(stylesDir, 'workers.css'), 'utf8')), /@font-face/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('jede selbst gehostete Schrift in src/styles liegt unter public/fonts und hat dort ihren Lizenztext', () => {
|
||||||
|
const dateien = fontFaces().flatMap((r) => [...r.regel.matchAll(/url\(\s*["']?(\/fonts\/[\w.-]+)["']?\s*\)/g)].map((m) => m[1]))
|
||||||
|
assert.ok(dateien.length >= 2, 'mindestens Press Start 2P und Pixelify Sans')
|
||||||
|
for (const datei of dateien) assert.ok(existsSync(fileURLToPath(new URL('../../public' + datei, import.meta.url))), `${datei} fehlt in public/`)
|
||||||
|
for (const lizenz of ['OFL-PressStart2P.txt', 'OFL-PixelifySans.txt']) assert.ok(existsSync(fileURLToPath(new URL('../../public/fonts/' + lizenz, import.meta.url))), lizenz)
|
||||||
|
})
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
import { makeCtx } from './helpers/fakeCtx.mjs'
|
|
||||||
|
|
||||||
// Effekt-System: Verzögerung, Lebensdauer, Obergrenze und die acht benannten Effekte
|
|
||||||
const F = await loadModule('src/components/workers/engine/fx.js')
|
|
||||||
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
|
|
||||||
|
|
||||||
test('Verzögerung, Lebensdauer, onEnd genau einmal', () => {
|
|
||||||
const fx = F.createFx(), log = []; fx.add(1, (c, p) => log.push(['draw', p]), () => log.push(['end']), 0.5)
|
|
||||||
const { ctx } = makeCtx(); fx.update(0.4); fx.draw(ctx); assert.equal(log.length, 0)
|
|
||||||
fx.update(0.4); fx.draw(ctx); assert.equal(log.at(-1)[0], 'draw')
|
|
||||||
fx.update(1); assert.deepEqual(log.at(-1), ['end']); fx.update(1); assert.equal(log.filter((l) => l[0] === 'end').length, 1); assert.equal(fx.size(), 0)
|
|
||||||
})
|
|
||||||
test('Obergrenze 80: Überschuss wird verworfen und meldet onEnd; clear ruft kein onEnd', () => {
|
|
||||||
const fx = F.createFx(); let ended = 0
|
|
||||||
for (let i = 0; i < 200; i++) fx.add(5, () => {}, () => { ended++ })
|
|
||||||
assert.equal(fx.size(), F.MAX_FX); assert.equal(ended, 120); fx.clear(); assert.equal(fx.size(), 0); assert.equal(ended, 120)
|
|
||||||
})
|
|
||||||
test('jeder Effektname zeichnet und endet in höchstens 6 s', () => {
|
|
||||||
for (const name of F.FX_NAMES) { const fx = F.createFx(), { ctx, rects } = makeCtx(); let ended = false
|
|
||||||
F.spawnFx(fx, name, { from: { x: 50, y: 100 }, to: { x: 150, y: 60 }, onEnd: () => { ended = true } })
|
|
||||||
for (let i = 0; i < 60; i++) { fx.update(0.1); fx.draw(ctx) }
|
|
||||||
assert.ok(rects.length > 0, name); assert.equal(fx.size(), 0, name); assert.ok(ended, name) }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
|
|
||||||
|
|
||||||
const K = 3 // Standardraster der Engine (kein setGrid): Geräte-Pixel = 3 × logische Einheiten
|
|
||||||
const LIFE = { paper: 0.9, envelope: 3.4, coin: 0.7, stampOk: 1.2, stampNo: 1.2, sparkle: 1.0, alert: 1.5, drawer: 0.9 }
|
|
||||||
|
|
||||||
// Umschließendes Rechteck aller Rechtecke in logischen Einheiten (null ohne Rechtecke)
|
|
||||||
function bounds(rects) {
|
|
||||||
if (!rects.length) return null
|
|
||||||
const x0 = Math.min(...rects.map((r) => r.x)) / K, y0 = Math.min(...rects.map((r) => r.y)) / K
|
|
||||||
const x1 = Math.max(...rects.map((r) => r.x + r.w)) / K, y1 = Math.max(...rects.map((r) => r.y + r.h)) / K
|
|
||||||
return { x0, y0, x1, y1, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 }
|
|
||||||
}
|
|
||||||
// Ein Bild des Effekts nach `at` Sekunden (frischer Kontext)
|
|
||||||
function shot(name, opts, at) {
|
|
||||||
const fx = F.createFx(), { ctx, rects } = makeCtx()
|
|
||||||
F.spawnFx(fx, name, opts)
|
|
||||||
if (at > 0) fx.update(at)
|
|
||||||
fx.draw(ctx)
|
|
||||||
return { b: bounds(rects), rects, fx }
|
|
||||||
}
|
|
||||||
const FROM = { x: 40, y: 100 }
|
|
||||||
|
|
||||||
test('FX_NAMES und MAX_FX wie vereinbart, die Liste ist eingefroren', () => {
|
|
||||||
assert.deepEqual([...F.FX_NAMES], ['paper', 'envelope', 'coin', 'stampOk', 'stampNo', 'sparkle', 'alert', 'drawer'])
|
|
||||||
assert.ok(Object.isFrozen(F.FX_NAMES)); assert.equal(F.MAX_FX, 80)
|
|
||||||
})
|
|
||||||
test('Lebensdauer je Effekt: kurz vor dem Ende noch da, kurz danach weg und onEnd genau einmal', () => {
|
|
||||||
for (const name of F.FX_NAMES) {
|
|
||||||
const fx = F.createFx(); let ended = 0
|
|
||||||
F.spawnFx(fx, name, { from: FROM, to: { x: 140, y: 100 }, onEnd: () => { ended++ } })
|
|
||||||
fx.update(LIFE[name] - 0.05); assert.equal(fx.size(), 1, name); assert.equal(ended, 0, name)
|
|
||||||
fx.update(0.1); assert.equal(fx.size(), 0, name); assert.equal(ended, 1, name)
|
|
||||||
fx.update(1); assert.equal(ended, 1, name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('draw bekommt Fortschritt 0..1 (nie darüber) und den Effekt mit Zeit und Lebensdauer', () => {
|
|
||||||
const fx = F.createFx(), { ctx } = makeCtx(), seen = []
|
|
||||||
fx.add(1, (c, p, f) => seen.push([p, f.t, f.life]))
|
|
||||||
fx.draw(ctx); for (let i = 0; i < 9; i++) { fx.update(0.1); fx.draw(ctx) }
|
|
||||||
assert.equal(seen.length, 10); assert.deepEqual(seen[0], [0, 0, 1])
|
|
||||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i][0] >= seen[i - 1][0] && seen[i][0] <= 1)
|
|
||||||
assert.ok(seen.at(-1)[0] > 0.85)
|
|
||||||
const solo = F.createFx(); let p = null; solo.add(0, (c, q) => { p = q }); solo.draw(ctx); assert.equal(p, 1) // Lebensdauer 0: kein NaN
|
|
||||||
})
|
|
||||||
test('update ignoriert dt, das keine positive endliche Zahl ist (kein NaN im Zeitstand)', () => {
|
|
||||||
const fx = F.createFx(); let ended = 0; fx.add(1, () => {}, () => { ended++ })
|
|
||||||
fx.update(NaN); fx.update(-5); fx.update(0); fx.update(Infinity); fx.update(undefined)
|
|
||||||
assert.equal(fx.size(), 1); fx.update(1.1); assert.equal(fx.size(), 0); assert.equal(ended, 1)
|
|
||||||
})
|
|
||||||
test('ungültige Lebensdauer und Verzögerung laufen höchstens bis zum nächsten update', () => {
|
|
||||||
const fx = F.createFx(); fx.add(NaN, () => {}); fx.add(-3, () => {}); fx.add(1, () => {}, undefined, NaN); fx.add(1, () => {}, undefined, -2)
|
|
||||||
assert.equal(fx.size(), 4); fx.update(0.1); assert.equal(fx.size(), 2)
|
|
||||||
})
|
|
||||||
test('ein onEnd darf neue Effekte anlegen und clear() rufen; clear mitten im update wirft nicht', () => {
|
|
||||||
const fx = F.createFx(), log = []
|
|
||||||
fx.add(0.5, () => {}, () => { log.push('a'); fx.add(0.5, () => {}, () => log.push('b')) })
|
|
||||||
fx.update(0.6); assert.equal(fx.size(), 1); assert.deepEqual(log, ['a'])
|
|
||||||
fx.update(0.6); assert.deepEqual(log, ['a', 'b']); assert.equal(fx.size(), 0)
|
|
||||||
fx.add(0.5, () => {}, () => { log.push('c'); fx.clear() }); fx.add(5, () => {}, () => log.push('nie'))
|
|
||||||
fx.update(0.6); assert.equal(fx.size(), 0); assert.deepEqual(log, ['a', 'b', 'c'])
|
|
||||||
})
|
|
||||||
test('wirft ein onEnd, laufen die übrigen trotzdem und der erste Fehler kommt danach', () => {
|
|
||||||
const fx = F.createFx(); let second = 0
|
|
||||||
fx.add(0.1, () => {}, () => { throw new Error('boom') }); fx.add(0.1, () => {}, () => { second++ }); fx.add(5, () => {})
|
|
||||||
assert.throws(() => fx.update(0.2), /boom/); assert.equal(second, 1); assert.equal(fx.size(), 1)
|
|
||||||
})
|
|
||||||
test('voll: ein verworfener Effekt meldet onEnd sofort (nicht erst beim nächsten update)', () => {
|
|
||||||
const fx = F.createFx(); for (let i = 0; i < F.MAX_FX; i++) fx.add(5, () => {})
|
|
||||||
let ended = 0; F.spawnFx(fx, 'coin', { from: FROM, onEnd: () => { ended++ } })
|
|
||||||
assert.equal(ended, 1); assert.equal(fx.size(), F.MAX_FX)
|
|
||||||
})
|
|
||||||
test('spawnFx: Verzögerung zeichnet erst danach; unbekannter Name wirft nicht und meldet onEnd sofort', () => {
|
|
||||||
const fx = F.createFx(), { ctx, rects } = makeCtx()
|
|
||||||
F.spawnFx(fx, 'paper', { from: FROM, to: { x: 140, y: 100 }, delay: 0.5 })
|
|
||||||
fx.update(0.4); fx.draw(ctx); assert.equal(rects.length, 0)
|
|
||||||
fx.update(0.3); fx.draw(ctx); assert.ok(rects.length > 0)
|
|
||||||
let ended = 0; const empty = F.createFx()
|
|
||||||
for (const bad of ['gibtsnicht', '', undefined, null, 'constructor', '__proto__']) F.spawnFx(empty, bad, { from: FROM, onEnd: () => { ended++ } })
|
|
||||||
assert.equal(ended, 6); assert.equal(empty.size(), 0)
|
|
||||||
F.spawnFx(empty, 'sparkle'); assert.equal(empty.size(), 1) // ohne from/to und ohne Optionen: Standardpunkt statt Wurf
|
|
||||||
})
|
|
||||||
test('Bogenflug: Papier 8, Brief 12, Münze 14 Einheiten über der Geraden, Start und Ziel liegen auf der Geraden', () => {
|
|
||||||
const to = { x: 140, y: 100 } // gleiche Höhe: jede Abweichung in y ist der Bogen
|
|
||||||
for (const [name, high] of [['paper', 8], ['envelope', 12], ['coin', 14]]) {
|
|
||||||
const start = shot(name, { from: FROM, to }, 0).b, mid = shot(name, { from: FROM, to }, LIFE[name] / 2).b, end = shot(name, { from: FROM, to }, LIFE[name] - 0.001).b
|
|
||||||
assert.ok(Math.abs(start.cy - mid.cy - high) <= 1, `${name} Bogenhöhe ${start.cy - mid.cy}`)
|
|
||||||
assert.ok(Math.abs(start.cy - end.cy) <= 1, `${name} Ende auf der Geraden`)
|
|
||||||
assert.ok(Math.abs(start.cx - FROM.x) <= 1, `${name} Start bei from`); assert.ok(Math.abs(mid.cx - 90) <= 1, `${name} Mitte`); assert.ok(Math.abs(end.cx - to.x) <= 1, `${name} Ende bei to`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('Bogenflug folgt auch einem schrägen Weg (Ziel oben rechts)', () => {
|
|
||||||
const to = { x: 140, y: 60 }, start = shot('paper', { from: FROM, to }, 0).b, end = shot('paper', { from: FROM, to }, 0.899).b
|
|
||||||
assert.ok(end.cx - start.cx >= 98); assert.ok(start.cy - end.cy >= 38)
|
|
||||||
})
|
|
||||||
test('Briefumschlag ist violett mit weißem Fenster, Münze golden, Papier grau-weiß', () => {
|
|
||||||
const fills = (name) => new Set(shot(name, { from: FROM, to: { x: 140, y: 100 } }, 0.2).rects.map((r) => r.fill))
|
|
||||||
assert.ok(fills('envelope').has('#7a5cd6') && fills('envelope').has(PAL.w))
|
|
||||||
assert.ok(fills('coin').has(PAL.gold2)); assert.ok(fills('paper').has(PAL.g) && fills('paper').has(PAL.w))
|
|
||||||
})
|
|
||||||
test('Schubladen-Papier endet 3 Einheiten vor dem Ziel, das normale Papier erreicht es', () => {
|
|
||||||
const to = { x: 140, y: 100 }, d = shot('drawer', { from: FROM, to }, 0.899).b, p = shot('paper', { from: FROM, to }, 0.899).b
|
|
||||||
assert.ok(Math.abs(d.cx - (to.x - 3)) <= 1, `drawer ${d.cx}`); assert.ok(Math.abs(p.cx - to.x) <= 1, `paper ${p.cx}`)
|
|
||||||
// liegt das Ziel näher als 3 Einheiten, bleibt das Blatt beim Start (kein Rückwärtsflug, kein NaN)
|
|
||||||
const near = shot('drawer', { from: FROM, to: { x: 41, y: 100 } }, 0.899).b; assert.ok(Math.abs(near.cx - FROM.x) <= 1)
|
|
||||||
const same = shot('drawer', { from: FROM, to: FROM }, 0.4).b; assert.ok(Number.isFinite(same.cx))
|
|
||||||
})
|
|
||||||
test('Stempel: grün für OK, rot für Nein, hüpft über dem Punkt und landet darauf', () => {
|
|
||||||
for (const [name, color, other] of [['stampOk', PAL.green, PAL.red], ['stampNo', PAL.red, PAL.green]]) {
|
|
||||||
const heights = []
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const s = shot(name, { from: FROM }, i * 0.1); assert.ok(s.b.y1 <= FROM.y + 0.01, `${name} bleibt über from`)
|
|
||||||
assert.ok(s.rects.some((r) => r.fill === color), name); assert.ok(!s.rects.some((r) => r.fill === other), name); heights.push(s.b.y1)
|
|
||||||
}
|
|
||||||
assert.ok(Math.min(...heights) < FROM.y - 3, `${name} hüpft`); assert.ok(Math.abs(shot(name, { from: FROM }, 1.199).b.y1 - FROM.y) <= 1, `${name} landet`)
|
|
||||||
assert.ok(Math.abs(shot(name, { from: FROM }, 0.5).b.cx - FROM.x) <= 1, `${name} mittig`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('Funkeln: Goldpixel wachsen nach außen und bleiben um from', () => {
|
|
||||||
const early = shot('sparkle', { from: FROM }, 0.05).b, late = shot('sparkle', { from: FROM }, 0.95).b
|
|
||||||
assert.ok(late.x1 - late.x0 > early.x1 - early.x0 + 6); assert.ok(Math.abs(late.cx - FROM.x) <= 1)
|
|
||||||
assert.ok(shot('sparkle', { from: FROM }, 0.5).rects.every((r) => r.fill === PAL.gold || r.fill === '#fff3c4'))
|
|
||||||
})
|
|
||||||
test('Warnzeichen: blinkt, beginnt sichtbar, steht mit der Unterkante auf from und ist rot', () => {
|
|
||||||
const on = []
|
|
||||||
for (let i = 0; i < 30; i++) on.push(shot('alert', { from: FROM }, i * 0.05).rects.length > 0)
|
|
||||||
assert.equal(on[0], true); assert.ok(on.some((v) => !v)); assert.ok(on.filter(Boolean).length >= 10)
|
|
||||||
const b = shot('alert', { from: FROM }, 0).b; assert.equal(b.y1, FROM.y); assert.ok(Math.abs(b.cx - FROM.x) <= 1)
|
|
||||||
assert.ok(shot('alert', { from: FROM }, 0).rects.some((r) => r.fill === PAL.red))
|
|
||||||
})
|
|
||||||
test('mit nur to oder nur from werden die Eintragspunkte ersetzt (kein Wurf, kein NaN)', () => {
|
|
||||||
for (const name of F.FX_NAMES) {
|
|
||||||
const a = shot(name, { to: { x: 90, y: 50 } }, 0).b, c = shot(name, { from: { x: 90, y: 50 } }, 0).b
|
|
||||||
assert.ok(a && Number.isFinite(a.cx) && Number.isFinite(a.cy), name); assert.ok(c && Number.isFinite(c.cx) && Number.isFinite(c.cy), name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
|
|
||||||
// Hochhaus-Layout als Daten: 10 Etagen, 19 Plätze, Räume nach Rolle, feste Orte für Effekte
|
|
||||||
const { buildLayout, homeOf, roomById, floorIndex, fy, cabTop, WORLD, ROOM_META, FLOOR_PITCH, BRIDGE_H } = await loadModule('src/components/workers/engine/layout.js')
|
|
||||||
const { ROLES, FLOORS, FLOOR_TITLES, ROOM_TITLES } = await loadModule('src/lib/workersRoster.js')
|
|
||||||
const { TEAM } = await loadModule('src/components/workers/engine/sprites.js')
|
|
||||||
|
|
||||||
const layout = buildLayout()
|
|
||||||
const rooms = (id) => layout.floors.find((f) => f.id === id).rooms.map((r) => [r.id, r.x, r.w])
|
|
||||||
const role = (workerId, floor, room) => ({ workerId, name: workerId, floor, room, model: 'haiku', role: '', caps: [] })
|
|
||||||
|
|
||||||
test('10 Etagen in FLOORS-Reihenfolge mit festen Höhen, Welt 320×256', () => {
|
|
||||||
assert.deepEqual(layout.floors.map((f) => f.id), [...FLOORS])
|
|
||||||
assert.deepEqual([WORLD.w, WORLD.h], [320, 256])
|
|
||||||
assert.deepEqual(layout.floors.map((f) => [f.top, f.floorY]), [[6, 40], [41, 63], [64, 86], [87, 109], [110, 132], [133, 155], [156, 178], [179, 201], [202, 224], [225, 247]])
|
|
||||||
assert.equal(fy(layout, 3), 95)
|
|
||||||
assert.equal(cabTop(layout, 3), 88)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Etagen tragen Index, Titel und Höhe (Brücke 34, sonst 22 plus Bodenlinie = Raster 23)', () => {
|
|
||||||
assert.equal(BRIDGE_H, 34)
|
|
||||||
assert.equal(FLOOR_PITCH, 23)
|
|
||||||
layout.floors.forEach((f, i) => {
|
|
||||||
assert.equal(f.index, i)
|
|
||||||
assert.equal(f.title, FLOOR_TITLES[f.id])
|
|
||||||
assert.equal(f.h, i === 0 ? 34 : 22, f.id)
|
|
||||||
assert.equal(f.floorY - f.top, f.h, f.id)
|
|
||||||
})
|
|
||||||
assert.equal(layout.floors[1].top - layout.floors[0].floorY, 1)
|
|
||||||
assert.equal(floorIndex(layout, 'tresor'), 8)
|
|
||||||
assert.equal(floorIndex(layout, 'gibtsnicht'), -1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Räume je Etage', () => {
|
|
||||||
assert.deepEqual(rooms('anfragen'), [['netz', 42, 46], ['kleinanzeigen', 88, 46], ['portale', 134, 46], ['foren', 180, 48]])
|
|
||||||
assert.deepEqual(rooms('gespraeche').map((r) => r[0]), ['mail', 'forum', 'posteingang', 'abschluss'])
|
|
||||||
assert.deepEqual(rooms('werkstatt'), [['tisch1', 42, 62], ['tisch2', 104, 62], ['tisch3', 166, 62]])
|
|
||||||
for (const id of ['bruecke', 'bewertung', 'pruefung', 'freigabe', 'tresor', 'keller']) {
|
|
||||||
assert.deepEqual(rooms(id).map((r) => [r[1], r[2]]), [[42, 186]], id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('alle Räume füllen die Breite 42..228 lückenlos', () => {
|
|
||||||
for (const f of layout.floors) {
|
|
||||||
let x = 42
|
|
||||||
for (const r of f.rooms) {
|
|
||||||
assert.equal(r.x, x, `${f.id}/${r.id}`)
|
|
||||||
x += r.w
|
|
||||||
}
|
|
||||||
assert.equal(x, 228, f.id)
|
|
||||||
}
|
|
||||||
// andere Raumzahlen teilen gleichmäßig, der letzte Raum nimmt den Rest
|
|
||||||
const zwei = buildLayout([role('a', 'anfragen', 'netz'), role('b', 'anfragen', 'portale')], ['anfragen'])
|
|
||||||
assert.deepEqual(zwei.floors[0].rooms.map((r) => [r.x, r.w]), [[42, 93], [135, 93]])
|
|
||||||
const fuenf = buildLayout(['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte'].map((room, i) => role(`w${i}`, 'anfragen', room)), ['anfragen'])
|
|
||||||
assert.deepEqual(fuenf.floors[0].rooms.map((r) => [r.x, r.w]), [[42, 37], [79, 37], [116, 37], [153, 37], [190, 38]])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('19 Worker haben genau einen Platz im eigenen Raum; Freigabe und Tresor ohne Worker', () => {
|
|
||||||
for (const r of ROLES) {
|
|
||||||
const h = homeOf(layout, r.workerId)
|
|
||||||
const room = roomById(layout, r.room)
|
|
||||||
const wide = r.workerId === 'otto' ? 24 : 12
|
|
||||||
assert.ok(h.x >= room.x && h.x + wide <= room.x + room.w, r.workerId)
|
|
||||||
assert.equal(h.floor, FLOORS.indexOf(r.floor))
|
|
||||||
assert.equal(h.room, r.room)
|
|
||||||
}
|
|
||||||
assert.equal(Object.keys(layout.homes).length, 19)
|
|
||||||
assert.equal(homeOf(layout, 'otto').y, 13)
|
|
||||||
assert.equal(homeOf(layout, 'kim').y, 49)
|
|
||||||
assert.deepEqual(layout.floors.filter((f) => f.rooms.some((r) => !r.workerId)).map((f) => f.id), ['freigabe', 'tresor'])
|
|
||||||
assert.deepEqual(layout.spots, { pult: { x: 135, y: 186 }, tresor: { x: 163, y: 213 }, aussen: { x: 324, y: 212 }, archiv: { x: 148, y: 231 } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Platz zentriert in der Raumbreite (Otto 24 breit, sonst 12)', () => {
|
|
||||||
assert.deepEqual(homeOf(layout, 'otto'), { x: 123, y: 13, floor: 0, room: 'bruecke' })
|
|
||||||
assert.deepEqual(homeOf(layout, 'rita'), { x: 59, y: 49, floor: 1, room: 'netz' })
|
|
||||||
assert.equal(homeOf(layout, 'arne').y, 247 - 14)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Räume tragen Art, Titel, Teamfarbe und Worker aus den Tabellen', () => {
|
|
||||||
assert.deepEqual(roomById(layout, 'kleinanzeigen'), { id: 'kleinanzeigen', kind: 'inbox', title: ROOM_TITLES.kleinanzeigen, team: 'radar', x: 88, w: 46, workerId: 'kim' })
|
|
||||||
assert.equal(roomById(layout, 'pult').workerId, null)
|
|
||||||
assert.equal(roomById(layout, 'tresor').workerId, null)
|
|
||||||
assert.equal(roomById(layout, 'gibtsnicht'), null)
|
|
||||||
assert.equal(roomById(layout, 'constructor'), null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ROOM_META: genau die 21 Räume aus ROOM_TITLES, bekannte Arten, Teamfarbe aus TEAM', () => {
|
|
||||||
assert.deepEqual(Object.keys(ROOM_META).sort(), Object.keys(ROOM_TITLES).sort())
|
|
||||||
const kinds = ['bruecke', 'radar', 'inbox', 'feed', 'board', 'scout', 'waage', 'mail', 'schreiber', 'sortier', 'vertrag', 'tisch', 'pruefung', 'pult', 'tresor', 'archiv']
|
|
||||||
assert.deepEqual([...new Set(Object.values(ROOM_META).map((m) => m.kind))].sort(), [...kinds].sort())
|
|
||||||
for (const [id, m] of Object.entries(ROOM_META)) assert.ok(Object.hasOwn(TEAM, m.team), `${id}: ${m.team}`)
|
|
||||||
const kindTeam = (id) => [ROOM_META[id].kind, ROOM_META[id].team]
|
|
||||||
assert.deepEqual(['netz', 'kleinanzeigen', 'portale', 'foren'].map(kindTeam), [['radar', 'radar'], ['inbox', 'radar'], ['feed', 'radar'], ['board', 'radar']])
|
|
||||||
assert.deepEqual(['aerzte', 'handwerk', 'selbst', 'laden'].map(kindTeam), [['scout', 'aerzte'], ['scout', 'handwerk'], ['scout', 'selbst'], ['scout', 'laden']])
|
|
||||||
assert.deepEqual(['mail', 'forum', 'posteingang', 'abschluss'].map(kindTeam), [['mail', 'freigabe'], ['schreiber', 'freigabe'], ['sortier', 'freigabe'], ['vertrag', 'freigabe']])
|
|
||||||
assert.deepEqual(['tisch1', 'tisch2', 'tisch3'].map(kindTeam), [['tisch', 'werkstatt'], ['tisch', 'werkstatt'], ['tisch', 'werkstatt']])
|
|
||||||
assert.deepEqual(['waage', 'pruefung', 'bruecke', 'pult', 'tresor', 'archiv'].map(kindTeam), [['waage', 'bewertung'], ['pruefung', 'pruefung'], ['bruecke', 'freigabe'], ['pult', 'freigabe'], ['tresor', 'tresor'], ['archiv', 'archiv']])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unbekannte Kennung gibt null, auch bei Namen aus dem Prototyp', () => {
|
|
||||||
assert.equal(homeOf(layout, 'niemand'), null)
|
|
||||||
assert.equal(homeOf(layout, 'constructor'), null)
|
|
||||||
assert.equal(homeOf(layout, '__proto__'), null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('neue Räume kommen als Daten; unbekannter Raum oder unbekannte Etage wirft', () => {
|
|
||||||
assert.throws(() => buildLayout([role('x', 'anfragen', 'zz')], ['anfragen']), /Unbekannter Raum: zz/)
|
|
||||||
assert.throws(() => buildLayout([role('x', 'oben', 'netz')], ['anfragen']), /Unbekannte Etage: oben/)
|
|
||||||
assert.throws(() => buildLayout([role('x', 'anfragen', 'constructor')], ['anfragen']), /Unbekannter Raum: constructor/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Orte für Effekte fehlen (null), wenn die Etage nicht im Layout ist', () => {
|
|
||||||
const klein = buildLayout([role('x', 'anfragen', 'netz')], ['anfragen'])
|
|
||||||
assert.deepEqual(klein.spots, { pult: null, tresor: null, aussen: null, archiv: null })
|
|
||||||
assert.equal(klein.floors.length, 1)
|
|
||||||
})
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
import { makeCtx } from './helpers/fakeCtx.mjs'
|
|
||||||
|
|
||||||
// Aufzug: Warteschlange, Zustandsautomat, Abbruch/Reset und Kabine (Quelle: entwuerfe/scene-turm.js)
|
|
||||||
const { createLift, LIFT } = await loadModule('src/components/workers/engine/lift.js')
|
|
||||||
const { buildLayout, cabTop } = await loadModule('src/components/workers/engine/layout.js')
|
|
||||||
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
|
|
||||||
const { FLOORS } = await loadModule('src/lib/workersRoster.js')
|
|
||||||
|
|
||||||
const layout = buildLayout()
|
|
||||||
function until(lift, fn, max = 12) { let t = 0; while (!fn() && t < max) { lift.update(0.05); t += 0.05 } return t }
|
|
||||||
// Schritt für Schritt bis zur Bedingung, mit Sicherung gegen Endlosschleifen bei einem Fehler im Aufzug
|
|
||||||
function stepUntil(step, fn, max = 400) { for (let i = 0; i < max && !fn(); i++) step(); assert.ok(fn(), 'Bedingung nicht erreicht') }
|
|
||||||
test('Fahrt 1 → 8: queued, open, boarded, arrived, out; Fahrgast folgt der Kabine; danach idle', () => {
|
|
||||||
const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 1, 8)
|
|
||||||
assert.equal(req.st, 'queued')
|
|
||||||
let t = until(lift, () => req.st === 'open'); assert.ok(lift.at(1)); assert.equal(lift.state, 'boarding')
|
|
||||||
req.st = 'boarded'; t += until(lift, () => req.st === 'arrived'); assert.ok(lift.at(8)); assert.equal(a.y, cabTop(layout, 8) + 7); assert.equal(a.x, LIFT.cabWx)
|
|
||||||
assert.ok(t < 12); req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle')
|
|
||||||
})
|
|
||||||
test('FIFO: zweite Anfrage wartet bis die erste ausgestiegen ist', () => {
|
|
||||||
const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2)
|
|
||||||
until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); assert.equal(r2.st, 'queued')
|
|
||||||
r1.st = 'out'; until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
|
|
||||||
})
|
|
||||||
test('cancel und reset geben die Kabine frei (kein Festhängen)', () => {
|
|
||||||
const lift = createLift(layout), a = {}, r1 = lift.request(a, 7, 1), r2 = lift.request({}, 2, 3)
|
|
||||||
lift.update(0.05); lift.cancel(a); assert.equal(r1.st, 'cancelled'); until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
|
|
||||||
lift.reset(); assert.equal(r2.st, 'cancelled'); assert.equal(lift.state, 'idle')
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
|
|
||||||
|
|
||||||
test('LIFT-Werte wie vereinbart und cabH stimmt mit der Kabinenhöhe des Layouts überein', () => {
|
|
||||||
assert.deepEqual({ ...LIFT }, { cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 })
|
|
||||||
layout.floors.forEach((f, i) => assert.equal(f.floorY - cabTop(layout, i), LIFT.cabH))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Start: Kabine steht auf Etage 2 im Leerlauf; at() gilt bis 1,5 Einheiten Abstand und ist bei ungültigem Index false', () => {
|
|
||||||
const lift = createLift(layout)
|
|
||||||
assert.equal(lift.y, cabTop(layout, 2)); assert.equal(lift.state, 'idle')
|
|
||||||
layout.floors.forEach((f, i) => assert.equal(lift.at(i), i === 2, `at(${i})`))
|
|
||||||
lift.y = cabTop(layout, 2) + 1.4; assert.ok(lift.at(2))
|
|
||||||
lift.y = cabTop(layout, 2) - 1.4; assert.ok(lift.at(2))
|
|
||||||
lift.y = cabTop(layout, 2) + 1.5; assert.ok(!lift.at(2))
|
|
||||||
for (const i of [-1, 10, 99, 2.5, NaN, '2', undefined, null]) assert.equal(lift.at(i), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Layout mit nur zwei Etagen: Startetage ist die letzte vorhandene (kein Wurf)', () => {
|
|
||||||
const small = buildLayout([], FLOORS.slice(0, 2)), lift = createLift(small)
|
|
||||||
assert.equal(lift.y, cabTop(small, 1)); assert.ok(lift.at(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Zustandsfolge einer Fahrt; das Schließen dauert 0,3 s', () => {
|
|
||||||
const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 4, 6), seen = [lift.state]
|
|
||||||
const step = () => { lift.update(0.05); if (seen.at(-1) !== lift.state) seen.push(lift.state) }
|
|
||||||
stepUntil(step, () => req.st === 'open')
|
|
||||||
assert.equal(lift.state, 'boarding')
|
|
||||||
req.st = 'boarded'
|
|
||||||
step(); assert.equal(lift.state, 'closing')
|
|
||||||
for (let i = 0; i < 5; i++) step()
|
|
||||||
assert.equal(lift.state, 'closing') // 0,25 s Tür
|
|
||||||
step(); step(); assert.equal(lift.state, 'riding') // 0,35 s
|
|
||||||
stepUntil(step, () => req.st === 'arrived')
|
|
||||||
assert.equal(lift.state, 'unloading')
|
|
||||||
step(); assert.equal(lift.state, 'unloading') // wartet auf den Fahrgast
|
|
||||||
req.st = 'out'; step()
|
|
||||||
assert.deepEqual(seen, ['idle', 'toPick', 'boarding', 'closing', 'riding', 'unloading', 'idle'])
|
|
||||||
assert.equal(req.st, 'out')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Kabine fährt mit 110 Einheiten pro Sekunde, nach oben und nach unten', () => {
|
|
||||||
const lift = createLift(layout), y0 = lift.y
|
|
||||||
lift.request({}, 8, 1)
|
|
||||||
for (let i = 0; i < 20; i++) lift.update(0.05) // 1 s
|
|
||||||
assert.ok(Math.abs(lift.y - (y0 + 110)) < 1e-9, `nach 1 s ${lift.y - y0}`)
|
|
||||||
const up = createLift(layout)
|
|
||||||
up.request({}, 0, 1)
|
|
||||||
for (let i = 0; i < 4; i++) up.update(0.05) // 0,2 s
|
|
||||||
assert.ok(Math.abs(up.y - (y0 - 22)) < 1e-9, `nach 0,2 s ${up.y - y0}`)
|
|
||||||
for (let i = 0; i < 6; i++) up.update(0.05) // Etage 0 liegt 46 Einheiten höher: erreicht, kein Überschwingen
|
|
||||||
assert.equal(up.y, cabTop(layout, 0)); assert.equal(up.state, 'boarding')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Fahrgast bleibt bis zum Schließen unberührt, folgt dann der Kabine und wird beim Aussteigen losgelassen', () => {
|
|
||||||
const lift = createLift(layout), a = { x: 12, y: 34, frame: 1 }, req = lift.request(a, 5, 1)
|
|
||||||
until(lift, () => req.st === 'open')
|
|
||||||
assert.deepEqual(a, { x: 12, y: 34, frame: 1 }) // Anfahren und Einsteigen: die Regie bewegt ihn
|
|
||||||
a.x = LIFT.cabWx; a.y = lift.y + 7; a.frame = 0 // die Regie hat ihn in die Kabine gestellt (Schritt 0, Blick nach vorn)
|
|
||||||
req.st = 'boarded'
|
|
||||||
let followed = 0
|
|
||||||
stepUntil(() => {
|
|
||||||
lift.update(0.05)
|
|
||||||
if (lift.state === 'closing' || lift.state === 'riding') {
|
|
||||||
if (a.y !== lift.y + 7 || a.x !== LIFT.cabWx || a.frame !== 0) assert.fail(`Fahrgast ${a.x}/${a.y}/${a.frame} bei Kabine ${lift.y}`)
|
|
||||||
followed++
|
|
||||||
}
|
|
||||||
}, () => req.st === 'arrived')
|
|
||||||
assert.ok(followed > 10, `folgte nur ${followed}-mal`)
|
|
||||||
assert.equal(a.y, cabTop(layout, 1) + 7)
|
|
||||||
a.x = 77; a.y = 66; a.frame = 1 // Aussteigen: die Regie setzt ihn selbst
|
|
||||||
lift.update(0.05); lift.update(0.05)
|
|
||||||
assert.deepEqual(a, { x: 77, y: 66, frame: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Fahrt auf derselben Etage öffnet sofort und schließt ab', () => {
|
|
||||||
const lift = createLift(layout), req = lift.request({}, 2, 2)
|
|
||||||
lift.update(0.05); assert.equal(req.st, 'open')
|
|
||||||
req.st = 'boarded'; until(lift, () => req.st === 'arrived'); assert.equal(req.st, 'arrived')
|
|
||||||
req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cancel während der Fahrt: Kabine frei, Fahrgast wird nicht mehr bewegt, nächste Anfrage kommt dran', () => {
|
|
||||||
const lift = createLift(layout), a = { x: LIFT.cabWx, y: 0, frame: 0 }, r1 = lift.request(a, 2, 9), r2 = lift.request({}, 3, 4)
|
|
||||||
until(lift, () => r1.st === 'open'); r1.st = 'boarded'
|
|
||||||
until(lift, () => lift.state === 'riding'); lift.update(0.05)
|
|
||||||
lift.cancel(a)
|
|
||||||
assert.equal(r1.st, 'cancelled'); assert.equal(lift.state, 'idle')
|
|
||||||
const before = { ...a }
|
|
||||||
lift.update(0.05); assert.deepEqual(a, before)
|
|
||||||
until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open'); assert.equal(lift.state, 'boarding')
|
|
||||||
assert.deepEqual(a, before)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cancel einer wartenden Anfrage: nie bedient, die übrigen bleiben in Reihenfolge; mehrere Anfragen eines Agenten fallen alle', () => {
|
|
||||||
const lift = createLift(layout), a2 = {}
|
|
||||||
const r1 = lift.request({}, 1, 4), r2 = lift.request(a2, 5, 2), r3 = lift.request({}, 6, 3), r4 = lift.request(a2, 7, 3)
|
|
||||||
lift.cancel(a2)
|
|
||||||
assert.deepEqual([r1.st, r2.st, r3.st, r4.st], ['queued', 'cancelled', 'queued', 'cancelled'])
|
|
||||||
until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out'
|
|
||||||
const seen = new Set()
|
|
||||||
until(lift, () => { seen.add(r2.st).add(r4.st); return r3.st === 'open' })
|
|
||||||
assert.equal(r3.st, 'open')
|
|
||||||
assert.deepEqual([...seen], ['cancelled'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cancel eines unbekannten oder bereits ausgestiegenen Agenten ändert nichts', () => {
|
|
||||||
const lift = createLift(layout), a = {}, r = lift.request(a, 2, 3)
|
|
||||||
until(lift, () => r.st === 'open'); r.st = 'boarded'; until(lift, () => r.st === 'arrived'); r.st = 'out'
|
|
||||||
lift.cancel({}); lift.cancel(undefined); assert.equal(r.st, 'out'); assert.equal(lift.state, 'unloading')
|
|
||||||
lift.cancel(a); assert.equal(r.st, 'out') // ausgestiegen bleibt ausgestiegen
|
|
||||||
lift.update(0.05); assert.equal(lift.state, 'idle')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('update überspringt von außen abgebrochene Anfragen (wartend und laufend)', () => {
|
|
||||||
const lift = createLift(layout)
|
|
||||||
const r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3)
|
|
||||||
r2.st = 'cancelled'
|
|
||||||
until(lift, () => r1.st === 'open')
|
|
||||||
r1.st = 'cancelled' // laufende Anfrage von außen abgebrochen: kein Festhängen im Zustand boarding
|
|
||||||
lift.update(0.05)
|
|
||||||
assert.equal(lift.state, 'toPick')
|
|
||||||
until(lift, () => r3.st === 'open'); assert.equal(r3.st, 'open')
|
|
||||||
assert.equal(r2.st, 'cancelled'); assert.equal(r1.st, 'cancelled')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('reset bricht Wartende und die laufende Anfrage ab, ausgestiegene bleiben; danach fährt der Aufzug wieder', () => {
|
|
||||||
const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3)
|
|
||||||
until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out'; lift.update(0.05)
|
|
||||||
until(lift, () => r2.st === 'open')
|
|
||||||
lift.reset()
|
|
||||||
assert.deepEqual([r1.st, r2.st, r3.st], ['out', 'cancelled', 'cancelled']); assert.equal(lift.state, 'idle')
|
|
||||||
lift.update(0.05); assert.equal(lift.state, 'idle') // Warteschlange ist leer
|
|
||||||
const r4 = lift.request({}, 3, 5); until(lift, () => r4.st === 'open'); assert.equal(r4.st, 'open')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ungültige Anfragen (Etage, Agent) kommen abgebrochen zurück und blockieren nichts', () => {
|
|
||||||
const lift = createLift(layout)
|
|
||||||
for (const [a, from, to] of [[{}, -1, 3], [{}, 1, 10], [{}, 1.5, 2], [{}, '1', 2], [{}, 1], [{}, NaN, 2], [null, 1, 2], [undefined, 1, 2], ['x', 1, 2]]) {
|
|
||||||
const r = lift.request(a, from, to)
|
|
||||||
assert.equal(r.st, 'cancelled', JSON.stringify([from, to]))
|
|
||||||
assert.equal(r.a, a)
|
|
||||||
}
|
|
||||||
lift.update(0.05); assert.equal(lift.state, 'idle')
|
|
||||||
const ok = lift.request({}, 1, 2); until(lift, () => ok.st === 'open'); assert.equal(ok.st, 'open')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unbrauchbares dt (0, negativ, NaN, Infinity, kein Zahlentyp) ändert nichts', () => {
|
|
||||||
const lift = createLift(layout), r = lift.request({}, 8, 1), y0 = lift.y
|
|
||||||
for (const dt of [0, -1, NaN, Infinity, undefined, '0.05']) lift.update(dt)
|
|
||||||
assert.equal(lift.state, 'idle'); assert.equal(lift.y, y0); assert.equal(r.st, 'queued')
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Kabine zeichnen (Raster 3, kein setGrid: logische Einheit = 3 Geräte-Pixel) ----
|
|
||||||
|
|
||||||
const K = 3
|
|
||||||
const rect = (x, y, w, h, fill) => ({ x: x * K, y: y * K, w: w * K, h: h * K, fill })
|
|
||||||
|
|
||||||
test('drawBack: Seil von oben bis zur Kabine, Rückwand mit Handlauf und Fußleiste', () => {
|
|
||||||
const lift = createLift(layout), { ctx, rects } = makeCtx(), cy = cabTop(layout, 2)
|
|
||||||
lift.drawBack(ctx)
|
|
||||||
assert.deepEqual(rects, [
|
|
||||||
rect(28, 9, 1, cy - 9, PAL.metal2),
|
|
||||||
rect(18, cy, 20, 21, '#f1f3f8'),
|
|
||||||
rect(19, cy + 11, 18, 1, '#cfd5df'),
|
|
||||||
rect(19, cy + 18, 18, 2, '#e1e5ec'),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawFront: Glasschleier, Rahmen, Lampe und Aufhängung; save/restore gleichen sich aus', () => {
|
|
||||||
const lift = createLift(layout), { ctx, rects, counts, alphas } = makeCtx(), cy = cabTop(layout, 2)
|
|
||||||
lift.drawFront(ctx)
|
|
||||||
assert.deepEqual(rects, [
|
|
||||||
rect(19, cy + 1, 18, 19, '#ffffff'),
|
|
||||||
rect(18, cy, 20, 1, PAL.metal2),
|
|
||||||
rect(18, cy + 20, 20, 1, PAL.metal2),
|
|
||||||
rect(18, cy, 1, 21, PAL.metal2),
|
|
||||||
rect(37, cy, 1, 21, PAL.metal2),
|
|
||||||
rect(24, cy + 1, 8, 1, '#ffe9a8'),
|
|
||||||
rect(26, cy - 2, 4, 2, PAL.metal2),
|
|
||||||
])
|
|
||||||
assert.deepEqual(alphas, [0.18]); assert.equal(counts.save, 1); assert.equal(counts.restore, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Kabine wird an der gerundeten Fahrposition gezeichnet und bleibt im Schacht (x 18..38)', () => {
|
|
||||||
const lift = createLift(layout)
|
|
||||||
lift.request({}, 8, 1)
|
|
||||||
lift.update(0.05); lift.update(0.05); lift.update(0.05)
|
|
||||||
assert.ok(!Number.isInteger(lift.y), `Testvoraussetzung: Kabine zwischen Rastern (y ${lift.y})`)
|
|
||||||
const cy = Math.round(lift.y), back = makeCtx(), front = makeCtx()
|
|
||||||
lift.drawBack(back.ctx); lift.drawFront(front.ctx)
|
|
||||||
assert.deepEqual(back.rects[1], rect(18, cy, 20, 21, '#f1f3f8'))
|
|
||||||
assert.deepEqual(back.rects[0], rect(28, 9, 1, cy - 9, PAL.metal2))
|
|
||||||
assert.deepEqual(front.rects[1], rect(18, cy, 20, 1, PAL.metal2))
|
|
||||||
for (const r of [...back.rects.slice(1), ...front.rects]) assert.ok(r.x >= 18 * K && r.x + r.w <= 38 * K, JSON.stringify(r))
|
|
||||||
})
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
import { makeCtx } from './helpers/fakeCtx.mjs'
|
|
||||||
|
|
||||||
// Engine-Requisiten: Möbel, Briefkasten, Tresor, Waage, Schild und die 7×7-Branchen-Icons
|
|
||||||
const P = await loadModule('src/components/workers/engine/props.js')
|
|
||||||
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
|
|
||||||
const { SEARCH_ROOMS } = await loadModule('src/lib/workersRoster.js')
|
|
||||||
|
|
||||||
test('alle Requisiten zeichnen ohne Wurf und mindestens ein Rechteck', () => {
|
|
||||||
const calls = { desk: (c) => P.drawDesk(c, 0, 0, 18, '#f00'), monitor: (c) => P.drawMonitor(c, 0, 0, 1, '#7fb2ff'), cabinet: (c) => P.drawCabinet(c, 0, 0, 3), plant: (c) => P.drawPlant(c, 0, 0),
|
|
||||||
coin: (c) => P.drawCoin(c, 0, 0, 0.3), trophy: (c) => P.drawTrophy(c, 0, 0), stampOk: (c) => P.drawStamp(c, 0, 0, true), stampNo: (c) => P.drawStamp(c, 0, 0, false),
|
|
||||||
radar: (c) => P.drawRadar(c, 0, 0, 1), mailbox: (c) => P.drawMailbox(c, 0, 0, 2), vault: (c) => P.drawVault(c, 0, 0, 1), scale: (c) => P.drawScale(c, 0, 0, 2), tray: (c) => P.drawTray(c, 0, 0, true) }
|
|
||||||
for (const [name, fn] of Object.entries(calls)) { const { ctx, rects } = makeCtx(); fn(ctx); assert.ok(rects.length >= 1, name) }
|
|
||||||
})
|
|
||||||
test('Briefkasten-Abzeichen nur bei count > 0; Monitor aus zeigt keine Inhaltszeilen', () => {
|
|
||||||
const red = (n) => { const { ctx, rects } = makeCtx(); P.drawMailbox(ctx, 0, 0, n); return rects.filter((r) => r.fill === PAL.red).length }
|
|
||||||
assert.equal(red(0), 0); assert.ok(red(3) >= 1)
|
|
||||||
const lines = (on) => { const { ctx, rects } = makeCtx(); P.drawMonitor(ctx, 0, 0, 1, '#abcdef', on); return rects.filter((r) => r.fill === '#abcdef').length }
|
|
||||||
assert.ok(lines(true) >= 2); assert.equal(lines(false), 0)
|
|
||||||
})
|
|
||||||
test('ICONS: genau die 8 Suchräume, je 7 Zeilen à 7 Zeichen; drawSign schreibt den Text', () => {
|
|
||||||
assert.deepEqual(Object.keys(P.ICONS).sort(), ['aerzte', 'foren', 'handwerk', 'kleinanzeigen', 'laden', 'netz', 'portale', 'selbst'])
|
|
||||||
for (const [k, rows] of Object.entries(P.ICONS)) { assert.equal(rows.length, 7, k); for (const r of rows) assert.match(r, /^[.xa]{7}$/, k) }
|
|
||||||
const { ctx, texts, rects } = makeCtx(); P.drawSign(ctx, 0, 0, 40, 'noch nicht angeschlossen'); assert.equal(texts[0].text, 'noch nicht angeschlossen'); assert.ok(rects.length >= 2)
|
|
||||||
const t = makeCtx(); P.drawIcon(t.ctx, 'aerzte', 0, 0, '#3fb6b2'); assert.ok(t.rects.length >= 5)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
|
|
||||||
|
|
||||||
test('ICONS: Schlüssel gleich SEARCH_ROOMS, eingefroren, jedes Icon hat Linien und die Zeichen stehen nur in .xa', () => {
|
|
||||||
assert.deepEqual([...SEARCH_ROOMS].sort(), Object.keys(P.ICONS).sort())
|
|
||||||
assert.ok(Object.isFrozen(P.ICONS))
|
|
||||||
for (const [k, rows] of Object.entries(P.ICONS)) {
|
|
||||||
assert.ok(Object.isFrozen(rows), k)
|
|
||||||
assert.ok(rows.join('').includes('x'), k)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('drawIcon: x in der Farbe, a in Gold, nichts außerhalb von 7 × 7; unbekannter Name zeichnet nichts und wirft nicht', () => {
|
|
||||||
const { ctx, rects } = makeCtx(); P.drawIcon(ctx, 'handwerk', 10, 20, '#3fb6b2')
|
|
||||||
assert.ok(rects.some((r) => r.fill === '#3fb6b2')); assert.ok(rects.some((r) => r.fill === PAL.gold))
|
|
||||||
assert.ok(rects.every((r) => r.fill === '#3fb6b2' || r.fill === PAL.gold))
|
|
||||||
assert.ok(rects.every((r) => r.x >= 30 && r.x + r.w <= 30 + 21 && r.y >= 60 && r.y + r.h <= 60 + 21))
|
|
||||||
for (const name of ['gibt-es-nicht', 'constructor', '__proto__', 'toString', undefined, null]) {
|
|
||||||
const e = makeCtx(); assert.doesNotThrow(() => P.drawIcon(e.ctx, name, 0, 0, '#000'), String(name)); assert.equal(e.rects.length, 0, String(name))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('drawIcon: jedes der acht Icons zeichnet sichtbar (mindestens 5 Rechtecke)', () => {
|
|
||||||
for (const name of Object.keys(P.ICONS)) { const { ctx, rects } = makeCtx(); P.drawIcon(ctx, name, 0, 0, '#123456'); assert.ok(rects.length >= 5, name) }
|
|
||||||
})
|
|
||||||
test('drawMonitor: Standard ist an; aus hat kein Blinken, an blinkt mit t', () => {
|
|
||||||
const draw = (t, on) => { const { ctx, rects } = makeCtx(); if (on === undefined) P.drawMonitor(ctx, 0, 0, t, '#abcdef'); else P.drawMonitor(ctx, 0, 0, t, '#abcdef', on); return JSON.stringify(rects) }
|
|
||||||
assert.equal(draw(0.2), draw(0.2, true))
|
|
||||||
assert.notEqual(draw(0.2, true), draw(0.7, true))
|
|
||||||
assert.equal(draw(0.2, false), draw(0.7, false))
|
|
||||||
const off = makeCtx(); P.drawMonitor(off.ctx, 0, 0, 1, '#abcdef', false); assert.ok(off.rects.length >= 2) // Rahmen und Schirm bleiben
|
|
||||||
})
|
|
||||||
test('drawTray: voll zeigt Papier über dem Rand, beide bleiben in 10 × 4', () => {
|
|
||||||
const draw = (filled) => { const { ctx, rects } = makeCtx(); P.drawTray(ctx, 5, 7, filled); return rects }
|
|
||||||
const full = draw(true); const empty = draw(false)
|
|
||||||
assert.ok(full.some((r) => r.fill === PAL.w)); assert.ok(!empty.some((r) => r.fill === PAL.w))
|
|
||||||
for (const rs of [full, empty]) assert.ok(rs.every((r) => r.x >= 15 && r.x + r.w <= 15 + 30 && r.y >= 21 && r.y + r.h <= 21 + 12))
|
|
||||||
assert.equal(Math.max(...full.map((r) => r.x + r.w)) - Math.min(...full.map((r) => r.x)), 30)
|
|
||||||
})
|
|
||||||
test('drawSign: dunkler Rand, weiße Fläche, Text mittig im Schild', () => {
|
|
||||||
const { ctx, rects, texts } = makeCtx(); P.drawSign(ctx, 4, 6, 40, 'Ablage')
|
|
||||||
assert.equal(rects[0].fill, PAL.o); assert.ok(rects.some((r) => r.fill === PAL.w))
|
|
||||||
const border = rects[0]; assert.deepEqual([border.x, border.y, border.w], [12, 18, 120])
|
|
||||||
assert.ok(rects.slice(1).every((r) => r.x > border.x && r.x + r.w < border.x + border.w && r.y > border.y && r.y + r.h < border.y + border.h))
|
|
||||||
assert.equal(texts.length, 1); assert.equal(texts[0].x, (4 + 20) * 3)
|
|
||||||
assert.ok(texts[0].y > border.y && texts[0].y < border.y + border.h)
|
|
||||||
})
|
|
||||||
test('drawStamp: ok grün, nein rot; drawCoin dreht sich mit t; drawScale kippt mit tilt', () => {
|
|
||||||
const fills = (fn) => { const { ctx, rects } = makeCtx(); fn(ctx); return new Set(rects.map((r) => r.fill)) }
|
|
||||||
assert.ok(fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.green)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.red))
|
|
||||||
assert.ok(fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.red)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.green))
|
|
||||||
const coinW = (t) => { const { ctx, rects } = makeCtx(); P.drawCoin(ctx, 0, 0, t); return rects[0].w }
|
|
||||||
assert.notEqual(coinW(0), coinW(1 / 6))
|
|
||||||
const pans = (tilt) => { const { ctx, rects } = makeCtx(); P.drawScale(ctx, 0, 0, tilt); return rects.filter((r) => r.w === 15 && r.h === 6).map((r) => r.y) }
|
|
||||||
assert.equal(pans(0).length, 2); assert.notDeepEqual(pans(2), pans(-2))
|
|
||||||
})
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
import { makeCtx } from './helpers/fakeCtx.mjs'
|
|
||||||
|
|
||||||
// Räume nach kind, Pokal-Seiten, graue Räume und Gebäudehülle (Quelle: entwuerfe/scene-turm.js Z. 99-314)
|
|
||||||
const R = await loadModule('src/components/workers/engine/rooms.js')
|
|
||||||
const B = await loadModule('src/components/workers/engine/building.js')
|
|
||||||
const { buildLayout, ROOM_META } = await loadModule('src/components/workers/engine/layout.js')
|
|
||||||
const { PAL, TEAM } = await loadModule('src/components/workers/engine/sprites.js')
|
|
||||||
const { FLOOR_TITLES, ROOM_TITLES } = await loadModule('src/lib/workersRoster.js')
|
|
||||||
|
|
||||||
const layout = buildLayout()
|
|
||||||
const draw = (look, t = 1) => {
|
|
||||||
const m = makeCtx()
|
|
||||||
R.drawRooms(m.ctx, layout, t, look)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
const SIGN = 'noch nicht angeschlossen'
|
|
||||||
const looks = (patch = {}) => Object.assign(R.createLook(), patch)
|
|
||||||
const count = (rects, fill) => rects.filter((r) => r.fill === fill).length
|
|
||||||
// Pokale sind die 7 × 3 großen Goldflächen (k = 3: 21 × 9 Geräte-Pixel)
|
|
||||||
const trophies = (look) => draw(look).rects.filter((r) => r.fill === PAL.gold && r.w === 21 && r.h === 9).length
|
|
||||||
|
|
||||||
test('trophyPaging: 8 je Seite, Seite wird geklemmt', () => {
|
|
||||||
assert.deepEqual(R.trophyPaging(0, 0), { shown: 0, page: 0, pages: 1, from: 0 })
|
|
||||||
assert.deepEqual(R.trophyPaging(8, 0), { shown: 8, page: 0, pages: 1, from: 0 })
|
|
||||||
assert.deepEqual(R.trophyPaging(9, 1), { shown: 1, page: 1, pages: 2, from: 8 })
|
|
||||||
assert.deepEqual(R.trophyPaging(9, 5), { shown: 1, page: 1, pages: 2, from: 8 })
|
|
||||||
assert.deepEqual(R.trophyPaging(25, -1), { shown: 8, page: 0, pages: 4, from: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('alle 21 Räume zeichnen, jeder Titel erscheint', () => {
|
|
||||||
const { texts, rects } = draw(R.createLook())
|
|
||||||
assert.ok(rects.length > 300)
|
|
||||||
for (const title of Object.values(ROOM_TITLES)) assert.ok(texts.some((x) => x.text === title), title)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('graue Räume tragen das Schild, sonst keins (Review Focus 4)', () => {
|
|
||||||
const look = R.createLook()
|
|
||||||
look.greyRooms = new Set(['kleinanzeigen', 'portale'])
|
|
||||||
assert.equal(draw(look).texts.filter((x) => x.text === SIGN).length, 2)
|
|
||||||
assert.equal(draw(R.createLook()).texts.filter((x) => x.text === SIGN).length, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Pult-Zahl, Pokal- und Bot-Lead-Zähler', () => {
|
|
||||||
const look = R.createLook()
|
|
||||||
look.pultOpen = 7
|
|
||||||
look.dealsMonth = 9
|
|
||||||
look.botLeads = 112
|
|
||||||
const t = draw(look).texts.map((x) => x.text)
|
|
||||||
assert.ok(t.includes('7'))
|
|
||||||
assert.ok(t.includes('9 Deals'))
|
|
||||||
assert.ok(t.includes('112 Bot-Leads'))
|
|
||||||
assert.ok(!draw(R.createLook()).texts.some((x) => x.text === '7'))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Gebäude: Etagenschilder, Lampen, Pausenschleier', () => {
|
|
||||||
const m = makeCtx()
|
|
||||||
B.drawTower(m.ctx, layout, 1, { at: (i) => i === 3 })
|
|
||||||
for (const title of Object.values(FLOOR_TITLES)) assert.ok(m.texts.some((x) => x.text === title), title)
|
|
||||||
const on = makeCtx()
|
|
||||||
const look = R.createLook()
|
|
||||||
look.paused = true
|
|
||||||
B.drawPauseVeil(on.ctx, look)
|
|
||||||
assert.ok(on.texts.some((x) => x.text === 'Pausiert'))
|
|
||||||
const off = makeCtx()
|
|
||||||
B.drawPauseVeil(off.ctx, R.createLook())
|
|
||||||
assert.equal(off.texts.length, 0)
|
|
||||||
B.drawOutside(makeCtx().ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Ergänzungen: Verhalten, das die fünf Tests oben nicht berühren ----
|
|
||||||
|
|
||||||
test('createLook: Ausgangszustand ohne gemeinsame Mengen zwischen zwei Aufrufen', () => {
|
|
||||||
const a = R.createLook()
|
|
||||||
const b = R.createLook()
|
|
||||||
assert.ok(a.greyRooms instanceof Set && a.busyRooms instanceof Set)
|
|
||||||
assert.equal(a.greyRooms.size + a.busyRooms.size, 0)
|
|
||||||
assert.deepEqual({ ...a, greyRooms: 0, busyRooms: 0 }, { greyRooms: 0, busyRooms: 0, pultOpen: 0, dealsMonth: 0, botLeads: 0, trophyPage: 0, pageT: 0, trophyPop: 0, vaultFlash: 0, paused: false })
|
|
||||||
a.busyRooms.add('netz')
|
|
||||||
assert.equal(b.busyRooms.size, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ROOM_DRAWERS: eine Zeichnung für jede Raumart des Layouts, eingefroren', () => {
|
|
||||||
const kinds = new Set(Object.values(ROOM_META).map((m) => m.kind))
|
|
||||||
assert.equal(kinds.size, 16)
|
|
||||||
for (const kind of kinds) assert.equal(typeof R.ROOM_DRAWERS[kind], 'function', kind)
|
|
||||||
assert.ok(Object.isFrozen(R.ROOM_DRAWERS))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('jeder Raum zeichnet sichtbaren Inhalt über Tint und Titel hinaus (kein leerer Raum)', () => {
|
|
||||||
// Rechtecke je Raum zählen: Raum für Raum in ein eigenes Layout mit nur dieser Etage
|
|
||||||
for (const floor of layout.floors) {
|
|
||||||
for (const room of floor.rooms) {
|
|
||||||
const single = { ...layout, floors: [{ ...floor, rooms: [room] }] }
|
|
||||||
const m = makeCtx()
|
|
||||||
R.drawRooms(m.ctx, single, 1, R.createLook())
|
|
||||||
assert.ok(m.rects.length >= 5, room.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('trophyPaging: ungültige Werte fallen auf eine leere erste Seite zurück', () => {
|
|
||||||
const empty = { shown: 0, page: 0, pages: 1, from: 0 }
|
|
||||||
assert.deepEqual(R.trophyPaging(NaN, NaN), empty)
|
|
||||||
assert.deepEqual(R.trophyPaging(undefined, undefined), empty)
|
|
||||||
assert.deepEqual(R.trophyPaging(-4, 2), empty)
|
|
||||||
assert.deepEqual(R.trophyPaging(16, 1), { shown: 8, page: 1, pages: 2, from: 8 })
|
|
||||||
assert.deepEqual(R.trophyPaging(5, 0, 2), { shown: 2, page: 0, pages: 3, from: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Pokalbrett zeigt die gewählte Seite; der neueste Pokal blinkt bei trophyPop', () => {
|
|
||||||
assert.equal(trophies(looks()), 0)
|
|
||||||
assert.equal(trophies(looks({ dealsMonth: 3 })), 3)
|
|
||||||
assert.equal(trophies(looks({ dealsMonth: 20 })), 8)
|
|
||||||
assert.equal(trophies(looks({ dealsMonth: 9, trophyPage: 1 })), 1)
|
|
||||||
assert.equal(trophies(looks({ dealsMonth: 9, trophyPage: 7 })), 1)
|
|
||||||
// floor(0.25 * 10) = 2 -> gerade: sichtbar; floor(0.15 * 10) = 1 -> ungerade: der neueste Pokal fehlt
|
|
||||||
assert.equal(trophies(looks({ dealsMonth: 3, trophyPop: 0.25 })), 3)
|
|
||||||
assert.equal(trophies(looks({ dealsMonth: 3, trophyPop: 0.15 })), 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Tresor blitzt Gold bei vaultFlash, sonst nicht', () => {
|
|
||||||
const gold = (look) => draw(look).alphas.filter((a) => a > 0.1).length
|
|
||||||
assert.ok(gold(looks({ vaultFlash: 0.7 })) > gold(looks()))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Pult-Zahl: 0 zeigt keine Zahl, ab 100 steht 99+', () => {
|
|
||||||
const nums = (n) => draw(looks({ pultOpen: n })).texts.map((x) => x.text).filter((x) => /^\d+\+?$/.test(x))
|
|
||||||
assert.deepEqual(nums(0), [])
|
|
||||||
assert.deepEqual(nums(120), ['99+'])
|
|
||||||
assert.deepEqual(nums(99), ['99'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Zähler zeigen immer ihre Zahl, auch 0', () => {
|
|
||||||
const t = draw(R.createLook()).texts.map((x) => x.text)
|
|
||||||
assert.ok(t.includes('0 Deals'))
|
|
||||||
assert.ok(t.includes('0 Bot-Leads'))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Monitore laufen nur in Räumen aus busyRooms', () => {
|
|
||||||
const lines = (look) => count(draw(look).rects, TEAM.aerzte)
|
|
||||||
const idle = lines(looks())
|
|
||||||
const busy = lines(looks({ busyRooms: new Set(['aerzte']) }))
|
|
||||||
assert.ok(busy > idle)
|
|
||||||
// ein anderer Raum arbeitet: Ärzte & Praxen bleibt dunkel
|
|
||||||
assert.equal(lines(looks({ busyRooms: new Set(['handwerk']) })), idle)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('graue Räume: Monitor aus trotz busyRooms, pult und tresor werden nie grau', () => {
|
|
||||||
const lines = (look) => count(draw(look).rects, TEAM.aerzte)
|
|
||||||
const grey = looks({ greyRooms: new Set(['aerzte']), busyRooms: new Set(['aerzte']) })
|
|
||||||
const greyIdle = looks({ greyRooms: new Set(['aerzte']) })
|
|
||||||
assert.equal(lines(grey), lines(greyIdle))
|
|
||||||
const never = looks({ greyRooms: new Set(['pult', 'tresor', 'gibt-es-nicht']) })
|
|
||||||
assert.equal(draw(never).texts.filter((x) => x.text === SIGN).length, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Schild steht innerhalb des grauen Raums und in der Etage seiner Figur', () => {
|
|
||||||
const look = looks({ greyRooms: new Set(['kleinanzeigen']) })
|
|
||||||
const m = draw(look)
|
|
||||||
const sign = m.texts.find((x) => x.text === SIGN)
|
|
||||||
const floor = layout.floors.find((f) => f.id === 'anfragen')
|
|
||||||
const room = floor.rooms.find((r) => r.id === 'kleinanzeigen')
|
|
||||||
// Mitte des Raums (k = 3, align center), Zeile innerhalb der Etage
|
|
||||||
assert.equal(sign.x, Math.round((room.x + room.w / 2) * 3))
|
|
||||||
assert.ok(sign.y >= floor.top * 3 && sign.y < floor.floorY * 3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unbekannte Raumart zeichnet nur Titel und Tint, ohne Wurf', () => {
|
|
||||||
const odd = { ...layout, floors: layout.floors.map((f) => ({ ...f, rooms: f.rooms.map((r) => ({ ...r, kind: 'gibt-es-nicht' })) })) }
|
|
||||||
const m = makeCtx()
|
|
||||||
assert.doesNotThrow(() => R.drawRooms(m.ctx, odd, 1, R.createLook()))
|
|
||||||
assert.ok(m.texts.some((x) => x.text === ROOM_TITLES.netz))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawRooms hält save/restore im Gleichgewicht', () => {
|
|
||||||
const m = draw(looks({ greyRooms: new Set(['netz']), pultOpen: 3, dealsMonth: 2 }))
|
|
||||||
assert.equal(m.counts.save, m.counts.restore)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Gebäude: Bodenlinie liegt auf dem Boden der Tresor-Etage (y 224)', () => {
|
|
||||||
const tresor = layout.floors.find((f) => f.id === 'tresor')
|
|
||||||
assert.equal(tresor.floorY, 224)
|
|
||||||
const m = makeCtx()
|
|
||||||
B.drawOutside(m.ctx)
|
|
||||||
// k = 3: volle Breite 960, eine logische Einheit hoch, auf y 672
|
|
||||||
assert.ok(m.rects.some((r) => r.x === 0 && r.y === 224 * 3 && r.w === 960 && r.h === 3))
|
|
||||||
// Erde unter der Bodenlinie, bis zum Weltrand
|
|
||||||
assert.ok(m.rects.some((r) => r.y === 225 * 3 && r.h === (256 - 225) * 3))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Gebäude: Lampe der Etage mit Kabine leuchtet grün, keine ohne Kabine', () => {
|
|
||||||
const green = (at) => {
|
|
||||||
const m = makeCtx()
|
|
||||||
B.drawTower(m.ctx, layout, 1, { at })
|
|
||||||
return count(m.rects, PAL.green)
|
|
||||||
}
|
|
||||||
const none = green(() => false)
|
|
||||||
assert.equal(green((i) => i === 3), none + 1)
|
|
||||||
assert.equal(green((i) => i === 0 || i === 9), none + 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Gebäude: Etagenschilder rechts außen bei x 234, in der Zeile ihrer Etage', () => {
|
|
||||||
const m = makeCtx()
|
|
||||||
B.drawTower(m.ctx, layout, 1, { at: () => false })
|
|
||||||
for (const floor of layout.floors) {
|
|
||||||
const sign = m.texts.find((x) => x.text === floor.title)
|
|
||||||
assert.equal(sign.x, 234 * 3, floor.id)
|
|
||||||
assert.ok(sign.y >= floor.top * 3 && sign.y < floor.floorY * 3, floor.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Gebäude: Hülle reicht bis zum Fundament der letzten Etage', () => {
|
|
||||||
const m = makeCtx()
|
|
||||||
B.drawTower(m.ctx, layout, 1, { at: () => false })
|
|
||||||
const last = layout.floors[layout.floors.length - 1]
|
|
||||||
const bottom = Math.max(...m.rects.map((r) => (r.y + r.h) / 3))
|
|
||||||
assert.equal(bottom, last.floorY + 2)
|
|
||||||
assert.ok(m.rects.every((r) => r.x >= 0 && (r.x + r.w) / 3 <= 320))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Pausenschleier: 45 % Weiß über der ganzen Welt, Aufrufe gleichen sich aus', () => {
|
|
||||||
const m = makeCtx()
|
|
||||||
B.drawPauseVeil(m.ctx, looks({ paused: true }))
|
|
||||||
assert.ok(m.alphas.includes(0.45))
|
|
||||||
const veil = m.rects.find((r) => r.fill === PAL.w)
|
|
||||||
assert.ok(veil.x <= 0 && veil.y <= 0 && veil.x + veil.w >= 960 && veil.y + veil.h >= 768)
|
|
||||||
assert.equal(m.counts.save, m.counts.restore)
|
|
||||||
const off = makeCtx()
|
|
||||||
B.drawPauseVeil(off.ctx, R.createLook())
|
|
||||||
assert.equal(off.rects.length, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Gebäude: Layout ohne Etagen zeichnet nichts und wirft nicht', () => {
|
|
||||||
const m = makeCtx()
|
|
||||||
assert.doesNotThrow(() => B.drawTower(m.ctx, { floors: [], homes: {}, spots: {} }, 1, { at: () => false }))
|
|
||||||
assert.doesNotThrow(() => R.drawRooms(m.ctx, { floors: [], homes: {}, spots: {} }, 1, R.createLook()))
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Fix-Runde 1: Zeichnung und Effektorte, graue Räume ----
|
|
||||||
|
|
||||||
// Rechtecke der Raumzeichnung, die den Mittelpunkt der Einheitszelle (x, y) überdecken; die Tönung, die den ganzen Raum füllt, zählt nicht
|
|
||||||
const hitsAt = (floorId, spot, look = R.createLook()) => {
|
|
||||||
const floor = layout.floors.find((f) => f.id === floorId)
|
|
||||||
const full = Math.max(...floor.rooms.map((r) => r.w)) * 3
|
|
||||||
const m = makeCtx()
|
|
||||||
R.drawRooms(m.ctx, { ...layout, floors: [floor] }, 1, look)
|
|
||||||
const cx = spot.x * 3 + 1
|
|
||||||
const cy = spot.y * 3 + 1
|
|
||||||
return m.rects.filter((r) => r.w < full && cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h)
|
|
||||||
}
|
|
||||||
|
|
||||||
test('Zeichnung trifft den Spot: Briefkasten am Pult, Spitze genau bei spots.pult', () => {
|
|
||||||
const spot = layout.spots.pult
|
|
||||||
assert.ok(hitsAt('freigabe', spot).some((r) => r.fill === TEAM.freigabe), 'Briefkasten unter dem Spot')
|
|
||||||
// Spitze: eine Zeile darüber ist kein Briefkasten mehr
|
|
||||||
assert.ok(!hitsAt('freigabe', { x: spot.x, y: spot.y - 1 }).some((r) => r.fill === TEAM.freigabe))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Zeichnung trifft den Spot: Tresortür in der Mitte von spots.tresor', () => {
|
|
||||||
const hit = hitsAt('tresor', layout.spots.tresor)
|
|
||||||
assert.ok(hit.some((r) => r.fill === PAL.metal2 && r.w === 22 * 3 && r.h === 20 * 3), 'Tresorkörper unter dem Spot')
|
|
||||||
// die 6 × 6 große Türplatte (x + 8 ... x + 14) umschließt den Spot
|
|
||||||
assert.ok(hit.some((r) => r.fill === PAL.metal && r.w === 6 * 3 && r.h === 6 * 3), 'Türplatte unter dem Spot')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Zeichnung trifft den Spot: Schublade eines Aktenschranks bei spots.archiv, nicht auf Arne', () => {
|
|
||||||
const spot = layout.spots.archiv
|
|
||||||
const hit = hitsAt('keller', spot)
|
|
||||||
// oberste Schubladenfront (10 × 5) des ersten Aktenschranks
|
|
||||||
assert.ok(hit.some((r) => r.fill === PAL.metal && r.w === 10 * 3 && r.h === 5 * 3), 'Schubladenfront unter dem Spot')
|
|
||||||
assert.ok(hit.some((r) => r.fill === PAL.metal2 && r.w === 12 * 3), 'Schrankkorpus unter dem Spot')
|
|
||||||
// Arnes Platz (Körper 12 breit, 14 hoch, Hut ab y - 2) liegt nicht auf dem Spot
|
|
||||||
const arne = layout.homes.arne
|
|
||||||
assert.ok(!(spot.x >= arne.x && spot.x < arne.x + 12 && spot.y >= arne.y - 2 && spot.y < arne.y + 14))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('grauer Raum: Teamfarbton bleibt, das Schild ersetzt den Titel (kein Platz für beides)', () => {
|
|
||||||
const floor = layout.floors.find((f) => f.id === 'anfragen')
|
|
||||||
const room = floor.rooms.find((r) => r.id === 'kleinanzeigen')
|
|
||||||
assert.equal(room.w, 46)
|
|
||||||
const run = (look) => {
|
|
||||||
const m = makeCtx()
|
|
||||||
R.drawRooms(m.ctx, { ...layout, floors: [{ ...floor, rooms: [room] }] }, 1, look)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
const normal = run(looks())
|
|
||||||
const grey = run(looks({ greyRooms: new Set(['kleinanzeigen']) }))
|
|
||||||
const tintRects = (m) => m.rects.filter((r) => r.fill === TEAM[room.team] && r.w === room.w * 3 && r.h === floor.h * 3)
|
|
||||||
assert.equal(tintRects(normal).length, 1)
|
|
||||||
assert.equal(tintRects(grey).length, 1, 'Teamfarbton auch im grauen Raum')
|
|
||||||
assert.ok(grey.alphas.includes(0.1) && grey.alphas.includes(0.45), 'Teamfarbton 0,10, darüber die Grautönung 0,45')
|
|
||||||
assert.ok(normal.texts.some((x) => x.text === room.title))
|
|
||||||
assert.ok(!grey.texts.some((x) => x.text === room.title), 'im grauen Raum steht das Schild an der Stelle des Titels')
|
|
||||||
assert.equal(grey.texts.filter((x) => x.text === SIGN).length, 1)
|
|
||||||
assert.equal(normal.texts.filter((x) => x.text === SIGN).length, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Schilder: drawRooms mit signs: false lässt sie aus (Grautönung bleibt), drawSigns zeichnet genau diese Schilder an dieselbe Stelle', () => {
|
|
||||||
const look = looks({ greyRooms: new Set(['kleinanzeigen', 'portale']) })
|
|
||||||
const withSigns = draw(look)
|
|
||||||
const m = makeCtx()
|
|
||||||
R.drawRooms(m.ctx, layout, 1, look, { signs: false })
|
|
||||||
assert.equal(m.texts.filter((x) => x.text === SIGN).length, 0)
|
|
||||||
assert.equal(m.rects.length < withSigns.rects.length, true)
|
|
||||||
assert.ok(m.alphas.includes(0.45), 'die Grautönung gehört weiter zum Raum')
|
|
||||||
assert.deepEqual(m.texts.filter((x) => x.text !== SIGN).map((x) => x.text), withSigns.texts.filter((x) => x.text !== SIGN).map((x) => x.text))
|
|
||||||
const s = makeCtx()
|
|
||||||
R.drawSigns(s.ctx, layout, look)
|
|
||||||
assert.deepEqual(s.texts.map((x) => x.text), [SIGN, SIGN])
|
|
||||||
assert.deepEqual(s.texts.map((x) => [x.x, x.y]), withSigns.texts.filter((x) => x.text === SIGN).map((x) => [x.x, x.y]))
|
|
||||||
const none = makeCtx()
|
|
||||||
R.drawSigns(none.ctx, layout, R.createLook())
|
|
||||||
assert.equal(none.rects.length + none.texts.length, 0)
|
|
||||||
const noWorker = makeCtx()
|
|
||||||
R.drawSigns(noWorker.ctx, layout, looks({ greyRooms: new Set(['pult', 'tresor']) })) // Räume ohne Worker werden nie grau
|
|
||||||
assert.equal(noWorker.texts.length, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Raumtitel: über den Möbeln gezeichnet (ein Monitor deckt sie nicht zu) und mit hellem Rand', () => {
|
|
||||||
const floor = layout.floors.find((f) => f.id === 'gespraeche')
|
|
||||||
const room = floor.rooms.find((r) => r.id === 'mail')
|
|
||||||
const m = makeCtx()
|
|
||||||
R.drawRooms(m.ctx, { ...layout, floors: [{ ...floor, rooms: [room] }] }, 1, R.createLook())
|
|
||||||
assert.equal(m.strokes.length, 1)
|
|
||||||
assert.equal(m.strokes[0].text, room.title)
|
|
||||||
assert.ok(m.strokes[0].style.startsWith('rgba(255, 255, 255'))
|
|
||||||
assert.equal(m.strokes[0].rects, m.rects.length, 'nach dem Titel kommt keine Fläche mehr: Möbel, Tisch und Monitor liegen darunter')
|
|
||||||
assert.ok(m.rects.length > 20)
|
|
||||||
assert.equal(m.texts.filter((x) => x.text === room.title).length, 1)
|
|
||||||
const grey = makeCtx()
|
|
||||||
R.drawRooms(grey.ctx, { ...layout, floors: [{ ...floor, rooms: [{ ...room, id: 'kleinanzeigen' }] }] }, 1, looks({ greyRooms: new Set(['kleinanzeigen']) }))
|
|
||||||
assert.equal(grey.strokes.length, 0, 'im grauen Raum steht kein Titel')
|
|
||||||
})
|
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { createEngineSandbox } from './helpers/engineVm.mjs'
|
|
||||||
import { makeState, ev, IDS } from './helpers/workersFixtures.mjs'
|
|
||||||
|
|
||||||
// Szene: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus) in einer vm-Sandbox ohne window/document
|
|
||||||
|
|
||||||
const sel = []
|
|
||||||
const open = async (o = {}, extra = {}) => {
|
|
||||||
const sb = await createEngineSandbox(o)
|
|
||||||
const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => sel.push(id), ...extra }))
|
|
||||||
return { sb, h }
|
|
||||||
}
|
|
||||||
|
|
||||||
test('Handle hat die Methoden aus Übersicht §7 und E4; Mount zeichnet sofort', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
for (const m of ['destroy', 'setState', 'pushEvents', 'select', 'setPaused', 'flush', 'resize', 'zoomBy', 'resetView', 'locate', 'stats', 'debug']) {
|
|
||||||
assert.equal(typeof h[m], 'function', m)
|
|
||||||
}
|
|
||||||
assert.ok(sb.counts.fillRect > 100)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Größe: Breite·dpr (max. 2400), Höhe 4/5 davon', async () => {
|
|
||||||
const a = await open({ cssWidth: 960, dpr: 1 })
|
|
||||||
assert.deepEqual([a.sb.canvas.width, a.sb.canvas.height], [960, 768])
|
|
||||||
a.h.destroy()
|
|
||||||
const b = await open({ cssWidth: 1500, dpr: 2 })
|
|
||||||
assert.deepEqual([b.sb.canvas.width, b.sb.canvas.height], [2400, 1920])
|
|
||||||
b.h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Takt: höchstens 30 Bilder je Sekunde', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
const f0 = h.stats().frames
|
|
||||||
sb.tick(1)
|
|
||||||
const f = h.stats().frames - f0
|
|
||||||
assert.ok(f >= 28 && f <= 32, String(f))
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Lebenszyklus: destroy doppelt, nichts danach, Listener weg; StrictMode mount → destroy → mount', async () => {
|
|
||||||
const sb = await createEngineSandbox()
|
|
||||||
const a = sb.mountTower(sb.canvas, sb.opts({}))
|
|
||||||
a.destroy()
|
|
||||||
a.destroy()
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
sb.tick(1)
|
|
||||||
assert.equal(sb.counts.fillRect, n)
|
|
||||||
assert.equal(Object.values(sb.listeners).flat().length, 0)
|
|
||||||
const b = sb.mountTower(sb.canvas, sb.opts({}))
|
|
||||||
sb.tick(0.5)
|
|
||||||
assert.ok(sb.counts.fillRect > n)
|
|
||||||
a.setState(makeState())
|
|
||||||
a.pushEvents([ev('fund', 'kim')])
|
|
||||||
b.destroy()
|
|
||||||
assert.equal(Object.values(sb.listeners).flat().length, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('fontsReady: Ablehnung oder Zerstörung davor lässt nichts mehr zeichnen', async () => {
|
|
||||||
let resolve
|
|
||||||
const p = new Promise((r) => { resolve = r })
|
|
||||||
const { sb, h } = await open({}, { fontsReady: p })
|
|
||||||
h.destroy()
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
resolve()
|
|
||||||
await new Promise((r) => setImmediate(r))
|
|
||||||
assert.equal(sb.counts.fillRect, n)
|
|
||||||
// das abgelehnte Versprechen erst nach dem Sandbox-Aufbau erzeugen, sonst „unhandled rejection“
|
|
||||||
const sb2 = await createEngineSandbox()
|
|
||||||
sb2.mountTower(sb2.canvas, sb2.opts({ fontsReady: Promise.reject(new Error('x')) })).destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('reducedMotion: keine Schleife, jede Änderung zeichnet ein Standbild und beruhigt die Regie', async () => {
|
|
||||||
const { sb, h } = await open({}, { reducedMotion: true })
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
h.setState(makeState())
|
|
||||||
h.pushEvents(Array.from({ length: 30 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 4) % 19] })))
|
|
||||||
assert.ok(sb.counts.fillRect > n)
|
|
||||||
assert.equal(h.debug().pending, 0)
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('setPaused hält die Schleife an und setzt sie fort', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setPaused(true)
|
|
||||||
sb.tick(0.2)
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
sb.tick(1)
|
|
||||||
assert.equal(sb.counts.fillRect, n)
|
|
||||||
h.setPaused(false)
|
|
||||||
sb.tick(0.5)
|
|
||||||
assert.ok(sb.counts.fillRect > n)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Klick trifft über die Umkehr-Transformation, auch gezoomt und verschoben', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
sel.length = 0
|
|
||||||
for (const id of ['otto', 'ben', 'arne']) {
|
|
||||||
const p = h.locate(id)
|
|
||||||
sb.click(p.x, p.y)
|
|
||||||
assert.equal(sel.at(-1), id)
|
|
||||||
}
|
|
||||||
h.zoomBy(2.5)
|
|
||||||
h.select('ben')
|
|
||||||
const p = h.locate('ben')
|
|
||||||
assert.ok(p)
|
|
||||||
sb.click(p.x, p.y)
|
|
||||||
assert.equal(sel.at(-1), 'ben')
|
|
||||||
assert.equal(h.stats().zoom, 2.5)
|
|
||||||
h.resetView()
|
|
||||||
assert.equal(h.stats().zoom, 1)
|
|
||||||
sel.length = 0
|
|
||||||
h.select('kim')
|
|
||||||
assert.equal(sel.length, 0)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Ziehen verschiebt bei Zoom > 1 und unterdrückt den Klick', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
h.zoomBy(2)
|
|
||||||
h.select('bea')
|
|
||||||
const before = h.locate('bea')
|
|
||||||
sel.length = 0
|
|
||||||
sb.pointer('pointerdown', 400, 300)
|
|
||||||
sb.pointer('pointermove', 460, 300)
|
|
||||||
sb.pointer('pointerup', 460, 300)
|
|
||||||
sb.click(460, 300)
|
|
||||||
assert.equal(sel.length, 0)
|
|
||||||
const after = h.locate('bea')
|
|
||||||
// 60 CSS-Pixel nach rechts gezogen
|
|
||||||
assert.ok(Math.abs(after.x - (before.x + 60)) < 2)
|
|
||||||
assert.ok(Math.abs(after.y - before.y) < 1e-6)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- Größe und Startansicht ----------
|
|
||||||
|
|
||||||
test('resize() folgt der Bühne; unter 10 CSS-Pixeln wird nicht gemessen', async () => {
|
|
||||||
const { sb, h } = await open({ cssWidth: 960 })
|
|
||||||
sb.setCssWidth(500)
|
|
||||||
h.resize()
|
|
||||||
assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400])
|
|
||||||
sb.setCssWidth(5)
|
|
||||||
h.resize()
|
|
||||||
assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400])
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unsichtbare Bühne (Breite unter 10): nichts wird gezeichnet, bis resize() sie misst', async () => {
|
|
||||||
const { sb, h } = await open({ cssWidth: 5 })
|
|
||||||
h.setState(makeState())
|
|
||||||
sb.tick(1)
|
|
||||||
assert.equal(sb.counts.fillRect, 0)
|
|
||||||
assert.equal(h.stats().frames, 0)
|
|
||||||
sb.setCssWidth(960)
|
|
||||||
h.resize()
|
|
||||||
assert.deepEqual([sb.canvas.width, sb.canvas.height], [960, 768])
|
|
||||||
assert.ok(sb.counts.fillRect > 100)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Startansicht: breite Bühne Zoom 1, schmale fitZoom; die Touch-Aktion folgt dem Zoom', async () => {
|
|
||||||
const wide = await open({ cssWidth: 960 })
|
|
||||||
assert.equal(wide.h.stats().zoom, 1)
|
|
||||||
assert.equal(wide.sb.canvas.style.touchAction, 'pan-y')
|
|
||||||
wide.h.destroy()
|
|
||||||
const narrow = await open({ cssWidth: 375 })
|
|
||||||
narrow.h.setState(makeState())
|
|
||||||
assert.ok(Math.abs(narrow.h.stats().zoom - 560 / 375) < 1e-9)
|
|
||||||
assert.equal(narrow.sb.canvas.style.touchAction, 'none')
|
|
||||||
// alle Figuren der oberen Etagen liegen in der Ansicht (die untere Hälfte des Turms ist bei Zoom > 1 abgeschnitten)
|
|
||||||
const upper = narrow.h.debug().agents.filter((a) => a.y < 120)
|
|
||||||
assert.ok(upper.length >= 10)
|
|
||||||
for (const a of upper) assert.ok(narrow.h.locate(a.id), a.id)
|
|
||||||
narrow.h.resetView()
|
|
||||||
assert.equal(narrow.h.stats().zoom, 1)
|
|
||||||
assert.equal(narrow.sb.canvas.style.touchAction, 'pan-y')
|
|
||||||
narrow.h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Startansicht auf schmaler Bühne: alle Räume (x 42..228) liegen in der Breite', async () => {
|
|
||||||
const { sb, h } = await open({ cssWidth: 375, record: true })
|
|
||||||
const k = sb.canvas.width / (320 / h.stats().zoom)
|
|
||||||
const backWalls = sb.rects.filter((r) => Math.abs(r.w - 186 * k) <= 1)
|
|
||||||
assert.ok(backWalls.length >= 10, 'Rückwand und Bodenstreifen jeder Etage sind 186 Einheiten breit')
|
|
||||||
for (const r of backWalls) assert.ok(r.x >= 0 && r.x + r.w <= sb.canvas.width, JSON.stringify(r))
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('resize() zieht den Startausschnitt nach, bis der Nutzer die Kamera bewegt hat', async () => {
|
|
||||||
const { sb, h } = await open({ cssWidth: 375 })
|
|
||||||
assert.ok(h.stats().zoom > 1.4)
|
|
||||||
sb.setCssWidth(960)
|
|
||||||
h.resize()
|
|
||||||
assert.equal(h.stats().zoom, 1)
|
|
||||||
sb.setCssWidth(375)
|
|
||||||
h.resize()
|
|
||||||
h.zoomBy(2)
|
|
||||||
const z = h.stats().zoom
|
|
||||||
sb.setCssWidth(960)
|
|
||||||
h.resize()
|
|
||||||
assert.equal(h.stats().zoom, z)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- Zeichnen ----------
|
|
||||||
|
|
||||||
test('Zeichenreihenfolge: Weiß, Außenwelt, Turm, Räume, Aufzug hinten, Figuren, Schilder, Aufzug vorn, Pausenschleier', async () => {
|
|
||||||
const { sb, h } = await open({ record: true })
|
|
||||||
const state = makeState({ paused: true, connected: { kim: false } })
|
|
||||||
h.setState(state)
|
|
||||||
sb.rects.length = 0
|
|
||||||
sb.texts.length = 0
|
|
||||||
h.setState(state) // genau ein Bild
|
|
||||||
const white = sb.rects[0]
|
|
||||||
assert.deepEqual([white.x, white.y, white.w, white.h, white.fill], [0, 0, 960, 768, '#ffffff'])
|
|
||||||
const rectN = (pred, what) => {
|
|
||||||
const r = sb.rects.find(pred)
|
|
||||||
assert.ok(r, what)
|
|
||||||
return r.n
|
|
||||||
}
|
|
||||||
const textN = (text) => {
|
|
||||||
const t = sb.texts.find((x) => x.text === text)
|
|
||||||
assert.ok(t, text)
|
|
||||||
return t.n
|
|
||||||
}
|
|
||||||
const outside = rectN((r) => r.fill === '#f7f4f0', 'Erde der Außenwelt')
|
|
||||||
const tower = rectN((r) => r.fill === '#e4e8ef', 'Aufzugschacht')
|
|
||||||
const rooms = textN('Netz-Radar') // Raumtitel
|
|
||||||
const liftBack = rectN((r) => r.fill === '#f1f3f8', 'Rückwand der Kabine')
|
|
||||||
const figure = rectN((r) => r.alpha === 0.55, 'graue Figur')
|
|
||||||
const sign = textN('noch nicht angeschlossen') // das Schild liegt über dem Hut der schlafenden Figur (sonst deckt der Hut den Text zu)
|
|
||||||
const liftFront = rectN((r) => r.fill === '#ffe9a8', 'Lampe der Kabine')
|
|
||||||
const veil = textN('Pausiert')
|
|
||||||
assert.deepEqual([outside, tower, rooms, liftBack, figure, sign, liftFront, veil].toSorted((a, b) => a - b), [outside, tower, rooms, liftBack, figure, sign, liftFront, veil])
|
|
||||||
assert.equal(sb.texts.filter((t) => t.text === 'noch nicht angeschlossen').length, 1, 'genau ein Schild, nicht zweimal')
|
|
||||||
assert.equal(sb.texts.at(-1).text, 'Pausiert')
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Effekte liegen über dem Aufzug vorn und unter dem Pausenschleier', async () => {
|
|
||||||
const { sb, h } = await open({ record: true })
|
|
||||||
h.setState(makeState())
|
|
||||||
h.pushEvents([ev('vorschau_link', 'max')])
|
|
||||||
sb.tick(2) // erst Sprechblase, dann fliegt der Brief (Lebensdauer 3,4 s)
|
|
||||||
assert.ok(h.debug().fx > 0)
|
|
||||||
sb.rects.length = 0
|
|
||||||
sb.texts.length = 0
|
|
||||||
h.setState(makeState({ paused: true })) // genau ein Bild, der Brief ist noch unterwegs
|
|
||||||
const lamp = sb.rects.find((r) => r.fill === '#ffe9a8')
|
|
||||||
const mail = sb.rects.filter((r) => r.fill === '#7a5cd6' && r.w === 21 && r.h === 15).at(-1)
|
|
||||||
const veil = sb.texts.find((t) => t.text === 'Pausiert')
|
|
||||||
assert.ok(lamp && mail && veil)
|
|
||||||
assert.ok(lamp.n < mail.n && mail.n < veil.n, [lamp.n, mail.n, veil.n].join(' < '))
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Pausenschleier nur bei pausiertem Dienst', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
assert.equal(sb.seenTexts.has('Pausiert'), false)
|
|
||||||
h.setState(makeState({ paused: true }))
|
|
||||||
assert.equal(sb.seenTexts.has('Pausiert'), true)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('stats().avgDrawMs misst die Zeit eines Bildes mit der Uhr der Szene', async () => {
|
|
||||||
const sb = await createEngineSandbox()
|
|
||||||
let t = 0
|
|
||||||
const h = sb.mountTower(sb.canvas, sb.opts({ reducedMotion: true, now: () => { t += 2; return t } }))
|
|
||||||
h.setState(makeState())
|
|
||||||
const s = h.stats()
|
|
||||||
assert.equal(s.frames, 2) // Mount und setState
|
|
||||||
assert.equal(s.avgDrawMs, 2)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Schleife treibt die Regie: eine Übergabe über sieben Etagen läuft in der Szene zu Ende', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
const arne = () => h.debug().agents.find((a) => a.id === 'arne')
|
|
||||||
const home = { x: arne().x, y: arne().y, floor: arne().floor }
|
|
||||||
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
|
|
||||||
assert.equal(h.debug().pending, 1)
|
|
||||||
let away = false
|
|
||||||
for (let s = 0; s < 40 && h.debug().pending > 0; s++) {
|
|
||||||
sb.tick(1)
|
|
||||||
if (Math.abs(arne().x - home.x) > 10 || arne().floor !== home.floor) away = true
|
|
||||||
}
|
|
||||||
assert.ok(away)
|
|
||||||
assert.equal(h.debug().pending, 0)
|
|
||||||
sb.tick(2)
|
|
||||||
assert.equal(h.debug().lift, 'idle')
|
|
||||||
assert.deepEqual({ x: arne().x, y: arne().y, floor: arne().floor }, home)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('dt wird nach einem Stillstand auf DT_MAX gekappt', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
|
|
||||||
sb.tick(1) // 0,4 s Aufnehmen, dann unterwegs zum Aufzug
|
|
||||||
const at = () => h.debug().agents.find((a) => a.id === 'arne')
|
|
||||||
const before = { x: at().x, y: at().y }
|
|
||||||
sb.jump(60) // die Uhr springt, ohne dass ein Bild kommt
|
|
||||||
sb.tick(2 / 60) // ein Bild mit Δt = 60 s
|
|
||||||
const moved = Math.hypot(at().x - before.x, at().y - before.y)
|
|
||||||
assert.ok(moved > 0 && moved <= 46 * sb.engine.DT_MAX + 1e-6, String(moved))
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- Eingabe ----------
|
|
||||||
|
|
||||||
test('Klick ins Leere wählt ab (onSelect(null))', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
sel.length = 0
|
|
||||||
const p = h.locate('otto')
|
|
||||||
sb.click(p.x, p.y)
|
|
||||||
sb.click(5, 5)
|
|
||||||
assert.deepEqual(sel, ['otto', null])
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('coarsePointer weitet die Trefferfläche von 4 auf 10 CSS-Pixel', async () => {
|
|
||||||
for (const [coarsePointer, expected] of [[false, null], [true, 'arne']]) {
|
|
||||||
const { sb, h } = await open({}, { coarsePointer })
|
|
||||||
h.setState(makeState())
|
|
||||||
sel.length = 0
|
|
||||||
const p = h.locate('arne')
|
|
||||||
sb.click(p.x + 25, p.y) // die Figur ist 12 Einheiten = 36 CSS-Pixel breit, 25 > 18 + 4 und 25 < 18 + 10
|
|
||||||
assert.equal(sel.at(-1), expected, String(coarsePointer))
|
|
||||||
h.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('zoomBy zoomt um die Mitte der Ansicht', async () => {
|
|
||||||
const { h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
const before = h.locate('bea')
|
|
||||||
h.zoomBy(1.4)
|
|
||||||
const after = h.locate('bea')
|
|
||||||
assert.ok(Math.abs(after.x - (480 + (before.x - 480) * 1.4)) < 1, JSON.stringify(after))
|
|
||||||
assert.ok(Math.abs(after.y - (384 + (before.y - 384) * 1.4)) < 1, JSON.stringify(after))
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Strg+Rad zoomt um den Zeiger, ohne Strg bleibt das Rad der Seite; Schritt begrenzt, passive: false', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
const free = sb.wheel(480, 384, -100)
|
|
||||||
assert.equal(free.defaultPrevented, false)
|
|
||||||
assert.equal(h.stats().zoom, 1)
|
|
||||||
const p = h.locate('arne')
|
|
||||||
const e = sb.wheel(p.x, p.y, -100, { ctrlKey: true })
|
|
||||||
assert.equal(e.defaultPrevented, true)
|
|
||||||
assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP) < 1e-9)
|
|
||||||
const q = h.locate('arne')
|
|
||||||
assert.ok(Math.abs(q.x - p.x) < 1 && Math.abs(q.y - p.y) < 1, 'der Punkt unter dem Zeiger bleibt stehen')
|
|
||||||
sb.wheel(p.x, p.y, 100, { ctrlKey: true })
|
|
||||||
assert.ok(Math.abs(h.stats().zoom - 1) < 1e-9)
|
|
||||||
sb.wheel(480, 384, -1e6, { ctrlKey: true })
|
|
||||||
assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP ** 2) < 1e-9)
|
|
||||||
const wheel = sb.listeners.wheel[0]
|
|
||||||
assert.equal(sb.listenerOptions.get(wheel).passive, false)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Ziehen bei Zoom 1 verschiebt nichts, unterdrückt aber den Klick; kleine Bewegung und die rechte Maustaste', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
const before = h.locate('bea')
|
|
||||||
sel.length = 0
|
|
||||||
sb.pointer('pointerdown', 400, 300)
|
|
||||||
sb.pointer('pointermove', 460, 300)
|
|
||||||
sb.pointer('pointerup', 460, 300)
|
|
||||||
sb.click(before.x, before.y)
|
|
||||||
assert.equal(sel.length, 0)
|
|
||||||
assert.deepEqual(h.locate('bea'), before)
|
|
||||||
// der folgende Klick gilt wieder
|
|
||||||
sb.click(before.x, before.y)
|
|
||||||
assert.deepEqual(sel, ['bea'])
|
|
||||||
// bis DRAG_PX Wanderung bleibt ein Klick
|
|
||||||
sb.pointer('pointerdown', before.x, before.y)
|
|
||||||
sb.pointer('pointermove', before.x + sb.engine.DRAG_PX, before.y)
|
|
||||||
sb.pointer('pointerup', before.x + sb.engine.DRAG_PX, before.y)
|
|
||||||
sb.click(before.x, before.y)
|
|
||||||
assert.deepEqual(sel, ['bea', 'bea'])
|
|
||||||
// die rechte Maustaste startet kein Ziehen
|
|
||||||
sb.pointer('pointerdown', 400, 300, { button: 2 })
|
|
||||||
sb.pointer('pointermove', 460, 300)
|
|
||||||
sb.click(before.x, before.y)
|
|
||||||
assert.deepEqual(sel, ['bea', 'bea', 'bea'])
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Zeiger-Erfassung beim Ziehen; ein Fehler dabei stört das Ziehen nicht', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
h.zoomBy(2)
|
|
||||||
h.select('bea')
|
|
||||||
const calls = []
|
|
||||||
sb.canvas.setPointerCapture = (id) => calls.push(['set', id])
|
|
||||||
sb.canvas.releasePointerCapture = (id) => calls.push(['release', id])
|
|
||||||
sb.pointer('pointerdown', 400, 300)
|
|
||||||
sb.pointer('pointercancel', 400, 300)
|
|
||||||
assert.deepEqual(calls, [['set', 1], ['release', 1]])
|
|
||||||
sb.canvas.setPointerCapture = () => { throw new Error('InvalidPointerId') }
|
|
||||||
sb.canvas.releasePointerCapture = () => { throw new Error('InvalidPointerId') }
|
|
||||||
const before = h.locate('bea')
|
|
||||||
sb.pointer('pointerdown', 400, 300)
|
|
||||||
sb.pointer('pointermove', 450, 300)
|
|
||||||
sb.pointer('pointerup', 450, 300)
|
|
||||||
assert.ok(Math.abs(h.locate('bea').x - (before.x + 50)) < 2)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('select() von außen holt die Figur bei Zoom > 1 in die Mitte, der Klick selbst verschiebt die Ansicht nicht', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
h.zoomBy(3)
|
|
||||||
h.select('rita')
|
|
||||||
const mid = h.locate('rita')
|
|
||||||
assert.ok(Math.abs(mid.x - 480) < 1 && Math.abs(mid.y - 384) < 1, JSON.stringify(mid))
|
|
||||||
sb.pointer('pointerdown', 400, 300)
|
|
||||||
sb.pointer('pointermove', 450, 300)
|
|
||||||
sb.pointer('pointerup', 450, 300)
|
|
||||||
sb.click(450, 300) // gilt als Ende des Ziehens
|
|
||||||
const dragged = h.locate('rita')
|
|
||||||
assert.ok(Math.abs(dragged.x - (mid.x + 50)) < 2)
|
|
||||||
sel.length = 0
|
|
||||||
sb.click(dragged.x, dragged.y)
|
|
||||||
assert.deepEqual(sel, ['rita'])
|
|
||||||
h.select('rita') // so antwortet die Seite auf onSelect
|
|
||||||
assert.deepEqual(h.locate('rita'), dragged)
|
|
||||||
h.select('rita') // jetzt von außen (Liste): in die Mitte
|
|
||||||
const again = h.locate('rita')
|
|
||||||
assert.ok(Math.abs(again.x - 480) < 1 && Math.abs(again.y - 384) < 1)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('locate: null außerhalb der Ansicht und für Unbekanntes', async () => {
|
|
||||||
const { h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
h.zoomBy(3)
|
|
||||||
h.select('otto')
|
|
||||||
assert.ok(h.locate('otto'))
|
|
||||||
assert.equal(h.locate('arne'), null)
|
|
||||||
for (const id of ['niemand', '__proto__', 'constructor', '', 42, null, undefined]) assert.equal(h.locate(id), null, String(id))
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- Lebenszyklus und Sonderfälle ----------
|
|
||||||
|
|
||||||
test('nach destroy ist jeder Aufruf wirkungslos', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
h.destroy()
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
const zoom = h.stats().zoom
|
|
||||||
h.setState(makeState({ paused: true }))
|
|
||||||
h.pushEvents([ev('fund', 'kim')])
|
|
||||||
h.select('kim')
|
|
||||||
h.zoomBy(2)
|
|
||||||
h.resetView()
|
|
||||||
h.resize()
|
|
||||||
h.setPaused(true)
|
|
||||||
h.setPaused(false)
|
|
||||||
sb.click(100, 100)
|
|
||||||
assert.equal(sb.counts.fillRect, n)
|
|
||||||
assert.equal(h.stats().zoom, zoom)
|
|
||||||
assert.equal(h.locate('kim'), null)
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Standbild: das Aufblitzen des Tresors nach einem Deal bleibt nicht stehen', async () => {
|
|
||||||
const { sb, h } = await open({ record: true }, { reducedMotion: true })
|
|
||||||
h.setState(makeState())
|
|
||||||
sb.rects.length = 0
|
|
||||||
h.pushEvents([ev('deal', 'system'), ev('deal', 'system')])
|
|
||||||
assert.ok(sb.rects.length > 100)
|
|
||||||
assert.equal(sb.rects.filter((r) => r.fill === '#e8b730' && r.alpha > 0 && r.alpha < 0.5).length, 0)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('setPaused(true) bestellt keinen Bildrückruf mehr, setPaused(false) genau einen', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
assert.equal(sb.rafPending(), 1)
|
|
||||||
h.setPaused(true)
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
h.setPaused(true)
|
|
||||||
h.setPaused(false)
|
|
||||||
h.setPaused(false)
|
|
||||||
assert.equal(sb.rafPending(), 1)
|
|
||||||
h.destroy()
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ohne wirksames caf läuft nach Pause und Fortsetzen nie eine zweite Schleife', async () => {
|
|
||||||
const { sb, h } = await open({}, { caf: () => {} })
|
|
||||||
h.setPaused(true)
|
|
||||||
h.setPaused(false) // der alte Rückruf steht noch in der Warteschlange
|
|
||||||
assert.equal(sb.rafPending(), 2)
|
|
||||||
sb.tick(0.1)
|
|
||||||
assert.equal(sb.rafPending(), 1)
|
|
||||||
const f0 = h.stats().frames
|
|
||||||
sb.tick(1)
|
|
||||||
const f = h.stats().frames - f0
|
|
||||||
assert.ok(f >= 28 && f <= 32, String(f))
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('setPaused im Standbild startet keine Schleife', async () => {
|
|
||||||
const { sb, h } = await open({}, { reducedMotion: true })
|
|
||||||
h.setPaused(true)
|
|
||||||
h.setPaused(false)
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ohne raf (Option und global) bleibt es beim Standbild; die Standardwerte werden erst beim Aufruf gelesen', async () => {
|
|
||||||
const sb = await createEngineSandbox()
|
|
||||||
const h = sb.mountTower(sb.canvas, { onSelect() {} })
|
|
||||||
assert.ok(sb.counts.fillRect > 100)
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
h.setState(makeState())
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('fontsReady erfüllt: danach wird einmal neu gezeichnet', async () => {
|
|
||||||
const sb = await createEngineSandbox()
|
|
||||||
const h = sb.mountTower(sb.canvas, sb.opts({ fontsReady: Promise.resolve() }))
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
await new Promise((r) => setImmediate(r))
|
|
||||||
assert.ok(sb.counts.fillRect > n)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Canvas ohne 2D-Kontext: ein Handle, das nichts tut', async () => {
|
|
||||||
const sb = await createEngineSandbox()
|
|
||||||
sb.canvas.getContext = () => null
|
|
||||||
const h = sb.mountTower(sb.canvas, sb.opts({}))
|
|
||||||
h.setState(makeState())
|
|
||||||
h.pushEvents([ev('fund', 'kim')])
|
|
||||||
h.select('kim')
|
|
||||||
h.zoomBy(2)
|
|
||||||
h.resetView()
|
|
||||||
h.resize()
|
|
||||||
h.setPaused(true)
|
|
||||||
assert.equal(h.locate('kim'), null)
|
|
||||||
assert.equal(h.stats().frames, 0)
|
|
||||||
assert.equal(h.debug().pending, 0)
|
|
||||||
h.destroy()
|
|
||||||
assert.equal(sb.counts.fillRect, 0)
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
assert.equal(Object.values(sb.listeners).flat().length, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('debug() und stats(): Form', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState({ connected: { kim: false } }))
|
|
||||||
sb.tick(1)
|
|
||||||
const d = h.debug()
|
|
||||||
assert.equal(d.agents.length, 19)
|
|
||||||
assert.deepEqual(Object.keys(d.agents[0]).sort(), ['floor', 'grey', 'id', 'pose', 'x', 'y'])
|
|
||||||
assert.equal(d.agents.find((a) => a.id === 'kim').grey, true)
|
|
||||||
assert.deepEqual([typeof d.pending, typeof d.lift, typeof d.fx], ['number', 'string', 'number'])
|
|
||||||
const s = h.stats()
|
|
||||||
assert.deepEqual(Object.keys(s).sort(), ['avgDrawMs', 'frames', 'zoom'])
|
|
||||||
assert.ok(s.frames > 25)
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('flush(): Rückkehr in den Tab beruhigt die Regie (alles heim, Aufzug und Effekte leer) und zeichnet; nach destroy wirkungslos', async () => {
|
|
||||||
const { sb, h } = await open()
|
|
||||||
h.setState(makeState())
|
|
||||||
const arne = () => h.debug().agents.find((a) => a.id === 'arne')
|
|
||||||
const home = { x: arne().x, y: arne().y, floor: arne().floor }
|
|
||||||
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
|
|
||||||
sb.tick(3)
|
|
||||||
assert.ok(h.debug().pending > 0)
|
|
||||||
h.setPaused(true)
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
h.setPaused(false)
|
|
||||||
h.flush()
|
|
||||||
assert.ok(sb.counts.fillRect > n)
|
|
||||||
assert.equal(h.debug().pending, 0)
|
|
||||||
assert.equal(h.debug().lift, 'idle')
|
|
||||||
assert.equal(h.debug().fx, 0)
|
|
||||||
assert.deepEqual({ x: arne().x, y: arne().y, floor: arne().floor }, home)
|
|
||||||
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
|
|
||||||
assert.equal(h.debug().pending, 1)
|
|
||||||
h.destroy()
|
|
||||||
const m = sb.counts.fillRect
|
|
||||||
h.flush()
|
|
||||||
assert.equal(sb.counts.fillRect, m)
|
|
||||||
assert.equal(h.debug().pending, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('flush() im Standbild zeichnet ein Standbild ohne Bildrückruf; ohne 2D-Kontext tut es nichts', async () => {
|
|
||||||
const { sb, h } = await open({}, { reducedMotion: true })
|
|
||||||
h.setState(makeState())
|
|
||||||
const n = sb.counts.fillRect
|
|
||||||
h.flush()
|
|
||||||
assert.ok(sb.counts.fillRect > n)
|
|
||||||
assert.equal(sb.rafPending(), 0)
|
|
||||||
h.destroy()
|
|
||||||
const bare = await createEngineSandbox()
|
|
||||||
bare.canvas.getContext = () => null
|
|
||||||
const g = bare.mountTower(bare.canvas, bare.opts({}))
|
|
||||||
g.flush()
|
|
||||||
assert.equal(bare.counts.fillRect, 0)
|
|
||||||
})
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { createEngineSandbox } from './helpers/engineVm.mjs'
|
|
||||||
import { makeState, ev, IDS } from './helpers/workersFixtures.mjs'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
|
|
||||||
// Rauchtest der Engine ohne Browser (Muster: entwuerfe/smoke.js): 19 Figuren sind anklickbar, 150 simulierte Sekunden
|
|
||||||
// mit allen 26 Ereignisarten und einem Stoß von 120 Übergaben laufen ohne Wurf und beruhigen sich danach wieder.
|
|
||||||
|
|
||||||
const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
|
|
||||||
|
|
||||||
// Eine Art alle 5 s ab Sekunde 3, in dieser Reihenfolge: [Art, Arbeiter, Zusatzfelder]
|
|
||||||
const PLAN = [
|
|
||||||
['gelesen', 'kim'],
|
|
||||||
['fund', 'rita'],
|
|
||||||
['doppelt', 'arne'],
|
|
||||||
['uebergabe', 'rita', { to: 'bea' }],
|
|
||||||
['bewertet', 'bea'],
|
|
||||||
['top3', 'bea'],
|
|
||||||
['botlead', 'arne'],
|
|
||||||
['entwurf', 'max'],
|
|
||||||
['pult', 'clara'],
|
|
||||||
['gesendet', 'max'],
|
|
||||||
['eingang', 'nora'],
|
|
||||||
['sortiert', 'nora'],
|
|
||||||
['bau_start', 'bob'],
|
|
||||||
['bau_fertig', 'bob'],
|
|
||||||
['pruefung_zurueck', 'paul', { to: 'bob', meta: { round: 1 } }],
|
|
||||||
['pruefung_ok', 'paul'],
|
|
||||||
['vorschau_link', 'max'],
|
|
||||||
['angebot', 'theo'],
|
|
||||||
['deal', 'system'],
|
|
||||||
['gesperrt', 'system'],
|
|
||||||
['budget_warnung', 'otto'],
|
|
||||||
['budget_stopp', 'otto'],
|
|
||||||
['pause', 'system'],
|
|
||||||
['fehler', 'ben'],
|
|
||||||
['auftrag', 'otto', { to: 'kim' }],
|
|
||||||
['leerlauf', 'pia'],
|
|
||||||
]
|
|
||||||
const FIRST_SEC = 3
|
|
||||||
const EVERY_SEC = 5
|
|
||||||
const BURST_SEC = 100
|
|
||||||
const BURST_SIZE = 120
|
|
||||||
|
|
||||||
// Die Ereignisse für Sekunde `sec`: höchstens eine Art aus dem Plan, ab Sekunde 100 zusätzlich der Stoß (einmalig)
|
|
||||||
function scripted(sec) {
|
|
||||||
const out = []
|
|
||||||
const slot = (sec - FIRST_SEC) / EVERY_SEC
|
|
||||||
if (Number.isInteger(slot) && slot >= 0 && slot < PLAN.length) {
|
|
||||||
const [kind, who, extra] = PLAN[slot]
|
|
||||||
out.push(ev(kind, who, extra))
|
|
||||||
}
|
|
||||||
if (sec === BURST_SEC) {
|
|
||||||
for (let i = 0; i < BURST_SIZE; i++) out.push(ev('uebergabe', IDS[i % IDS.length], { to: IDS[(i + 4) % IDS.length] }))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const OFF = { kim: false, pia: false, finn: false }
|
|
||||||
|
|
||||||
test('alle 19 Worker sind anklickbar (Auswahl liefert die richtige workerId)', async () => {
|
|
||||||
const sb = await createEngineSandbox()
|
|
||||||
const picked = []
|
|
||||||
const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => picked.push(id) }))
|
|
||||||
h.setState(makeState())
|
|
||||||
for (const id of IDS) {
|
|
||||||
const p = h.locate(id)
|
|
||||||
assert.ok(p, id)
|
|
||||||
sb.click(p.x, p.y)
|
|
||||||
assert.equal(picked.at(-1), id)
|
|
||||||
}
|
|
||||||
assert.deepEqual([...new Set(picked)].sort(), [...IDS].sort())
|
|
||||||
h.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('150 s mit allen 26 Ereignisarten: kein Wurf, alle endlich und im Bild, danach ruhig', async () => {
|
|
||||||
const sb = await createEngineSandbox()
|
|
||||||
const h = sb.mountTower(sb.canvas, sb.opts({ onSelect() {} }))
|
|
||||||
h.setState(makeState({ connected: OFF }))
|
|
||||||
const fed = []
|
|
||||||
for (let s = 0; s < 150; s++) {
|
|
||||||
const evs = scripted(s)
|
|
||||||
if (evs.length) {
|
|
||||||
fed.push(...evs)
|
|
||||||
h.pushEvents(evs)
|
|
||||||
}
|
|
||||||
if (s % 15 === 0) {
|
|
||||||
h.setState(makeState({ connected: OFF, kpis: { pultOpen: s % 7, dealsMonth: 3 + Math.floor(s / 40), botLeads: 112 + s } }))
|
|
||||||
}
|
|
||||||
sb.tick(1)
|
|
||||||
// alle zwei Sekunden ein Raster von Klicks über die ganze Leinwand
|
|
||||||
if (s % 2 === 0) {
|
|
||||||
for (let x = 10; x < 960; x += 40) {
|
|
||||||
for (let y = 10; y < 768; y += 40) sb.click(x, y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// der Plan deckt die geschlossene Liste vollständig ab, der Stoß kam genau einmal
|
|
||||||
assert.equal(PLAN.length, 26)
|
|
||||||
assert.deepEqual([...new Set(fed.map((e) => e.kind))].sort(), [...EVENT_KINDS].sort())
|
|
||||||
assert.equal(fed.length, PLAN.length + BURST_SIZE)
|
|
||||||
assert.equal(fed.filter((e) => e.kind === 'uebergabe').length, BURST_SIZE + 1)
|
|
||||||
// danach 90 ruhige Sekunden: Warteschlangen, Aufzug und Effekte laufen leer
|
|
||||||
for (let s = 0; s < 90; s++) sb.tick(1)
|
|
||||||
const dbg = h.debug()
|
|
||||||
assert.equal(dbg.pending, 0)
|
|
||||||
assert.equal(dbg.lift, 'idle')
|
|
||||||
assert.equal(dbg.fx, 0)
|
|
||||||
for (const a of dbg.agents) {
|
|
||||||
assert.ok(Number.isFinite(a.x) && Number.isFinite(a.y) && a.x > -30 && a.x < 350 && a.floor >= 0 && a.floor <= 9, a.id)
|
|
||||||
}
|
|
||||||
assert.ok(sb.counts.fillRect > 100000)
|
|
||||||
assert.ok(sb.counts.fillText > 1000)
|
|
||||||
assert.ok(sb.seenTexts.has('noch nicht angeschlossen'))
|
|
||||||
h.destroy()
|
|
||||||
assert.equal(Object.values(sb.listeners).flat().length, 0)
|
|
||||||
})
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
import { makeCtx } from './helpers/fakeCtx.mjs'
|
|
||||||
|
|
||||||
// Engine-Grundlagen: Raster, Figuren mit Posen, Text, Sprechblasen
|
|
||||||
const S = await loadModule('src/components/workers/engine/sprites.js')
|
|
||||||
|
|
||||||
test('px: gerundete Kanten ohne Naht, Standardraster 3, Versatz, Mindestgröße', () => {
|
|
||||||
let { ctx, rects } = makeCtx(); S.setGrid(ctx, { k: 3.4, ox: 0, oy: 0 })
|
|
||||||
S.px(ctx, 0, 0, 1, 1, '#000'); S.px(ctx, 1, 0, 1, 1, '#111'); S.px(ctx, 2, 0, 1, 1, '#222')
|
|
||||||
assert.equal(rects[0].x + rects[0].w, rects[1].x); assert.equal(rects[1].x + rects[1].w, rects[2].x)
|
|
||||||
;({ ctx, rects } = makeCtx()); S.px(ctx, 1, 2, 3, 4, '#fff'); assert.deepEqual([rects[0].x, rects[0].y, rects[0].w, rects[0].h], [3, 6, 9, 12])
|
|
||||||
;({ ctx, rects } = makeCtx()); S.setGrid(ctx, { k: 2, ox: -10, oy: 0 }); S.px(ctx, 5, 0, 0.1, 1, '#fff'); assert.equal(rects[0].x, 0); assert.ok(rects[0].w >= 1)
|
|
||||||
})
|
|
||||||
test('label: Schriftgröße wächst mit dem Raster', () => {
|
|
||||||
const { ctx, texts } = makeCtx(); S.setGrid(ctx, { k: 6 }); S.label(ctx, 'Hallo', 1, 1, { size: 11 }); assert.match(texts[0].font, /22px/)
|
|
||||||
})
|
|
||||||
test('drawWorker: Trefferfläche', () => {
|
|
||||||
const { ctx } = makeCtx()
|
|
||||||
assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'haiku' }), { x: 10, y: 18, w: 12, h: 16 })
|
|
||||||
assert.equal(S.drawWorker(ctx, 10, 20, { hat: 'haiku', carrying: true }).w, 14)
|
|
||||||
assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'overseer', scale: 2 }), { x: 10, y: 16, w: 24, h: 32 })
|
|
||||||
})
|
|
||||||
test('drawWorker: Hutfarbe zeigt das Modell, Augen je Pose, Grau mit Alpha', () => {
|
|
||||||
for (const hat of ['opus', 'sonnet', 'haiku', 'overseer']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat }); assert.ok(rects.some((r) => r.fill === S.HAT[hat][0]), hat) }
|
|
||||||
const eyes = (pose) => { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'haiku', pose }); return rects.filter((r) => r.fill === S.PAL.e).length }
|
|
||||||
assert.equal(eyes('stand'), 4); assert.equal(eyes('sleep'), 2)
|
|
||||||
for (const pose of ['stand', 'type', 'coffee', 'sleep']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t: 1.3 }); assert.ok(rects.length > 20, pose) }
|
|
||||||
const g = makeCtx(); S.drawWorker(g.ctx, 0, 0, { hat: 'opus', grey: true }); assert.ok(g.alphas.includes(0.55)); assert.equal(g.counts.save, g.counts.restore)
|
|
||||||
})
|
|
||||||
test('drawBubble bleibt im Bild (Spitze nahe am linken und rechten Rand, Raster 3, Breite 960)', () => {
|
|
||||||
const l = makeCtx(); S.drawBubble(l.ctx, 2, 30, 'Hallo'); assert.ok(Math.min(...l.rects.map((r) => r.x)) >= 0)
|
|
||||||
const r = makeCtx(); S.drawBubble(r.ctx, 318, 30, 'Hallo'); assert.ok(Math.max(...r.rects.map((q) => q.x + q.w)) <= 960)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Ergänzungen: Verhalten, das die fünf Tests oben nicht berühren ----
|
|
||||||
|
|
||||||
test('Raster gilt je Kontext (WeakMap, kein Feld am Kontext); ohne setGrid gilt k = 3', () => {
|
|
||||||
const a = makeCtx()
|
|
||||||
const b = makeCtx()
|
|
||||||
S.setGrid(a.ctx, { k: 6, ox: 1, oy: 2 })
|
|
||||||
S.px(a.ctx, 1, 1, 1, 1, '#000')
|
|
||||||
S.px(b.ctx, 1, 1, 1, 1, '#000')
|
|
||||||
assert.deepEqual([a.rects[0].x, a.rects[0].y, a.rects[0].w, a.rects[0].h], [7, 8, 6, 6])
|
|
||||||
assert.deepEqual([b.rects[0].x, b.rects[0].y, b.rects[0].w, b.rects[0].h], [3, 3, 3, 3])
|
|
||||||
assert.equal('wkGrid' in a.ctx, false)
|
|
||||||
})
|
|
||||||
test('tint: Alpha nur um die Fläche, save und restore ausgeglichen', () => {
|
|
||||||
const { ctx, rects, alphas, counts } = makeCtx()
|
|
||||||
S.tint(ctx, 0, 0, 2, 2, '#ff0000', 0.25)
|
|
||||||
assert.deepEqual([rects.length, rects[0].fill, rects[0].w], [1, '#ff0000', 6])
|
|
||||||
assert.deepEqual(alphas, [0.25])
|
|
||||||
assert.deepEqual(counts, { save: 1, restore: 1 })
|
|
||||||
})
|
|
||||||
test('label: Zahl wird zu Text, Hintergrund folgt der Ausrichtung', () => {
|
|
||||||
const n = makeCtx()
|
|
||||||
S.label(n.ctx, 42, 1, 1)
|
|
||||||
assert.equal(n.texts[0].text, '42')
|
|
||||||
const c = makeCtx()
|
|
||||||
S.label(c.ctx, 'abcd', 10, 5, { size: 12, align: 'center', bg: '#ffffff' })
|
|
||||||
// Breite 4 * 7 = 28, Mitte bei 30: Kasten von 30 - 14 - 4 bis + 28 + 8, Höhe 12 + 5
|
|
||||||
assert.deepEqual([c.rects[0].x, c.rects[0].y, c.rects[0].w, c.rects[0].h, c.rects[0].fill], [12, 13, 36, 17, '#ffffff'])
|
|
||||||
assert.equal(c.texts[0].x, 30)
|
|
||||||
})
|
|
||||||
test('label: halo zeichnet erst einen hellen Rand (strokeText), dann den Text darüber; ohne halo kein Rand', () => {
|
|
||||||
const h = makeCtx()
|
|
||||||
S.setGrid(h.ctx, { k: 3 })
|
|
||||||
S.label(h.ctx, 'Mail-Stube', 2, 1, { size: 10, color: '#445566', halo: '#ffffff' })
|
|
||||||
assert.equal(h.strokes.length, 1)
|
|
||||||
assert.deepEqual([h.strokes[0].text, h.strokes[0].style], ['Mail-Stube', '#ffffff'])
|
|
||||||
assert.deepEqual([h.strokes[0].x, h.strokes[0].y], [h.texts[0].x, h.texts[0].y], 'Rand und Text an derselben Stelle')
|
|
||||||
assert.equal(h.strokes[0].texts, 0, 'der Rand kommt vor dem Text')
|
|
||||||
assert.ok(h.strokes[0].width >= 2, 'mindestens ein Geräte-Pixel Rand rundum')
|
|
||||||
const big = makeCtx()
|
|
||||||
S.setGrid(big.ctx, { k: 6 })
|
|
||||||
S.label(big.ctx, 'Mail-Stube', 2, 1, { size: 10, halo: '#ffffff' })
|
|
||||||
assert.ok(big.strokes[0].width > h.strokes[0].width, 'der Rand wächst mit dem Raster')
|
|
||||||
const plain = makeCtx()
|
|
||||||
S.label(plain.ctx, 'Mail-Stube', 2, 1, { size: 10 })
|
|
||||||
assert.equal(plain.strokes.length, 0)
|
|
||||||
})
|
|
||||||
test('drawWorker: Schlafen zeigt ein z über dem Hut, das mit t steigt; stehen nicht', () => {
|
|
||||||
const zs = (pose, t) => {
|
|
||||||
const { ctx, rects } = makeCtx()
|
|
||||||
S.drawWorker(ctx, 20, 40, { hat: 'haiku', pose, t })
|
|
||||||
return rects.filter((r) => r.fill === S.PAL.mute)
|
|
||||||
}
|
|
||||||
const z0 = zs('sleep', 0)
|
|
||||||
const z1 = zs('sleep', 0.5)
|
|
||||||
assert.equal(z0.length, 3)
|
|
||||||
assert.equal(zs('stand', 0).length, 0)
|
|
||||||
assert.ok(z1[0].y < z0[0].y, 'steigt')
|
|
||||||
assert.ok(Math.max(...z0.map((r) => r.y + r.h)) <= (40 - 2) * 3, 'über dem Hut')
|
|
||||||
})
|
|
||||||
test('drawWorker: Kaffee zeichnet eine 3x3-Tasse und einen flackernden Dampfpixel', () => {
|
|
||||||
const run = (pose, t) => {
|
|
||||||
const { ctx, rects } = makeCtx()
|
|
||||||
S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t })
|
|
||||||
return rects
|
|
||||||
}
|
|
||||||
const cups = (rects) => rects.filter((r) => r.fill === S.PAL.metal2 && r.w === 9 && r.h === 9).length
|
|
||||||
const steam = (rects) => rects.filter((r) => r.fill === S.PAL.g).length
|
|
||||||
assert.equal(cups(run('coffee', 0)), 1)
|
|
||||||
assert.equal(cups(run('stand', 0)), 0)
|
|
||||||
assert.deepEqual([steam(run('coffee', 0)), steam(run('coffee', 0.4)), steam(run('stand', 0))], [1, 0, 0])
|
|
||||||
})
|
|
||||||
test('drawWorker: Grau tauscht Körper- und Hutfarben, Trefferfläche bleibt', () => {
|
|
||||||
const grey = makeCtx()
|
|
||||||
const hit = S.drawWorker(grey.ctx, 10, 20, { hat: 'opus', grey: true, carrying: true })
|
|
||||||
assert.deepEqual(hit, S.drawWorker(makeCtx().ctx, 10, 20, { hat: 'opus', carrying: true }))
|
|
||||||
const own = [S.PAL.b, S.PAL.s, S.PAL.h, S.HAT.opus[0], S.HAT.opus[1]]
|
|
||||||
assert.ok(!grey.rects.some((r) => own.includes(r.fill)))
|
|
||||||
assert.deepEqual([grey.alphas, grey.counts], [[0.55], { save: 1, restore: 1 }])
|
|
||||||
})
|
|
||||||
test('drawWorker: Auswahlpfeil nur bei selected, außerhalb der Graustufe', () => {
|
|
||||||
const ink = (opts) => {
|
|
||||||
const { ctx, rects, alphas } = makeCtx()
|
|
||||||
S.drawWorker(ctx, 0, 0, { hat: 'haiku', ...opts })
|
|
||||||
return [rects.filter((r) => r.fill === S.PAL.ink).length, alphas.length]
|
|
||||||
}
|
|
||||||
assert.deepEqual(ink({}), [0, 0])
|
|
||||||
assert.deepEqual(ink({ selected: true }), [9, 0])
|
|
||||||
assert.deepEqual(ink({ selected: true, grey: true }), [9, 1])
|
|
||||||
})
|
|
||||||
test('drawAlertMark: rotes 3x7-Ausrufezeichen mit Umriss, unten mittig bei (x, y)', () => {
|
|
||||||
const { ctx, rects } = makeCtx()
|
|
||||||
S.drawAlertMark(ctx, 50, 30)
|
|
||||||
assert.deepEqual([...new Set(rects.map((r) => r.fill))].sort(), [S.PAL.o, S.PAL.red].sort())
|
|
||||||
const red = rects.filter((r) => r.fill === S.PAL.red)
|
|
||||||
const left = Math.min(...red.map((r) => r.x))
|
|
||||||
const right = Math.max(...red.map((r) => r.x + r.w))
|
|
||||||
const top = Math.min(...red.map((r) => r.y))
|
|
||||||
const bottom = Math.max(...red.map((r) => r.y + r.h))
|
|
||||||
assert.deepEqual([right - left, bottom - top], [9, 21])
|
|
||||||
assert.ok(Math.abs((left + right) / 2 - 150) <= 1, 'mittig')
|
|
||||||
assert.equal(Math.max(...rects.map((r) => r.y + r.h)), 90)
|
|
||||||
})
|
|
||||||
test('drawBubble: Text und Maße folgen dem Raster, leerer Text zeichnet nichts, Spitze außerhalb bleibt im Bild', () => {
|
|
||||||
const a = makeCtx()
|
|
||||||
S.drawBubble(a.ctx, 50, 40, 'Hallo')
|
|
||||||
assert.deepEqual([a.texts.length, a.texts[0].text], [1, 'Hallo'])
|
|
||||||
assert.match(a.texts[0].font, /12px/)
|
|
||||||
const b = makeCtx()
|
|
||||||
S.setGrid(b.ctx, { k: 6 })
|
|
||||||
S.drawBubble(b.ctx, 50, 40, 'Hallo')
|
|
||||||
assert.match(b.texts[0].font, /24px/)
|
|
||||||
const e = makeCtx()
|
|
||||||
S.drawBubble(e.ctx, 50, 40, '')
|
|
||||||
assert.equal(e.rects.length, 0)
|
|
||||||
const o = makeCtx()
|
|
||||||
S.drawBubble(o.ctx, 330, 30, 'Hallo')
|
|
||||||
assert.ok(Math.max(...o.rects.map((q) => q.x + q.w)) <= 960)
|
|
||||||
const small = makeCtx({ width: 100 })
|
|
||||||
S.drawBubble(small.ctx, 20, 30, 'Hallo')
|
|
||||||
assert.ok(Math.max(...small.rects.map((q) => q.x + q.w)) <= 100)
|
|
||||||
})
|
|
||||||
test('drawSideBubble: links neben dem Körper, Spitze nach rechts; am linken Rand rechts daneben, Spitze nach links', () => {
|
|
||||||
const l = makeCtx()
|
|
||||||
S.drawSideBubble(l.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 })
|
|
||||||
assert.deepEqual(l.texts.map((t) => t.text), ['Hallo'])
|
|
||||||
assert.ok(Math.max(...l.rects.map((r) => r.x + r.w)) <= 300, 'endet vor der linken Körperkante')
|
|
||||||
assert.ok(Math.max(...l.rects.map((r) => r.x + r.w)) >= 290, 'Spitze reicht bis an den Körper')
|
|
||||||
assert.equal(l.rects.length, 4)
|
|
||||||
const r = makeCtx()
|
|
||||||
S.drawSideBubble(r.ctx, 'Hallo', { leftX: 10, rightX: 40, y: 10 })
|
|
||||||
assert.ok(Math.min(...r.rects.map((q) => q.x)) >= 120, 'beginnt hinter der rechten Körperkante')
|
|
||||||
assert.ok(Math.min(...r.rects.map((q) => q.x)) <= 124, 'Spitze reicht bis an den Körper')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drawSideBubble: Oberkante bei y, Maße und Schrift folgen dem Raster, rechts in die Bildbreite geklemmt, leer zeichnet nichts', () => {
|
|
||||||
const a = makeCtx()
|
|
||||||
S.drawSideBubble(a.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 })
|
|
||||||
const white = a.rects.find((q) => q.fill === S.PAL.w)
|
|
||||||
assert.deepEqual([white.y, white.h], [30, 22])
|
|
||||||
assert.match(a.texts[0].font, /12px/)
|
|
||||||
const b = makeCtx()
|
|
||||||
S.setGrid(b.ctx, { k: 6 })
|
|
||||||
S.drawSideBubble(b.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 })
|
|
||||||
assert.match(b.texts[0].font, /24px/)
|
|
||||||
assert.deepEqual([b.rects.find((q) => q.fill === S.PAL.w).y, b.rects.find((q) => q.fill === S.PAL.w).h], [60, 44])
|
|
||||||
const small = makeCtx({ width: 120 })
|
|
||||||
S.drawSideBubble(small.ctx, 'Hallo', { leftX: 10, rightX: 40, y: 10 })
|
|
||||||
assert.ok(Math.max(...small.rects.map((q) => q.x + q.w)) <= 120)
|
|
||||||
const e = makeCtx()
|
|
||||||
S.drawSideBubble(e.ctx, '', { leftX: 100, rightX: 130, y: 10 })
|
|
||||||
S.drawSideBubble(e.ctx, undefined, { leftX: 100, rightX: 130, y: 10 })
|
|
||||||
assert.equal(e.rects.length, 0)
|
|
||||||
})
|
|
||||||
Reference in new issue
Block a user