diff --git a/src/game.ts b/src/game.ts index 357452f..f3d0b9c 100644 --- a/src/game.ts +++ b/src/game.ts @@ -96,11 +96,123 @@ export function installGame(world: World) { 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)) + // Seal the under-shelf cavity: fast racers overshoot the slot and slip + // beneath the landing shelf into an inescapable wedge (constant minZ -17 in + // testing). Wall is flush with the shelf face, so it reads as its plinth. + const plinth = new THREE.Mesh( + new THREE.BoxGeometry(14, 2.5, 0.5), + new THREE.MeshStandardMaterial({ color: '#0A5FCB', roughness: 0.8 })) + plinth.position.set(0, 1.25, -2) + world.scene.add(plinth) + world.physics.createCollider( + world.rapier.ColliderDesc.cuboid(7, 1.25, 0.25).setTranslation(0, 1.25, -2)) + // 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) < 6 && t.z > -2.2 && t.z < 2.2 && t.y < 1.4 + 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)) + blob.body.setLinvel({ x: v.x * 0.4, 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 = + `