Integration: blob+paint+machines composed in game.ts; cannons staged along the racing line (muzzle 26 — default 16 undershoots course distances); plate threshold above clean-blob mass so only painted blobs trip it (paint=weight= machine access); belt delivers riders under the bucket (shallow catch timed to teeter drift); arch moved to a detour lane. Cross-lane fixes found at integration, all one class — gameplay logic living on render-frame time, which stalls in hidden tabs: - telegraph windup -> fixed step (gates forces/dumps) - bucket tip/pour -> fixed step (gates the splat) - blob group sync -> fixed step in controller (paint proximity gate reads it) world.ts grew tick()/renderOnce() + a hidden-tab watchdog interval for this. Assets: mesh wave 1 (4 GLBs via farm: blobbo-base, toaster, spring boot, paint bucket) + mesh_pipeline.py with outdir scp fallback for the farm's unregistered-remote-outputs bug. Verified headless end-to-end via tick(): move/jump/squash, cannon hits on a moving blob, coverage->buffs (BURN 1.6x, super at 70%), mass 1.0->1.67 with paint, plate clean-reject + painted-trip, boot launch (peak y 3.9), belt-> bucket dump (23.6% purple), arch cleanse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
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<string, Set<(p: any) => 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<World> {
|
|
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
|
|
}
|