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:
1 parent
1aabbef3ab
commit
e69c4355bd
5 files changed
+619
-1
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user