Race loop: timer, finish pad, best time, auto-restart + gap-recovery launcher
First movement starts the clock; touching the pink pad (incl. its front face — it's a 1.2-high podium, and mid-air boot crossings) finishes: banner, localStorage best, R/Start or 6s auto-restart. Course fix: the gap jump soft-locked straight- line drivers under the green slope — sealed the under-shelf cavity (plinth) and added a deterministic launcher pad in the slot (restitution pachinkoed off the shelf face; held-W air control flattens shallow arcs, so it launches high+centered). Verified headless: naive hold-W racer finishes in ~12s, loop restarts, best persists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a966e65bde
commit
dd94161b8e
112
src/game.ts
112
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 =
|
||||
`<div>🏁 FINISH — ${fmt(raceT)}</div>` +
|
||||
`<div style="font-size:22px">${isBest ? 'NEW BEST!' : 'best ' + fmt(best!)}</div>` +
|
||||
`<div style="font-size:16px;opacity:.85">R / Start to race again — auto in 6s</div>`
|
||||
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.onFrame(() => {
|
||||
if (blob.body.translation().y < -25) respawn()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user