// 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 }, } }