import * as THREE from 'three' import RAPIER from '@dimforge/rapier3d-compat' import type { Events, System, World } from './contracts' const FIXED_DT = 1 / 60 function makeEvents(): Events { const map = new Map void>>() return { on(name, fn) { if (!map.has(name)) map.set(name, new Set()) map.get(name)!.add(fn) return () => map.get(name)!.delete(fn) }, emit(name, payload) { map.get(name)?.forEach((fn) => fn(payload)) }, } } export async function createWorld(container: HTMLElement): Promise { await RAPIER.init() const scene = new THREE.Scene() scene.background = new THREE.Color('#bfe3ff') scene.fog = new THREE.Fog('#bfe3ff', 60, 160) const camera = new THREE.PerspectiveCamera( 60, container.clientWidth / container.clientHeight, 0.1, 400) camera.position.set(0, 6, 12) const renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(container.clientWidth, container.clientHeight) renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) renderer.shadowMap.enabled = true container.appendChild(renderer.domElement) const sun = new THREE.DirectionalLight('#fffbe8', 2.2) sun.position.set(18, 30, 10) sun.castShadow = true sun.shadow.mapSize.set(2048, 2048) sun.shadow.camera.left = sun.shadow.camera.bottom = -50 sun.shadow.camera.right = sun.shadow.camera.top = 50 scene.add(sun, new THREE.AmbientLight('#cfe8ff', 0.9)) const physics = new RAPIER.World({ x: 0, y: -14, z: 0 }) // gamey gravity const systems: System[] = [] const frameHooks: Array<(dt: number) => void> = [] const events = makeEvents() const step = () => { physics.step() for (const s of systems) s.update(FIXED_DT) } const world: World = { scene, camera, renderer, physics, rapier: RAPIER, events, addSystem: (s) => void systems.push(s), onFrame: (fn) => void frameHooks.push(fn), tick(steps = 1) { for (let i = 0; i < steps; i++) step() }, renderOnce() { for (const fn of frameHooks) fn(FIXED_DT) renderer.render(scene, camera) }, start() { let last = performance.now() let acc = 0 const advance = (now: number) => { const dt = Math.min((now - last) / 1000, 0.1) last = now acc += dt while (acc >= FIXED_DT) { step() acc -= FIXED_DT } return dt } const loop = (now: number) => { requestAnimationFrame(loop) const dt = advance(now) for (const fn of frameHooks) fn(dt) renderer.render(scene, camera) } requestAnimationFrame(loop) // Hidden tabs suspend rAF entirely — keep the sim alive on a coarse // interval so the game (and headless verification) survives backgrounding. setInterval(() => { if (performance.now() - last > 500) advance(performance.now()) }, 250) }, } addEventListener('resize', () => { camera.aspect = container.clientWidth / container.clientHeight camera.updateProjectionMatrix() renderer.setSize(container.clientWidth, container.clientHeight) }) return world }