Workers P2: Regie 1 (Agenten, Snapshot ohne Animation, Posen, Leerlauf)

Dazu drawSideBubble in sprites.js: Ottos seitliche Blase braucht das Raster des Kontexts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 02:53:59 +00:00
1 parent 1aabbef3ab
commit e69c4355bd
5 files changed
+619 -1

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
// 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.
import { ROLES, hatForWorker } from '../../../lib/workersRoster.js'
import { indexWorkers, cutBubble } from '../../../lib/workersEvents.js'
import { homeOf } from './layout.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 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 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)
// 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)
/* createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) → { agents, byId, applyState(state), update(dt), pending(), 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. */
export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) {
const agents = []
const byId = Object.create(null)
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: [],
cur: null,
stepT: 0,
groups: 0,
box: null,
home,
ph: rnd() * 6, // Phase des Wippens: nicht alle im Gleichschritt
}
a.lane = laneTarget(a)
agents.push(a)
byId[a.id] = a
}
// Auf den Heimatplatz stellen (erster Snapshot, T13: 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)
}
}
// Eintrag aus indexWorkers (oder null = fehlt im Zustand) auf den Agenten übertragen
function adopt(a, w) {
a.status = w ? w.status : 'grau'
a.grey = a.status === 'grau'
a.alert = a.status === 'fehler'
a.counters = w ? w.counters : {}
if (!busy(a)) {
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) {
const workers = indexWorkers(state)
for (const a of agents) adopt(a, Object.hasOwn(workers, a.id) ? workers[a.id] : null)
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
}
function stepAgent(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
}
}
}
function update(dt) {
if (look.paused || !Number.isFinite(dt) || dt <= 0) return
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
}
}
// 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 }
}
// ---------- 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)
}
+39 -1
View File
@@ -253,6 +253,9 @@ function deviceRect(ctx, x, y, w, h, 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) {
@@ -270,7 +273,7 @@ export function drawBubble(ctx, x, y, text) {
const bh = u(22)
const tipX = Math.round(x * k + ox)
const by = Math.round(y * k + oy) - bh - u(6)
const maxW = ctx.canvas && ctx.canvas.width > 0 ? ctx.canvas.width : 960
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)
@@ -282,3 +285,38 @@ export function drawBubble(ctx, x, y, text) {
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))
}