From cb31d1476bc5affb1168eb2e984e73f4fa14cd99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:37:08 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P2:=20mountTower=20(Gr=C3=B6=C3=9Fe,?= =?UTF-8?q?=2030-fps-Schleife,=20Eingabe,=20Kamera,=20Lebenszyklus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/engine/scene.js | 385 +++++++++++++++++ tests/unit/helpers/engineVm.mjs | 179 ++++++++ tests/unit/workersScene.test.mjs | 577 +++++++++++++++++++++++++ 3 files changed, 1141 insertions(+) create mode 100644 src/components/workers/engine/scene.js create mode 100644 tests/unit/helpers/engineVm.mjs create mode 100644 tests/unit/workersScene.test.mjs diff --git a/src/components/workers/engine/scene.js b/src/components/workers/engine/scene.js new file mode 100644 index 0000000..4273652 --- /dev/null +++ b/src/components/workers/engine/scene.js @@ -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 +} diff --git a/tests/unit/helpers/engineVm.mjs b/tests/unit/helpers/engineVm.mjs new file mode 100644 index 0000000..60a621a --- /dev/null +++ b/tests/unit/helpers/engineVm.mjs @@ -0,0 +1,179 @@ +// 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 + }, + } +} diff --git a/tests/unit/workersScene.test.mjs b/tests/unit/workersScene.test.mjs new file mode 100644 index 0000000..ac5d17e --- /dev/null +++ b/tests/unit/workersScene.test.mjs @@ -0,0 +1,577 @@ +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', '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, 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('noch nicht angeschlossen') + const liftBack = rectN((r) => r.fill === '#f1f3f8', 'Rückwand der Kabine') + const figure = rectN((r) => r.alpha === 0.55, 'graue Figur') + const liftFront = rectN((r) => r.fill === '#ffe9a8', 'Lampe der Kabine') + const veil = textN('Pausiert') + assert.deepEqual([outside, tower, rooms, liftBack, figure, liftFront, veil].toSorted((a, b) => a - b), [outside, tower, rooms, liftBack, figure, liftFront, veil]) + 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() +})