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:
parent
e06e17296e
commit
194f3e22d3
@ -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' },
|
||||
]
|
||||
|
||||
18
src/game.ts
18
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<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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user