Workers P2: Regie 2 (Schritte, Aufzugfahrten, Rückstau-Kappung, Wachhund)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 03:14:55 +00:00
1 parent e69c4355bd
commit ad8ee9afe2
2 files changed
+848 -27

No files matched your search

+438 -24
View File
@@ -1,17 +1,20 @@
// Regie des Hustler-Hochhauses, Teil 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen.
// Portiert aus entwuerfe/scene-turm.js (Agenten Z. 596-625, Update Z. 864-905, Zeichnen Z. 925-960).
// Schritte, Aufzugfahrten, Rückstau-Kappung und Wachhund ergänzt P2-T13. Kein React, kein Appwrite, kein window/document.
// 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, cutBubble } from '../../../lib/workersEvents.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 Ereignisse (T13)
export const GROUPS_PER_AGENT = 4 // je Worker höchstens so viele wartende Gruppen (T13)
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 (T13)
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
@@ -22,6 +25,17 @@ 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)
@@ -53,13 +67,47 @@ 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)
/* createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) → { agents, byId, applyState(state), update(dt), pending(), boxes(), bubble(agent, text, dur) }
// 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).
update(dt) tut bei look.paused nichts und läuft sonst ohne Obergrenze für dt (flush in T13 ruft es mit 0,25). rnd streut die Wipp-Phase. */
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)
@@ -91,12 +139,14 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma
bob: 0,
idleT: 0,
cheer: 0,
q: [],
cur: null,
stepT: 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)
@@ -104,7 +154,11 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma
byId[a.id] = a
}
// Auf den Heimatplatz stellen (erster Snapshot, T13: reset)
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
@@ -127,16 +181,24 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma
}
}
// Eintrag aus indexWorkers (oder null = fehlt im Zustand) auf den Agenten übertragen
function adopt(a, w) {
// 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)) {
a.task = w ? w.task : ''
a.pose = poseFor(a)
if (busy(a)) {
lagging.add(a.id)
return
}
lagging.delete(a.id)
a.task = w ? w.task : ''
a.pose = poseFor(a)
}
function applyKpis(kpis) {
@@ -153,8 +215,8 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma
}
function applyState(state) {
const workers = indexWorkers(state)
for (const a of agents) adopt(a, Object.hasOwn(workers, a.id) ? workers[a.id] : null)
workers = indexWorkers(state)
for (const a of agents) adopt(a, entryOf(a))
if (first) {
first = false
for (const a of agents) placeHome(a)
@@ -176,7 +238,326 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma
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)
@@ -196,8 +577,8 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma
}
}
function update(dt) {
if (look.paused || !Number.isFinite(dt) || dt <= 0) return
// 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)
@@ -211,12 +592,45 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma
}
}
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 }))
}
return { agents, byId, applyState, update, pending: () => 0, boxes, bubble }
const debug = () => ({ door: door.slice(), groups: pending() })
return { agents, byId, applyState, pushEvents, update, flush, reset, pending, debug, boxes, bubble }
}
// ---------- Zeichnen ----------