The title screen now shows a course-select row: one card per course with its emoji, name, your best time, and a ghost badge. The current course is highlighted "you are here"; the others are links. COURSE_META in course/spec is the one source of truth for the rotation — the title cards and the in-game switcher both read it, so they can't drift. Fixes a real cross-course bug found while scoping this: ghosts were stored under one global key, so your Breakfast Rush ghost would replay through Butter Run's walls. save/load/has/time now take an optional course key (threaded installGhost -> recorder/player); the default course keeps the bare legacy key so nobody's existing ghost is orphaned. The title's best/ghost line is course-aware for the same reason. Browser-verified with seeded storage: cards show per-course bests and ghost badges independently, current-course card highlights, clicking a card loads that course, breakfast's data untouched from butter. tsc + build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
414 lines
19 KiB
TypeScript
414 lines
19 KiB
TypeScript
/**
|
|
* INTEGRATION SEAM — owned by integration, not by lanes.
|
|
* The playable slice: a blob + paint + buffs, the shared race loop, and a
|
|
* COURSE. The flagship "Breakfast Rush" is hand-built (buildBreakfastRush — it
|
|
* carries a lot of integration tuning); extra courses are data (see course/spec)
|
|
* and load with `?course=<name>`.
|
|
*/
|
|
import * as THREE from 'three'
|
|
import type { World } from './contracts'
|
|
import { buildGreybox } from './course/greybox'
|
|
import { createBlob, type BlobHandle } 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, createPaintMine, createColorGate,
|
|
createPaintBubbles, SURFACES, applySurface,
|
|
} from './machine/index'
|
|
import { installZones } from './course/zones'
|
|
import { buildCourseFromSpec, COURSES, COURSE_META, type FinishBox } from './course/spec'
|
|
import { installCheckpoints, type CheckpointList } from './course/checkpoints'
|
|
import { assets } from './assets/registry'
|
|
import { hasOverrides } from './assets/idb'
|
|
import { installAudio } from './audio/index'
|
|
import { installGhost } from './ghost/index'
|
|
import { installTitle } from './ui/title'
|
|
|
|
/** Breakfast Rush spawn — matches buildGreybox()'s recommended spawn plateau. */
|
|
const BREAKFAST_SPAWN = new THREE.Vector3(0, 3.5, 30)
|
|
|
|
/** Breakfast Rush checkpoints: landing shelf, slope bottom, fork entrance.
|
|
* y is the local floor top at that spot (posts stand on it; respawn is +0.8). */
|
|
const BREAKFAST_CHECKPOINTS: CheckpointList = [
|
|
// Two, both on dry open ground. No mid-course one: the slope's volume would
|
|
// eject a respawner and the river's rinse strips the paint a checkpoint is
|
|
// supposed to preserve — a fall there honestly costs you the river re-run.
|
|
[0, 3.5, -6], // gap-jump landing shelf
|
|
[0, 0.24, -42], // machine district / fork entrance
|
|
]
|
|
|
|
/**
|
|
* Build the hand-tuned flagship course onto the world and return its finish box.
|
|
* (Everything here used to live inline in installGame; it is course-specific.)
|
|
*/
|
|
function buildBreakfastRush(world: World, blob: BlobHandle, skin: PaintSkin): FinishBox {
|
|
buildGreybox(world)
|
|
|
|
// ---- paint economy (Lane G): puddles + colour zones + MEGA/MINI fork ----
|
|
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).
|
|
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
|
|
})
|
|
|
|
// ---- paint bubbles: blue soap bubbles drifting over the milk-river approach.
|
|
// Pop one mid-run → SLICK/waterproof → your paint survives the rinse. The
|
|
// bubble colour teaches the counter to the hazard it floats above. ----
|
|
createPaintBubbles(world, {
|
|
id: 'bubbles-river', position: [0, 1.4, -24], color: 'blue', radius: 0.6,
|
|
splats: 2, count: 6, area: [5, 1.0, 2.5], drift: [0, 0.3, 0], bubbleRadius: 0.6,
|
|
})
|
|
|
|
// ---- paint-mines: first-through floor panels on the centre line ----
|
|
// REWARD: roll over it and it launches you forward + coats you GREEN (GRIP) —
|
|
// a shortcut booster for the runner who takes the middle. HAZARD: further down,
|
|
// dumps heavy PURPLE (MEGA) and shoves you toward the pink tunnel lane where a
|
|
// grown blob bonks the roof — the greedy straight-liner pays for it. Both
|
|
// re-arm each run (race:respawn) so the time-trial stays repeatable.
|
|
createPaintMine(world, {
|
|
id: 'mine-reward', position: [0, 0.06, -34], color: 'green', radius: 0.9, splats: 5,
|
|
impulse: 8, direction: [0, 1, -0.6], size: [3.4, 3.4],
|
|
})
|
|
createPaintMine(world, {
|
|
id: 'mine-hazard', position: [0, 0.24, -44], color: 'purple', radius: 0.8, splats: 4,
|
|
impulse: 7, direction: [0.8, 0.5, 0], size: [3, 3],
|
|
})
|
|
|
|
// ---- oil slick: a slippery patch on the flat approach. applySurface gives
|
|
// it oil friction with a Min combine rule, so it stays slick under ANY blob
|
|
// (even a grippy green one), and the userData tag lets the controller throttle
|
|
// steering + add drag → you luge across it. Demo of the surface framework. ----
|
|
{
|
|
const oilMat = new THREE.MeshStandardMaterial({
|
|
color: SURFACES.oil.color, roughness: 0.12, metalness: 0.35,
|
|
})
|
|
const oil = new THREE.Mesh(new THREE.BoxGeometry(6, 0.08, 4), oilMat)
|
|
oil.position.set(0, 0.28, -40) // machine-pad centre lane (top y0.24), clear of the fork exits + hazard mine
|
|
oil.receiveShadow = true
|
|
world.scene.add(oil)
|
|
const oilCol = world.physics.createCollider(
|
|
world.rapier.ColliderDesc.cuboid(3, 0.04, 2).setTranslation(0, 0.28, -40))
|
|
applySurface(world.rapier, oilCol, 'oil')
|
|
;(oilCol as unknown as { userData?: unknown }).userData = { surface: 'oil' }
|
|
}
|
|
|
|
// ---- colour-keyed gate: a GREEN shortcut through the centre lane, just
|
|
// before the finish. Roll over the green reward mine upstream and you arrive
|
|
// green enough for the gate to sink into the floor — a straight blast to the
|
|
// line. Arrive un-green and it stays shut, so you steer around the short
|
|
// flank walls (a detour). Closes the core loop solo: paint decides your route.
|
|
// Walls only cover the centre (|x|<4) so the fork exits (boot x-6, tunnel
|
|
// x6.5) are untouched. ----
|
|
{
|
|
const gz = -59
|
|
const wallMat = new THREE.MeshStandardMaterial({ color: '#5b6b7a', roughness: 0.85 })
|
|
for (const x of [-3, 3] as const) { // flank the centre gate gap (gate is x[-2,2])
|
|
const wall = new THREE.Mesh(new THREE.BoxGeometry(2, 3, 0.8), wallMat)
|
|
wall.position.set(x, 1.5, gz)
|
|
wall.castShadow = true
|
|
wall.receiveShadow = true
|
|
world.scene.add(wall)
|
|
world.physics.createCollider(
|
|
world.rapier.ColliderDesc.cuboid(1, 1.5, 0.4).setTranslation(x, 1.5, gz))
|
|
}
|
|
createColorGate(world, {
|
|
id: 'gate-green', position: [0, 0.02, gz], color: 'green', size: [4, 3, 0.6],
|
|
qualifies: () => skin.coverage().byColor.green >= 0.4,
|
|
})
|
|
}
|
|
|
|
// ---- 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).
|
|
// A deterministic launcher 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))
|
|
// course.tramp slot: hide the MATERIAL, not the mesh — the custom model is
|
|
// parented as a child, so hiding the mesh would hide it too. The collider
|
|
// above is untouched, so the bounce is identical whatever the pad looks like.
|
|
assets().attachSlot('course.tramp', tramp, {
|
|
onSwap: () => { (tramp.material as THREE.Material).visible = false },
|
|
})
|
|
// 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
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
// Finish pad volume, extended to z -57.2 so a blob bonking the pad's front
|
|
// face (a 1.2-high podium, unclimbable from the floor) still counts as a
|
|
// finish. Mid-air crossings count too — the boot is a legit finisher.
|
|
return { xMin: -9, xMax: 9, zMin: -70, zMax: -57.2, yMax: 4 }
|
|
}
|
|
|
|
export function installGame(world: World) {
|
|
// ---- course selection: default = hand-built Breakfast Rush; ?course=<name>
|
|
// loads a data-driven course from COURSES. Spawn is known before geometry. ----
|
|
const which = new URLSearchParams(location.search).get('course')
|
|
const spec = which ? COURSES[which] : undefined
|
|
const spawn = spec ? new THREE.Vector3(...spec.spawn) : BREAKFAST_SPAWN.clone()
|
|
|
|
// ---- blob (Lane A) ----
|
|
const blob = createBlob(world, { position: 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))
|
|
|
|
const skin = blob.paint as PaintSkin
|
|
|
|
// ---- build the selected course, get its finish box ----
|
|
const finish = spec
|
|
? buildCourseFromSpec(world, spec, { blob, skin }).finish
|
|
: buildBreakfastRush(world, blob, skin)
|
|
|
|
// ---- checkpoints: a fall costs time, not the run (see course/checkpoints) ----
|
|
const cps = installCheckpoints(world, blob, spec ? spec.checkpoints ?? [] : BREAKFAST_CHECKPOINTS)
|
|
|
|
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
|
|
})
|
|
|
|
// ---- race loop: first movement starts the clock, the finish box ends it ----
|
|
// Per-course best time so switching courses doesn't compare apples to oranges.
|
|
const bestKey = `blobbo:best${spec ? ':' + which : ''}`
|
|
let raceState: 'idle' | 'running' | 'finished' = 'idle'
|
|
let raceT = 0
|
|
let best: number | null = Number(localStorage.getItem(bestKey)) || 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(bestKey, 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: spawn.x, y: spawn.y, z: 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', {})
|
|
}
|
|
// Fixed-step, not onFrame: the fall check GATES gameplay, and onFrame stalls
|
|
// in hidden tabs (same lesson as telegraph/bucket — found at integration).
|
|
world.addSystem({
|
|
update() {
|
|
if (blob.body.translation().y < -25) {
|
|
// Mid-run with a checkpoint lit → soft respawn there: clock keeps running
|
|
// (the fall's cost is time) and paint stays (progress already earned).
|
|
// Otherwise it's a full restart to the line.
|
|
const cp = raceState === 'running' ? cps.last() : null
|
|
if (cp) {
|
|
blob.body.setTranslation({ x: cp.x, y: cp.y + 0.8, z: cp.z }, true)
|
|
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
|
|
} else {
|
|
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)
|
|
})
|
|
|
|
// ---- course switcher: cycles through COURSE_META's rotation (shared with
|
|
// the title's course select). Labs/unknown courses cycle home. ----
|
|
{
|
|
const i = COURSE_META.findIndex((c) => c.course === (which ?? undefined))
|
|
const other = COURSE_META[(i + 1) % COURSE_META.length] ?? COURSE_META[0]
|
|
const link = document.createElement('a')
|
|
link.href = other.href
|
|
link.textContent = `▸ course: ${other.emoji} ${other.label}`
|
|
link.style.cssText =
|
|
'position:fixed;top:56px;right:14px;font:12px ui-monospace,Menlo,monospace;' +
|
|
'color:#0a2540;background:rgba(255,255,255,.82);padding:6px 11px;border-radius:9px;' +
|
|
'text-decoration:none;z-index:15;box-shadow:0 1px 6px rgba(0,0,0,.14)'
|
|
document.body.appendChild(link)
|
|
}
|
|
|
|
// ---- custom-assets pill: a forgotten Workshop swap otherwise reads as a
|
|
// broken game ("why is the boot a weird cylinder?"), with no way back. ----
|
|
void hasOverrides().then((on) => {
|
|
if (!on) return
|
|
const pill = document.createElement('a')
|
|
pill.href = 'editor.html'
|
|
pill.textContent = '🎨 your custom models are on — open the Workshop'
|
|
pill.style.cssText =
|
|
'position:fixed;bottom:14px;left:14px;font:12px ui-monospace,Menlo,monospace;' +
|
|
'color:#0a2540;background:rgba(255,255,255,.86);padding:7px 12px;border-radius:999px;' +
|
|
'text-decoration:none;z-index:18;box-shadow:0 1px 6px rgba(0,0,0,.18)'
|
|
document.body.appendChild(pill)
|
|
})
|
|
|
|
// ---- wave 2: audio, ghost, title ----
|
|
installAudio(world)
|
|
installGhost(world, blob, { radius: blob.radius, course: which ?? undefined })
|
|
installTitle(world)
|
|
|
|
// debug handle (dev builds are the only builds right now)
|
|
;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, spawn, finish }
|
|
|
|
return { blob, spawn, finish }
|
|
}
|