Workers P2: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 03:37:47 +00:00
1 parent ad8ee9afe2
commit cb31d1476b
3 files changed
+1141

No files matched your search

+385
View File
@@ -0,0 +1,385 @@
// 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 } 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, 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), 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,
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.
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)
lift.drawBack(ctx)
drawAgents(ctx, director, selectedId, time)
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()
}
},
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
}