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,
|
butter: BUTTER_RUN,
|
||||||
seesaw: SEESAW_LAB, // tuning lab — URL-only, not in the switcher rotation
|
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,
|
createPaintBubbles, SURFACES, applySurface,
|
||||||
} from './machine/index'
|
} from './machine/index'
|
||||||
import { installZones } from './course/zones'
|
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 { installCheckpoints, type CheckpointList } from './course/checkpoints'
|
||||||
import { assets } from './assets/registry'
|
import { assets } from './assets/registry'
|
||||||
import { hasOverrides } from './assets/idb'
|
import { hasOverrides } from './assets/idb'
|
||||||
@ -372,18 +372,14 @@ export function installGame(world: World) {
|
|||||||
setTimeout(() => toast.remove(), 3200)
|
setTimeout(() => toast.remove(), 3200)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- course switcher: cycles breakfast → toaster → butter → breakfast.
|
// ---- course switcher: cycles through COURSE_META's rotation (shared with
|
||||||
// (The see-saw lab stays URL-only — it's a tuning rig, not a course.) ----
|
// the title's course select). Labs/unknown courses cycle home. ----
|
||||||
{
|
{
|
||||||
const next: Record<string, { href: string; label: string }> = {
|
const i = COURSE_META.findIndex((c) => c.course === (which ?? undefined))
|
||||||
breakfast: { href: '?course=toaster', label: '🍞 Toaster Alley' },
|
const other = COURSE_META[(i + 1) % COURSE_META.length] ?? COURSE_META[0]
|
||||||
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 link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = other.href
|
link.href = other.href
|
||||||
link.textContent = `▸ course: ${other.label}`
|
link.textContent = `▸ course: ${other.emoji} ${other.label}`
|
||||||
link.style.cssText =
|
link.style.cssText =
|
||||||
'position:fixed;top:56px;right:14px;font:12px ui-monospace,Menlo,monospace;' +
|
'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;' +
|
'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 ----
|
// ---- wave 2: audio, ghost, title ----
|
||||||
installAudio(world)
|
installAudio(world)
|
||||||
installGhost(world, blob, { radius: blob.radius })
|
installGhost(world, blob, { radius: blob.radius, course: which ?? undefined })
|
||||||
installTitle(world)
|
installTitle(world)
|
||||||
|
|
||||||
// debug handle (dev builds are the only builds right now)
|
// debug handle (dev builds are the only builds right now)
|
||||||
|
|||||||
@ -12,6 +12,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const GHOST_KEY = 'blobbo:ghost'
|
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 RECORD_HZ = 15
|
||||||
export const MAX_SECONDS = 300 // ~5 min recording cap
|
export const MAX_SECONDS = 300 // ~5 min recording cap
|
||||||
export const MAX_SAMPLES = MAX_SECONDS * RECORD_HZ // 4500
|
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. */
|
/** 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()
|
const s = store()
|
||||||
if (!s) return false
|
if (!s) return false
|
||||||
try {
|
try {
|
||||||
s.setItem(GHOST_KEY, encodeGhost(track))
|
s.setItem(ghostKey(course), encodeGhost(track))
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadGhost(): GhostTrack | null {
|
export function loadGhost(course?: string): GhostTrack | null {
|
||||||
const s = store()
|
const s = store()
|
||||||
if (!s) return null
|
if (!s) return null
|
||||||
const raw = s.getItem(GHOST_KEY)
|
const raw = s.getItem(ghostKey(course))
|
||||||
return raw ? decodeGhost(raw) : null
|
return raw ? decodeGhost(raw) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearGhost(): void {
|
export function clearGhost(course?: string): void {
|
||||||
store()?.removeItem(GHOST_KEY)
|
store()?.removeItem(ghostKey(course))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasGhost(): boolean {
|
export function hasGhost(course?: string): boolean {
|
||||||
return !!store()?.getItem(GHOST_KEY)
|
return !!store()?.getItem(ghostKey(course))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cheap read of just the saved run time (for the title), without decoding samples. */
|
/** 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()
|
const s = store()
|
||||||
if (!s) return null
|
if (!s) return null
|
||||||
const raw = s.getItem(GHOST_KEY)
|
const raw = s.getItem(ghostKey(course))
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
try {
|
try {
|
||||||
const p = JSON.parse(raw) as Partial<GhostPayload>
|
const p = JSON.parse(raw) as Partial<GhostPayload>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function installGhost(
|
|||||||
blob: Blob,
|
blob: Blob,
|
||||||
opts: GhostPlayerOptions = {},
|
opts: GhostPlayerOptions = {},
|
||||||
): GhostHandles {
|
): GhostHandles {
|
||||||
const recorder = new GhostRecorder(world, blob)
|
const recorder = new GhostRecorder(world, blob, opts.course)
|
||||||
world.addSystem(recorder)
|
world.addSystem(recorder)
|
||||||
|
|
||||||
const radius = opts.radius ?? (blob as { radius?: number }).radius ?? 0.5
|
const radius = opts.radius ?? (blob as { radius?: number }).radius ?? 0.5
|
||||||
|
|||||||
@ -24,6 +24,8 @@ const BOB_RATE = 4 // rad/s
|
|||||||
export interface GhostPlayerOptions {
|
export interface GhostPlayerOptions {
|
||||||
/** Visual radius; match the blob so the overlap reads true. Default 0.5. */
|
/** Visual radius; match the blob so the overlap reads true. Default 0.5. */
|
||||||
radius?: number
|
radius?: number
|
||||||
|
/** Course key — replay only THIS course's ghost. Undefined = legacy default. */
|
||||||
|
course?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GhostPlayer {
|
export class GhostPlayer {
|
||||||
@ -33,6 +35,7 @@ export class GhostPlayer {
|
|||||||
private t = 0
|
private t = 0
|
||||||
private bob = 0
|
private bob = 0
|
||||||
private readonly radius: number
|
private readonly radius: number
|
||||||
|
private readonly course?: string
|
||||||
private readonly out: GhostSample = { x: 0, y: 0, z: 0, scale: 1 }
|
private readonly out: GhostSample = { x: 0, y: 0, z: 0, scale: 1 }
|
||||||
private readonly mats: THREE.Material[] = []
|
private readonly mats: THREE.Material[] = []
|
||||||
private readonly geos: THREE.BufferGeometry[] = []
|
private readonly geos: THREE.BufferGeometry[] = []
|
||||||
@ -42,6 +45,7 @@ export class GhostPlayer {
|
|||||||
opts: GhostPlayerOptions = {},
|
opts: GhostPlayerOptions = {},
|
||||||
) {
|
) {
|
||||||
this.radius = opts.radius ?? 0.5
|
this.radius = opts.radius ?? 0.5
|
||||||
|
this.course = opts.course
|
||||||
world.events.on('race:started', () => this.spawn())
|
world.events.on('race:started', () => this.spawn())
|
||||||
world.events.on('race:finished', () => this.despawn())
|
world.events.on('race:finished', () => this.despawn())
|
||||||
world.onFrame((dt) => this.update(dt))
|
world.onFrame((dt) => this.update(dt))
|
||||||
@ -123,7 +127,7 @@ export class GhostPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private spawn(): void {
|
private spawn(): void {
|
||||||
this.track = loadGhost()
|
this.track = loadGhost(this.course)
|
||||||
if (!this.track || this.track.count < 2) {
|
if (!this.track || this.track.count < 2) {
|
||||||
this.track = null // no ghost yet (e.g. first run ever)
|
this.track = null // no ghost yet (e.g. first run ever)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -28,6 +28,9 @@ export class GhostRecorder implements System {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly world: World,
|
private readonly world: World,
|
||||||
private readonly blob: Blob,
|
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:started', () => this.begin())
|
||||||
world.events.on('race:finished', (p) => this.finish(p))
|
world.events.on('race:finished', (p) => this.finish(p))
|
||||||
@ -85,6 +88,6 @@ export class GhostRecorder implements System {
|
|||||||
count: this.count,
|
count: this.count,
|
||||||
data: quantize(this.buf, this.count),
|
data: quantize(this.buf, this.count),
|
||||||
}
|
}
|
||||||
saveGhost(track)
|
saveGhost(track, this.course)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
import type { World } from '../contracts'
|
import type { World } from '../contracts'
|
||||||
import { PALETTE } from '../contracts'
|
import { PALETTE } from '../contracts'
|
||||||
import { ghostTime, hasGhost } from '../ghost/format'
|
import { ghostTime, hasGhost } from '../ghost/format'
|
||||||
|
import { COURSE_META } from '../course/spec'
|
||||||
|
|
||||||
export interface TitleOptions {
|
export interface TitleOptions {
|
||||||
/** Resolves when Rapier/world finished booting. While pending: loading state. */
|
/** 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)}
|
55%{transform:translateY(0) scale(1.15,.85)}
|
||||||
75%{transform:translateY(-4px) scale(.96,1.05)}}
|
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-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)
|
document.head.appendChild(el)
|
||||||
}
|
}
|
||||||
@ -155,11 +167,34 @@ export function installTitle(world: World, opts: TitleOptions = {}): TitleHandle
|
|||||||
|
|
||||||
overlay.appendChild(buildControls())
|
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')
|
const best = document.createElement('div')
|
||||||
best.className = 'blobbo-best'
|
best.className = 'blobbo-best'
|
||||||
if (hasGhost()) {
|
if (hasGhost(here)) {
|
||||||
const t = ghostTime()
|
const t = ghostTime(here)
|
||||||
best.textContent = t != null ? `best ${t.toFixed(2)}s · 👻 ghost ready` : '👻 ghost ready'
|
best.textContent = t != null ? `best ${t.toFixed(2)}s · 👻 ghost ready` : '👻 ghost ready'
|
||||||
}
|
}
|
||||||
overlay.appendChild(best)
|
overlay.appendChild(best)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user