diff --git a/src/course/spec.ts b/src/course/spec.ts index b20a548..dca892e 100644 --- a/src/course/spec.ts +++ b/src/course/spec.ts @@ -177,3 +177,17 @@ export const COURSES: Record = { 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' }, +] diff --git a/src/game.ts b/src/game.ts index 7d90b68..a929044 100644 --- a/src/game.ts +++ b/src/game.ts @@ -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 = { - 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) diff --git a/src/ghost/format.ts b/src/ghost/format.ts index e324d1a..a5fd2ad 100644 --- a/src/ghost/format.ts +++ b/src/ghost/format.ts @@ -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 diff --git a/src/ghost/install.ts b/src/ghost/install.ts index 4641bce..0f6bf22 100644 --- a/src/ghost/install.ts +++ b/src/ghost/install.ts @@ -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 diff --git a/src/ghost/player.ts b/src/ghost/player.ts index 47144a6..01d18c4 100644 --- a/src/ghost/player.ts +++ b/src/ghost/player.ts @@ -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 diff --git a/src/ghost/recorder.ts b/src/ghost/recorder.ts index 863fa69..ee7645e 100644 --- a/src/ghost/recorder.ts +++ b/src/ghost/recorder.ts @@ -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) } } diff --git a/src/ui/title.ts b/src/ui/title.ts index 12b1172..3c4802d 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -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)