/** * INTEGRATION SEAM — owned by integration, not by lanes. * The playable slice: Lane A blob + course, Lane B paint + buffs, and (when * lane-c merges) the machine chain on the course's machine area. */ import * as THREE from 'three' import type { World } from './contracts' import { buildGreybox } from './course/greybox' import { createBlob } from './blob/createBlob' import { createBlobController } from './blob/controller' import { createBlobFeel } from './blob/feel' import { createFollowCamera } from './blob/camera' import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index' import { createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index' import { installZones } from './course/zones' import { installAudio } from './audio/index' import { installGhost } from './ghost/index' import { installTitle } from './ui/title' export function installGame(world: World) { const course = buildGreybox(world) // ---- blob (Lane A) ---- const blob = createBlob(world, { position: course.spawn }) blob.paint = new PaintSkin(blob.mesh, { size: 256 }) world.blob = blob world.addSystem(createBlobController(world, blob)) const feel = createBlobFeel(blob) world.events.on('blob:jumped', () => feel.onJump()) world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact)) world.onFrame((dt) => feel.update(dt)) const camera = createFollowCamera(world, blob) world.onFrame((dt) => camera.update(dt)) // ---- paint economy (Lane G): puddles + colour zones + MEGA/MINI fork ---- // Zones build the fork's own plate/boot (purple lane) and the MINI tunnel // (pink lane), and return cannon configs for the one cannon-staging path here. const skin = blob.paint as PaintSkin const zones = installZones(world, blob, skin) for (const spec of zones.cannonConfigs) { world.addSystem(new PaintCannon({ world, position: new THREE.Vector3(...spec.position), color: spec.color, target: blob.mesh, paint: skin, targetRadius: blob.radius, triggerId: spec.triggerId, interval: spec.interval, muzzleSpeed: 26, // default 16 caps ballistic range at ~18u — course distances need ~26 splatRadius: 0.45, })) } // ---- centre-lane machine garnish (Lane C) ---- // The fork owns plate/boot now (zones.ts). The belt+bucket moved to the // CENTRE lane: un-forked runners get slow-carried under a purple dump — // a free taste of MEGA with no roof above (never dump grow-juice inside // the MINI tunnel: growing under the roof would wedge you). const baseMass = blob.body.mass() createBucketDump(world, { id: 'bucket-1', position: [-1.4, 6, -50], color: 'purple', radius: 0.8, // Shallow catch just upstream of the spout (bucket.x+1.4 = belt centre x=0): // observed teeter drift is 0.4-2u depending on speed. trigger: { size: [3, 2, 1.6], offset: [1.4, -5.2, 0.75] }, }) createConveyorBelt(world, { id: 'belt-1', position: [0, 0.0, -50], // sunk flush with the machine pad — a proud belt face curbed the racing line size: [3, 0.5, 10], velocity: [0, 0, -3], }) createBubbleArch(world, { id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // purple-lane exit: cleansing punishes a failed MEGA attempt }) world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin })) installPaint(world, { mesh: blob.mesh, paint: skin }) const hud = new PaintHUD() world.onFrame(() => { hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms }) // ---- gap-recovery trampoline: falling in the gap jump must cost time, not // soft-lock you under the green slope (straight-line drivers wedged at z≈-16.6). // Max-combine restitution bounces fallers back to shelf height. ---- { const tramp = new THREE.Mesh( new THREE.BoxGeometry(12, 0.5, 4), new THREE.MeshStandardMaterial({ color: '#FF9500', roughness: 0.6 })) tramp.position.set(0, 0.25, 0) // exactly the open slot between the shelves tramp.receiveShadow = true world.scene.add(tramp) world.physics.createCollider( world.rapier.ColliderDesc.cuboid(6, 0.25, 2).setTranslation(0, 0.25, 0)) // Fill BOTH under-shelf volumes solid: the front-face plinth stopped // overshooters, but lane-huggers rolled in from the open x±7 sides and // wedged (observed at (-4.1,-7.9)). Under-shelf space serves no gameplay — // make the shelves read as solid blocks down to the floor. const plinthMat = new THREE.MeshStandardMaterial({ color: '#0A5FCB', roughness: 0.8 }) // Central fill only (x±5.2): observed wedges are all |x|<4.5; full-width // fills walled off the floor-level side corridors lane-hug routes use. for (const [zc, hz] of [[6, 4], [-6, 4]] as const) { const fill = new THREE.Mesh(new THREE.BoxGeometry(10.4, 2.8, hz * 2), plinthMat) fill.position.set(0, 1.4, zc) world.scene.add(fill) world.physics.createCollider( world.rapier.ColliderDesc.cuboid(5.2, 1.4, hz).setTranslation(0, 1.4, zc)) } // Deterministic launcher, not restitution: raw bounciness pachinkos off the // shelf's front edge and can still slip underneath. Catch a falling blob on // the pad and set a clean arc onto the landing shelf. let bounceCooldown = 0 world.addSystem({ update(dt) { bounceCooldown = Math.max(0, bounceCooldown - dt) const t = blob.body.translation() const v = blob.body.linvel() const onPad = Math.abs(t.x) < 7.2 && t.z > -2.2 && t.z < 2.2 && t.y < 1.4 // full gap width — lane-huggers fall at |x|≈7 if (onPad && v.y < 0.5 && bounceCooldown === 0) { // High + centered: held-W air control (~24 u/s²) flattens shallow arcs // into the shelf face, so go UP hard and pull toward the slot center; // forward drift only wins after the arc clears the shelf top. const vz = Math.max(-3, Math.min(3, (0.5 - t.z) * 1.5)) const vx = Math.max(-3, Math.min(3, (0 - t.x) * 1.5)) // center x too — cannon impacts drift racers sideways blob.body.setLinvel({ x: vx, y: 14, z: vz }, true) bounceCooldown = 0.6 world.events.emit('blob:jumped', {}) // reuse the stretch feel } }, }) } // ---- race loop: first movement starts the clock, the pink pad ends it ---- // Finish pad volume, extended to z -57.2 so a blob bonking the pad's front // face (it's a 1.2-high podium, unclimbable from the floor) still counts as // touching the finish. Mid-air crossings count too — the boot is a legit finisher. const FINISH = { xMin: -9, xMax: 9, zMin: -70, zMax: -57.2, yMax: 4 } let raceState: 'idle' | 'running' | 'finished' = 'idle' let raceT = 0 let best: number | null = Number(localStorage.getItem('blobbo:best')) || null const raceUI = document.createElement('div') raceUI.style.cssText = 'position:fixed;top:12px;right:14px;font:700 20px ui-monospace,Menlo,monospace;' + 'color:#0a2540;background:rgba(255,255,255,.82);padding:8px 14px;border-radius:10px;' + 'z-index:15;text-align:right;pointer-events:none' document.body.appendChild(raceUI) const banner = document.createElement('div') banner.style.cssText = 'position:fixed;inset:0;display:none;align-items:center;justify-content:center;' + 'flex-direction:column;gap:12px;font:800 42px ui-monospace,Menlo,monospace;color:#fff;' + 'background:rgba(20,24,34,.72);z-index:30;text-align:center' document.body.appendChild(banner) const fmt = (s: number) => s.toFixed(2) + 's' let finishHold = 0 world.addSystem({ update(dt) { const t = blob.body.translation() const v = blob.body.linvel() if (raceState === 'idle') { if (Math.hypot(v.x, v.z) > 0.6) { raceState = 'running' raceT = 0 world.events.emit('race:started', {}) } } else if (raceState === 'running') { raceT += dt const inFinish = t.x >= FINISH.xMin && t.x <= FINISH.xMax && t.z >= FINISH.zMin && t.z <= FINISH.zMax && t.y <= FINISH.yMax if (inFinish) { raceState = 'finished' const isBest = best === null || raceT < best if (isBest) { best = raceT localStorage.setItem('blobbo:best', String(raceT)) } banner.innerHTML = `
🏁 FINISH — ${fmt(raceT)}
` + `
${isBest ? 'NEW BEST!' : 'best ' + fmt(best!)}
` + `
R / Start to race again — auto in 6s
` banner.style.display = 'flex' world.events.emit('race:finished', { time: raceT, best }) finishHold = 6 } } else { finishHold -= dt if (finishHold <= 0) respawn() } raceUI.textContent = (raceState === 'running' ? '⏱ ' + fmt(raceT) : raceState === 'idle' ? '⏱ ready' : '🏁 ' + fmt(raceT)) + (best !== null ? ' · best ' + fmt(best) : '') }, }) // ---- fall respawn + debug ---- const respawn = () => { blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true) blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true) world.events.emit('paint:request-cleanse', { fraction: 1 }) raceState = 'idle' raceT = 0 banner.style.display = 'none' world.events.emit('race:respawn', {}) } world.onFrame(() => { if (blob.body.translation().y < -25) respawn() }) addEventListener('keydown', (e) => { if (e.code === 'KeyR') respawn() if (e.code === 'KeyC') world.events.emit('paint:request-cleanse', { fraction: 0.5 }) }) // ---- gamepad niceties (movement/jump live in the blob controller) ---- let startHeld = false world.addSystem({ update() { const pads = navigator.getGamepads?.() if (!pads) return for (const p of pads) { if (!p || !p.connected) continue const s = !!p.buttons[9]?.pressed // Start/Options = respawn if (s && !startHeld) respawn() startHeld = s break } }, }) addEventListener('gamepadconnected', (e) => { const toast = document.createElement('div') toast.textContent = `🎮 ${(e as GamepadEvent).gamepad.id.slice(0, 40)} connected — stick to roll, A to jump, Start to respawn` toast.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);' + 'font:13px ui-monospace,Menlo,monospace;color:#fff;background:rgba(20,24,34,.88);' + 'padding:9px 14px;border-radius:9px;z-index:20;pointer-events:none' document.body.appendChild(toast) setTimeout(() => toast.remove(), 3200) }) // ---- wave 2: audio, ghost, title ---- installAudio(world) installGhost(world, blob, { radius: blob.radius }) installTitle(world) // debug handle (dev builds are the only builds right now) ;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, course } return { course, blob } }