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:
1 parent
ad8ee9afe2
commit
cb31d1476b
3 files changed
+1141
No files matched your search
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user