feat(ui): course select on the title + per-course ghosts

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>
This commit is contained in:
type-two 2026-07-20 17:22:48 +10:00
parent e06e17296e
commit 194f3e22d3
7 changed files with 85 additions and 27 deletions

View File

@ -177,3 +177,17 @@ export const COURSES: Record<string, CourseSpec> = {
butter: BUTTER_RUN,
seesaw: SEESAW_LAB, // tuning lab — URL-only, not in the switcher rotation
}
/** The playable rotation, in order one source of truth for the title screen's
* course select and game.ts's in-game switcher. `course` undefined = the
* hand-built default (Breakfast Rush), whose best/ghost use the legacy keys. */
export const COURSE_META: Array<{
course?: string
label: string
emoji: string
href: string
}> = [
{ label: 'Breakfast Rush', emoji: '🍳', href: 'index.html' },
{ course: 'toaster', label: 'Toaster Alley', emoji: '🍞', href: '?course=toaster' },
{ course: 'butter', label: 'Butter Run', emoji: '🧈', href: '?course=butter' },
]

View File

@ -18,7 +18,7 @@ import {
createPaintBubbles, SURFACES, applySurface,
} from './machine/index'
import { installZones } from './course/zones'
import { buildCourseFromSpec, COURSES, type FinishBox } from './course/spec'
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'
@ -372,18 +372,14 @@ export function installGame(world: World) {
setTimeout(() => toast.remove(), 3200)
})
// ---- course switcher: cycles breakfast → toaster → butter → breakfast.
// (The see-saw lab stays URL-only — it's a tuning rig, not a course.) ----
// ---- course switcher: cycles through COURSE_META's rotation (shared with
// the title's course select). Labs/unknown courses cycle home. ----
{
const next: Record<string, { href: string; label: string }> = {
breakfast: { href: '?course=toaster', label: '🍞 Toaster Alley' },
toaster: { href: '?course=butter', label: '🧈 Butter Run' },
butter: { href: 'index.html', label: '🍳 Breakfast Rush' },
}
const other = next[which ?? 'breakfast'] ?? next.butter // unknown/lab 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.label}`
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;' +
@ -407,7 +403,7 @@ export function installGame(world: World) {
// ---- wave 2: audio, ghost, title ----
installAudio(world)
installGhost(world, blob, { radius: blob.radius })
installGhost(world, blob, { radius: blob.radius, course: which ?? undefined })
installTitle(world)
// debug handle (dev builds are the only builds right now)

View File

@ -12,6 +12,12 @@
*/
export const GHOST_KEY = 'blobbo:ghost'
/** Per-course storage key. The default course (Breakfast Rush) keeps the bare
* legacy key so nobody's existing ghost is orphaned by the course system. */
export function ghostKey(course?: string): string {
return course ? `${GHOST_KEY}:${course}` : GHOST_KEY
}
export const RECORD_HZ = 15
export const MAX_SECONDS = 300 // ~5 min recording cap
export const MAX_SAMPLES = MAX_SECONDS * RECORD_HZ // 4500
@ -143,37 +149,37 @@ function store(): Storage | null {
}
/** Persist a track. Returns false if storage is unavailable or the write threw. */
export function saveGhost(track: GhostTrack): boolean {
export function saveGhost(track: GhostTrack, course?: string): boolean {
const s = store()
if (!s) return false
try {
s.setItem(GHOST_KEY, encodeGhost(track))
s.setItem(ghostKey(course), encodeGhost(track))
return true
} catch {
return false
}
}
export function loadGhost(): GhostTrack | null {
export function loadGhost(course?: string): GhostTrack | null {
const s = store()
if (!s) return null
const raw = s.getItem(GHOST_KEY)
const raw = s.getItem(ghostKey(course))
return raw ? decodeGhost(raw) : null
}
export function clearGhost(): void {
store()?.removeItem(GHOST_KEY)
export function clearGhost(course?: string): void {
store()?.removeItem(ghostKey(course))
}
export function hasGhost(): boolean {
return !!store()?.getItem(GHOST_KEY)
export function hasGhost(course?: string): boolean {
return !!store()?.getItem(ghostKey(course))
}
/** Cheap read of just the saved run time (for the title), without decoding samples. */
export function ghostTime(): number | null {
export function ghostTime(course?: string): number | null {
const s = store()
if (!s) return null
const raw = s.getItem(GHOST_KEY)
const raw = s.getItem(ghostKey(course))
if (!raw) return null
try {
const p = JSON.parse(raw) as Partial<GhostPayload>

View File

@ -21,7 +21,7 @@ export function installGhost(
blob: Blob,
opts: GhostPlayerOptions = {},
): GhostHandles {
const recorder = new GhostRecorder(world, blob)
const recorder = new GhostRecorder(world, blob, opts.course)
world.addSystem(recorder)
const radius = opts.radius ?? (blob as { radius?: number }).radius ?? 0.5

View File

@ -24,6 +24,8 @@ const BOB_RATE = 4 // rad/s
export interface GhostPlayerOptions {
/** Visual radius; match the blob so the overlap reads true. Default 0.5. */
radius?: number
/** Course key — replay only THIS course's ghost. Undefined = legacy default. */
course?: string
}
export class GhostPlayer {
@ -33,6 +35,7 @@ export class GhostPlayer {
private t = 0
private bob = 0
private readonly radius: number
private readonly course?: string
private readonly out: GhostSample = { x: 0, y: 0, z: 0, scale: 1 }
private readonly mats: THREE.Material[] = []
private readonly geos: THREE.BufferGeometry[] = []
@ -42,6 +45,7 @@ export class GhostPlayer {
opts: GhostPlayerOptions = {},
) {
this.radius = opts.radius ?? 0.5
this.course = opts.course
world.events.on('race:started', () => this.spawn())
world.events.on('race:finished', () => this.despawn())
world.onFrame((dt) => this.update(dt))
@ -123,7 +127,7 @@ export class GhostPlayer {
}
private spawn(): void {
this.track = loadGhost()
this.track = loadGhost(this.course)
if (!this.track || this.track.count < 2) {
this.track = null // no ghost yet (e.g. first run ever)
return

View File

@ -28,6 +28,9 @@ export class GhostRecorder implements System {
constructor(
private readonly world: World,
private readonly blob: Blob,
/** Course key ghosts are per-course (a Breakfast ghost has no business
* driving through Butter Run's walls). Undefined = the legacy default. */
private readonly course?: string,
) {
world.events.on('race:started', () => this.begin())
world.events.on('race:finished', (p) => this.finish(p))
@ -85,6 +88,6 @@ export class GhostRecorder implements System {
count: this.count,
data: quantize(this.buf, this.count),
}
saveGhost(track)
saveGhost(track, this.course)
}
}

View File

@ -17,6 +17,7 @@
import type { World } from '../contracts'
import { PALETTE } from '../contracts'
import { ghostTime, hasGhost } from '../ghost/format'
import { COURSE_META } from '../course/spec'
export interface TitleOptions {
/** Resolves when Rapier/world finished booting. While pending: loading state. */
@ -89,6 +90,17 @@ function injectStyle(): void {
55%{transform:translateY(0) scale(1.15,.85)}
75%{transform:translateY(-4px) scale(.96,1.05)}}
.blobbo-hint{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:rgba(234,242,255,.65)}
.blobbo-courses{display:flex;flex-wrap:wrap;gap:10px;justify-content:center}
.blobbo-course{display:flex;flex-direction:column;align-items:center;gap:3px;
min-width:118px;padding:10px 14px;border-radius:14px;text-decoration:none;
background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);
color:#eaf2ff;font-weight:700;font-size:14px;
transition:transform .1s ease,background .1s ease}
a.blobbo-course:hover{transform:translateY(-2px);background:rgba(255,255,255,.16)}
.blobbo-course.here{background:rgba(255,225,77,.16);border-color:#ffe14d;cursor:default}
.blobbo-course .em{font-size:26px;line-height:1}
.blobbo-course .tm{font-family:ui-monospace,Menlo,monospace;font-size:12px;
font-weight:600;color:#ffd60a;min-height:1.1em}
`
document.head.appendChild(el)
}
@ -155,11 +167,34 @@ export function installTitle(world: World, opts: TitleOptions = {}): TitleHandle
overlay.appendChild(buildControls())
// saved best + ghost status
// ---- course select: one card per course, with your best + ghost status ----
const here = new URLSearchParams(location.search).get('course') ?? undefined
const courses = document.createElement('div')
courses.className = 'blobbo-courses'
for (const c of COURSE_META) {
const isHere = c.course === here
const card = document.createElement(isHere ? 'div' : 'a')
card.className = 'blobbo-course' + (isHere ? ' here' : '')
if (!isHere) (card as HTMLAnchorElement).href = c.href
const em = document.createElement('div')
em.className = 'em'
em.textContent = c.emoji
const nm = document.createElement('div')
nm.textContent = c.label
const tm = document.createElement('div')
tm.className = 'tm'
const bestT = Number(localStorage.getItem(`blobbo:best${c.course ? ':' + c.course : ''}`)) || null
tm.textContent = (bestT ? `${bestT.toFixed(2)}s` : '—') + (hasGhost(c.course) ? ' 👻' : '')
card.append(em, nm, tm)
courses.appendChild(card)
}
overlay.appendChild(courses)
// saved best + ghost status (for the course you're on)
const best = document.createElement('div')
best.className = 'blobbo-best'
if (hasGhost()) {
const t = ghostTime()
if (hasGhost(here)) {
const t = ghostTime(here)
best.textContent = t != null ? `best ${t.toFixed(2)}s · 👻 ghost ready` : '👻 ghost ready'
}
overlay.appendChild(best)