BLOBBO/src/world.ts
type-two d4d41208f8 M0 playable slice: lanes integrated, machine chain staged, hidden-tab hardening
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>
2026-07-17 20:50:16 +10:00

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
}