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

+179
View File
@@ -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
},
}
}