From ae8a93c4174d5efc16e1adc47baa8e2dd3a3ec2c Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 11:47:34 +1000 Subject: [PATCH] Lane H: ghost replay + title screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghost (src/ghost/): - format.ts: pure, headless-testable core — int16 (cm position / milli scale) quantization, base64 localStorage envelope (blobbo:ghost), linear sampling. - recorder.ts: 15Hz fixed-step recorder, ~5min cap, saves quantized track on a new best (time <= best, matching game.ts's pre-emit best update). - player.ts: translucent (~0.35) desaturated ghost + eyes, slight bob, replays in race time with interpolation; spawns on race:started, despawns on finish. - install.ts: installGhost(world, blob) wiring both to the frozen race events. Title (src/ui/title.ts): installTitle(world, {ready}) — CSS bubble-letter BLOBBO wordmark with paint-drip accents, pitch, controls card, PLAY. Loading blob-dot while a boot promise is pending. Emits ui:play exactly once inside the user gesture (audio-unlock friendly), then fades and never returns. Shows saved best time + "ghost ready". Demo (demos/lane-h.html + src/demo/lane-h.ts): scripted 20s circle run — title gates start, run 1 records, later runs replay the ghost on the same path. Keys: G wipe ghost, R restart, P snapshot. verify-drift.ts: numeric acceptance (esbuild+node) — max drift 0.0058m vs 0.5m blob radius. Emits ui:play only; no edits outside owned paths. Co-Authored-By: Claude Fable 5 --- demos/lane-h.html | 24 ++++ src/demo/lane-h.ts | 255 +++++++++++++++++++++++++++++++++++ src/ghost/format.ts | 184 +++++++++++++++++++++++++ src/ghost/index.ts | 17 +++ src/ghost/install.ts | 31 +++++ src/ghost/player.ts | 135 +++++++++++++++++++ src/ghost/recorder.ts | 90 +++++++++++++ src/ghost/verify-drift.ts | 104 +++++++++++++++ src/ui/title.ts | 273 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 1113 insertions(+) create mode 100644 demos/lane-h.html create mode 100644 src/demo/lane-h.ts create mode 100644 src/ghost/format.ts create mode 100644 src/ghost/index.ts create mode 100644 src/ghost/install.ts create mode 100644 src/ghost/player.ts create mode 100644 src/ghost/recorder.ts create mode 100644 src/ghost/verify-drift.ts create mode 100644 src/ui/title.ts diff --git a/demos/lane-h.html b/demos/lane-h.html new file mode 100644 index 0000000..d8ce8ab --- /dev/null +++ b/demos/lane-h.html @@ -0,0 +1,24 @@ + + +BLOBBO lane h demo — ghost + title + + +
+
+ Lane H — ghost + title
+ Title → PLAY starts run 1 (records). Later runs replay the ghost.
+ Gwipe saved ghost
+ Rrestart the run
+ Pprint state snapshot +
+ + + diff --git a/src/demo/lane-h.ts b/src/demo/lane-h.ts new file mode 100644 index 0000000..7a257b4 --- /dev/null +++ b/src/demo/lane-h.ts @@ -0,0 +1,255 @@ +/** + * Lane H demo — ghost replay + title screen. + * + * Self-contained (no game.ts): a scripted "run" drives a ball around a 20s + * circle. The title card gates the start (and emits ui:play). Run 1 records the + * path; every later run replays the saved ghost racing the live ball around the + * SAME deterministic circle, so the translucent ghost should sit right on top + * of the solid ball. The recorder persists the best to localStorage, so a page + * reload comes back with the ghost already armed on the title. + * + * Debug keys (browser): + * G — wipe the saved ghost from localStorage (blobbo:ghost) + * R — restart the run immediately + * P — print a one-line state snapshot to the console + * + * A live "drift" readout compares the replayed ghost against the true circle in + * real time; the rigorous numeric check is verify-drift.ts (bundle + node). + */ +import * as THREE from 'three' +import type { Blob, World } from '../contracts' +import { defaultModifiers } from '../contracts' +import { installTitle } from '../ui/title' +import { installGhost } from '../ghost/install' +import { clearGhost, hasGhost, loadGhost, sampleAt, type GhostSample, type GhostTrack } from '../ghost/format' + +// ---- the deterministic scripted path (a ball on a circle) ----------------- +const R = 8 // circle radius (m) +const CY = 1.0 // ride height (m) +const RUN_SECONDS = 20 +const OMEGA = (2 * Math.PI) / RUN_SECONDS +const BLOB_RADIUS = 0.5 + +function pathAt(t: number, out: GhostSample): GhostSample { + const th = OMEGA * t - Math.PI / 2 // start near (0,·,-R) + out.x = R * Math.cos(th) + out.y = CY + out.z = R * Math.sin(th) + out.scale = 1 + 0.15 * Math.sin(2 * OMEGA * t) // gentle pulse to exercise scale replay + return out +} + +export function buildLaneH(world: World): { + blob: Blob + beginRun: () => void + wipeGhost: () => void +} { + const { scene, physics, rapier } = world + + // ---------------------------------------------------------------- scenery + const floor = new THREE.Mesh( + new THREE.CircleGeometry(R + 4, 64), + new THREE.MeshStandardMaterial({ color: '#3a4a63', roughness: 0.95 }), + ) + floor.rotation.x = -Math.PI / 2 + floor.receiveShadow = true + scene.add(floor) + + // the racing line, drawn on the floor so the circle is obvious + const ring = new THREE.Mesh( + new THREE.RingGeometry(R - 0.12, R + 0.12, 96), + new THREE.MeshBasicMaterial({ color: '#ffd60a', side: THREE.DoubleSide }), + ) + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.02 + scene.add(ring) + + // start marker + const startMark = new THREE.Mesh( + new THREE.BoxGeometry(0.4, 0.05, 2.4), + new THREE.MeshBasicMaterial({ color: '#FF6EB4' }), + ) + startMark.position.set(0, 0.03, -R) + scene.add(startMark) + + // ---------------------------------------------------------------- the "blob" + // A Lane-H-owned stand-in shaped like the frozen Blob: the recorder reads the + // root group's position + scale. A kinematic body is created only to satisfy + // the Blob type (never stepped; the group is driven directly). + const group = new THREE.Group() + group.position.set(0, CY, -R) + scene.add(group) + + const mesh = new THREE.Mesh( + new THREE.SphereGeometry(BLOB_RADIUS, 32, 24), + new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.4, emissive: '#ffcaa8', emissiveIntensity: 0.15 }), + ) + mesh.castShadow = true + group.add(mesh) + + // little eyes so the live ball reads as Blobbo, for contrast with the ghost + const eyeMat = new THREE.MeshStandardMaterial({ color: '#1a1a22', roughness: 0.5 }) + const eyeGeo = new THREE.SphereGeometry(BLOB_RADIUS * 0.16, 12, 10) + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(eyeGeo, eyeMat) + eye.position.set(side * BLOB_RADIUS * 0.34, BLOB_RADIUS * 0.32, BLOB_RADIUS * 0.9) + group.add(eye) + } + + const body = physics.createRigidBody( + rapier.RigidBodyDesc.kinematicPositionBased().setTranslation(0, CY, -R), + ) + const blob: Blob = { group, mesh, body, modifiers: defaultModifiers() } + + // ---------------------------------------------------------------- run driver + let running = false + let runT = 0 + let runNo = 0 + let best: number | null = null + let ghostThisRun = false + let liveTrack: GhostTrack | null = null + let maxDrift = 0 + const scratchA: GhostSample = { x: 0, y: 0, z: 0, scale: 1 } + const scratchB: GhostSample = { x: 0, y: 0, z: 0, scale: 1 } + let nextTimer = 0 + + // Driver runs as a fixed-step System added BEFORE installGhost, so the + // recorder samples this frame's updated group position (insertion order). + world.addSystem({ + update(dt: number): void { + if (!running) return + runT += dt + const tt = Math.min(runT, RUN_SECONDS) + pathAt(tt, scratchA) + group.position.set(scratchA.x, scratchA.y, scratchA.z) + group.scale.setScalar(scratchA.scale) + + if (runT >= RUN_SECONDS) { + running = false + const time = RUN_SECONDS + const isBest = best == null || time < best + if (isBest) best = time + world.events.emit('race:finished', { time, best }) + toast(`🏁 run ${runNo} — ${time.toFixed(2)}s${ghostThisRun ? ` · ghost drift ${maxDrift.toFixed(3)}m` : ' (recording)'}`) + // auto-loop into the next run + window.clearTimeout(nextTimer) + nextTimer = window.setTimeout(beginRun, 2200) + } + }, + }) + + // ---------------------------------------------------------------- ghost wiring + installGhost(world, blob, { radius: BLOB_RADIUS }) + + // Live drift readout: replayed ghost vs the true circle at the same race time. + // Registered AFTER installGhost so the ghost has advanced this frame. + world.onFrame(() => { + if (!running || !ghostThisRun || !liveTrack) return + sampleAt(liveTrack, runT, scratchB) // what the ghost replays + pathAt(runT, scratchA) // ground truth + const d = Math.hypot(scratchA.x - scratchB.x, scratchA.y - scratchB.y, scratchA.z - scratchB.z) + if (d > maxDrift) maxDrift = d + }) + + function beginRun(): void { + window.clearTimeout(nextTimer) + runT = 0 + running = true + runNo++ + maxDrift = 0 + ghostThisRun = hasGhost() + liveTrack = ghostThisRun ? loadGhost() : null + // snap live ball to the start so recording starts clean + pathAt(0, scratchA) + group.position.set(scratchA.x, scratchA.y, scratchA.z) + group.scale.setScalar(scratchA.scale) + world.events.emit('race:started', {}) + toast(ghostThisRun ? `run ${runNo} — 👻 racing your ghost` : `run ${runNo} — recording…`) + } + + function wipeGhost(): void { + clearGhost() + toast('👻 ghost wiped') + updateHud() + } + + // ---------------------------------------------------------------- HUD + toast + const hud = document.createElement('div') + hud.style.cssText = + 'position:fixed;top:12px;left:14px;z-index:20;pointer-events:none;' + + 'font:600 14px ui-monospace,Menlo,monospace;color:#eaf2ff;background:rgba(16,20,34,.7);' + + 'padding:10px 13px;border-radius:10px;line-height:1.55' + document.body.appendChild(hud) + + function updateHud(): void { + hud.innerHTML = + `BLOBBO — Lane H ghost demo
` + + `run ${runNo}${running ? ` ⏱ ${Math.min(runT, RUN_SECONDS).toFixed(2)}s` : ''}
` + + `ghost: ${hasGhost() ? `saved ✓${ghostThisRun ? ' · replaying' : ''}` : 'none'}
` + + `${ghostThisRun ? `live drift: ${maxDrift.toFixed(3)}m (< ${BLOB_RADIUS}m radius)
` : ''}` + + `keys: G wipe ghost · R restart · P snapshot` + } + world.onFrame(updateHud) + + let toastEl: HTMLDivElement | null = null + let toastTimer = 0 + function toast(msg: string): void { + if (!toastEl) { + toastEl = document.createElement('div') + toastEl.style.cssText = + 'position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:20;pointer-events:none;' + + 'font:700 15px ui-monospace,Menlo,monospace;color:#16264a;background:#ffd60a;' + + 'padding:9px 16px;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.4)' + document.body.appendChild(toastEl) + } + toastEl.textContent = msg + toastEl.style.opacity = '1' + window.clearTimeout(toastTimer) + toastTimer = window.setTimeout(() => { + if (toastEl) toastEl.style.opacity = '0' + }, 2600) + } + + function snapshot(): Record { + return { + run: runNo, + running, + t: +runT.toFixed(2), + hasGhost: hasGhost(), + replaying: ghostThisRun, + maxDriftM: +maxDrift.toFixed(4), + pos: [+group.position.x.toFixed(2), +group.position.y.toFixed(2), +group.position.z.toFixed(2)], + } + } + + addEventListener('keydown', (e) => { + if (e.code === 'KeyG') wipeGhost() + else if (e.code === 'KeyR') beginRun() + else if (e.code === 'KeyP') console.log('[lane-h]', JSON.stringify(snapshot())) + }) + + return { blob, beginRun, wipeGhost } +} + +// --------------------------------------------------------------- bootstrap +if (typeof document !== 'undefined') { + const { createWorld } = await import('../world') + const world = await createWorld(document.getElementById('app')!) + world.camera.position.set(0, 16, 20) + world.onFrame(() => world.camera.lookAt(0, 0.5, 0)) + + const h = buildLaneH(world) + + // Title in front. Hand it a short boot promise to show the loading blob dot + // before PLAY (the real world is already booted; this just exercises the + // loading state). ui:play kicks off the first run exactly once. + const boot = new Promise((res) => setTimeout(res, 700)) + const title = installTitle(world, { ready: boot }) + title.done.then(() => { + console.log('[lane-h] ui:play received — starting run 1') + h.beginRun() + }) + + world.start() + console.log('[lane-h] ready — click PLAY, then keys: G wipe ghost · R restart · P snapshot') +} diff --git a/src/ghost/format.ts b/src/ghost/format.ts new file mode 100644 index 0000000..e324d1a --- /dev/null +++ b/src/ghost/format.ts @@ -0,0 +1,184 @@ +/** + * Ghost track storage + sampling — the pure, headless-testable core shared by + * the recorder (writes), the player (reads) and the title (reads best time). + * + * No three / no DOM here beyond guarded btoa/atob/localStorage, so the drift + * verifier (verify-drift.ts) can bundle + run this exact code under `node`. + * + * Wire storage is int16 centimetres (position) + int16 milli-units (uniform + * scale), base64 of the raw Int16Array inside a tiny JSON envelope in + * localStorage key `blobbo:ghost`. A full 5-minute run is ~48KB base64, well + * under the ~200KB budget. + */ + +export const GHOST_KEY = 'blobbo:ghost' +export const RECORD_HZ = 15 +export const MAX_SECONDS = 300 // ~5 min recording cap +export const MAX_SAMPLES = MAX_SECONDS * RECORD_HZ // 4500 +export const FLOATS_PER_SAMPLE = 4 // x, y, z, uniformScale +export const POS_SCALE = 100 // metres -> int16 centimetres (±327.67 m range) +export const SIZE_SCALE = 1000 // uniform scale -> int16 milli-units (±32.767) + +export interface GhostTrack { + time: number // recorded run time (seconds) + hz: number // sample rate + count: number // number of samples stored + data: Int16Array // interleaved [x_cm, y_cm, z_cm, scale_milli] * count +} + +export interface GhostSample { + x: number + y: number + z: number + scale: number +} + +// clamp to int16 so one wild sample can never corrupt the buffer / overflow +const i16 = (n: number): number => Math.max(-32768, Math.min(32767, Math.round(n))) + +/** Quantise the first `count` samples of a flat Float32 buffer into int16s. */ +export function quantize(src: Float32Array, count: number): Int16Array { + const out = new Int16Array(count * FLOATS_PER_SAMPLE) + for (let i = 0; i < count; i++) { + const o = i * FLOATS_PER_SAMPLE + out[o] = i16(src[o] * POS_SCALE) + out[o + 1] = i16(src[o + 1] * POS_SCALE) + out[o + 2] = i16(src[o + 2] * POS_SCALE) + out[o + 3] = i16(src[o + 3] * SIZE_SCALE) + } + return out +} + +/** + * Linear-interpolate the track at race-time `t` (seconds), writing into `out` + * (reused to avoid per-frame allocation). Clamps at both ends: before the run + * it sits at the start sample, after the run it freezes on the finish sample. + */ +export function sampleAt(track: GhostTrack, t: number, out: GhostSample): GhostSample { + const { data, count, hz } = track + if (count === 0) { + out.x = out.y = out.z = 0 + out.scale = 1 + return out + } + const f = Math.max(0, Math.min(count - 1, t * hz)) + const i0 = Math.floor(f) + const i1 = Math.min(count - 1, i0 + 1) + const a = f - i0 + const o0 = i0 * FLOATS_PER_SAMPLE + const o1 = i1 * FLOATS_PER_SAMPLE + out.x = (data[o0] + (data[o1] - data[o0]) * a) / POS_SCALE + out.y = (data[o0 + 1] + (data[o1 + 1] - data[o0 + 1]) * a) / POS_SCALE + out.z = (data[o0 + 2] + (data[o1 + 2] - data[o0 + 2]) * a) / POS_SCALE + out.scale = (data[o0 + 3] + (data[o1 + 3] - data[o0 + 3]) * a) / SIZE_SCALE + return out +} + +// ---- base64 <-> bytes (chunked so btoa never blows the arg limit) ---------- +const B64_CHUNK = 0x8000 + +function bytesToB64(bytes: Uint8Array): string { + let bin = '' + for (let i = 0; i < bytes.length; i += B64_CHUNK) { + bin += String.fromCharCode(...bytes.subarray(i, i + B64_CHUNK)) + } + return btoa(bin) +} + +function b64ToBytes(b64: string): Uint8Array { + const bin = atob(b64) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + return bytes +} + +// ---- envelope ------------------------------------------------------------- +interface GhostPayload { + v: number // schema version + t: number // run time (s) + hz: number // sample rate + n: number // sample count + b: string // base64 of the interleaved int16 buffer +} + +export function encodeGhost(track: GhostTrack): string { + const view = new Uint8Array(track.data.buffer, track.data.byteOffset, track.data.byteLength) + const payload: GhostPayload = { + v: 1, + t: track.time, + hz: track.hz, + n: track.count, + b: bytesToB64(view), + } + return JSON.stringify(payload) +} + +export function decodeGhost(json: string): GhostTrack | null { + try { + const p = JSON.parse(json) as Partial + if (!p || p.v !== 1 || typeof p.b !== 'string') return null + const bytes = b64ToBytes(p.b) + // ensure an even byte length for Int16Array + const len16 = Math.floor(bytes.byteLength / 2) + const data = new Int16Array(bytes.buffer, bytes.byteOffset, len16) + const count = typeof p.n === 'number' ? p.n : Math.floor(len16 / FLOATS_PER_SAMPLE) + return { + time: typeof p.t === 'number' ? p.t : count / RECORD_HZ, + hz: typeof p.hz === 'number' && p.hz > 0 ? p.hz : RECORD_HZ, + count, + data, + } + } catch { + return null + } +} + +// ---- localStorage (guarded so this module stays runnable headless) -------- +function store(): Storage | null { + try { + return typeof localStorage !== 'undefined' ? localStorage : null + } catch { + return null + } +} + +/** Persist a track. Returns false if storage is unavailable or the write threw. */ +export function saveGhost(track: GhostTrack): boolean { + const s = store() + if (!s) return false + try { + s.setItem(GHOST_KEY, encodeGhost(track)) + return true + } catch { + return false + } +} + +export function loadGhost(): GhostTrack | null { + const s = store() + if (!s) return null + const raw = s.getItem(GHOST_KEY) + return raw ? decodeGhost(raw) : null +} + +export function clearGhost(): void { + store()?.removeItem(GHOST_KEY) +} + +export function hasGhost(): boolean { + return !!store()?.getItem(GHOST_KEY) +} + +/** Cheap read of just the saved run time (for the title), without decoding samples. */ +export function ghostTime(): number | null { + const s = store() + if (!s) return null + const raw = s.getItem(GHOST_KEY) + if (!raw) return null + try { + const p = JSON.parse(raw) as Partial + return typeof p.t === 'number' ? p.t : null + } catch { + return null + } +} diff --git a/src/ghost/index.ts b/src/ghost/index.ts new file mode 100644 index 0000000..c608fed --- /dev/null +++ b/src/ghost/index.ts @@ -0,0 +1,17 @@ +/** Lane H — ghost replay. Public surface for integration + the demo. */ +export { installGhost, type GhostHandles } from './install' +export { GhostRecorder } from './recorder' +export { GhostPlayer, type GhostPlayerOptions } from './player' +export { + GHOST_KEY, + RECORD_HZ, + clearGhost, + ghostTime, + hasGhost, + loadGhost, + saveGhost, + sampleAt, + quantize, + type GhostSample, + type GhostTrack, +} from './format' diff --git a/src/ghost/install.ts b/src/ghost/install.ts new file mode 100644 index 0000000..4641bce --- /dev/null +++ b/src/ghost/install.ts @@ -0,0 +1,31 @@ +/** + * installGhost — wires a recorder + player to the race events. Integration + * calls this once, after the blob exists: + * + * installGhost(world, blob) + * + * The recorder captures every run and persists the best; the player replays the + * saved best on the next race. Both self-subscribe to race:started/finished. + */ +import type { Blob, World } from '../contracts' +import { GhostRecorder } from './recorder' +import { GhostPlayer, type GhostPlayerOptions } from './player' + +export interface GhostHandles { + recorder: GhostRecorder + player: GhostPlayer +} + +export function installGhost( + world: World, + blob: Blob, + opts: GhostPlayerOptions = {}, +): GhostHandles { + const recorder = new GhostRecorder(world, blob) + world.addSystem(recorder) + + const radius = opts.radius ?? (blob as { radius?: number }).radius ?? 0.5 + const player = new GhostPlayer(world, { ...opts, radius }) + + return { recorder, player } +} diff --git a/src/ghost/player.ts b/src/ghost/player.ts new file mode 100644 index 0000000..732c309 --- /dev/null +++ b/src/ghost/player.ts @@ -0,0 +1,135 @@ +/** + * GhostPlayer — if a saved ghost exists, spawns a translucent, physics-free + * "past self" on `race:started` and replays the recorded track in race time + * with linear interpolation, then despawns on `race:finished` (or the next + * respawn, which re-fires race:started and resets it). + * + * Interpolation runs on the RENDER frame (onFrame) for smoothness; the track + * itself was sampled deterministically at 15Hz. The ghost is deliberately + * NOT-you: ghostly desaturated white, ~0.35 opacity, faint emissive, a slight + * vertical bob. + * + * Consumes: race:started, race:finished. Emits: nothing. + */ +import * as THREE from 'three' +import type { World } from '../contracts' +import { loadGhost, sampleAt, type GhostSample, type GhostTrack } from './format' + +const GHOST_OPACITY = 0.35 +const BOB_AMPLITUDE = 0.06 // metres +const BOB_RATE = 4 // rad/s + +export interface GhostPlayerOptions { + /** Visual radius; match the blob so the overlap reads true. Default 0.5. */ + radius?: number +} + +export class GhostPlayer { + private group: THREE.Group | null = null + private track: GhostTrack | null = null + private playing = false + private t = 0 + private bob = 0 + private readonly radius: number + private readonly out: GhostSample = { x: 0, y: 0, z: 0, scale: 1 } + private readonly mats: THREE.Material[] = [] + private readonly geos: THREE.BufferGeometry[] = [] + + constructor( + private readonly world: World, + opts: GhostPlayerOptions = {}, + ) { + this.radius = opts.radius ?? 0.5 + world.events.on('race:started', () => this.spawn()) + world.events.on('race:finished', () => this.despawn()) + world.onFrame((dt) => this.update(dt)) + } + + private build(): THREE.Group { + const r = this.radius + const g = new THREE.Group() + + const bodyGeo = new THREE.SphereGeometry(r, 32, 24) + const bodyMat = new THREE.MeshStandardMaterial({ + color: '#eaf6ff', + emissive: '#bfe6ff', + emissiveIntensity: 0.5, + roughness: 0.35, + metalness: 0.0, + transparent: true, + opacity: GHOST_OPACITY, + depthWrite: false, // translucent: don't occlude the real blob behind it + }) + const body = new THREE.Mesh(bodyGeo, bodyMat) + g.add(body) + + // Simple eyes so it reads as a blob (not a soap bubble), slightly less sheer. + const eyeGeo = new THREE.SphereGeometry(r * 0.16, 12, 10) + const eyeMat = new THREE.MeshStandardMaterial({ + color: '#2a3a48', + transparent: true, + opacity: Math.min(1, GHOST_OPACITY + 0.25), + depthWrite: false, + }) + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(eyeGeo, eyeMat) + eye.position.set(side * r * 0.34, r * 0.32, r * 0.9) + g.add(eye) + } + + g.renderOrder = 2 // composite over the opaque scene + this.mats.push(bodyMat, eyeMat) + this.geos.push(bodyGeo, eyeGeo) + return g + } + + private spawn(): void { + this.track = loadGhost() + if (!this.track || this.track.count < 2) { + this.track = null // no ghost yet (e.g. first run ever) + return + } + if (!this.group) { + this.group = this.build() + this.world.scene.add(this.group) + } + this.group.visible = true + this.t = 0 + this.bob = 0 + this.playing = true + this.place() // snap to the start pose immediately, no one-frame pop + } + + private despawn(): void { + this.playing = false + if (this.group) this.group.visible = false + } + + private place(): void { + if (!this.group || !this.track) return + sampleAt(this.track, this.t, this.out) + const bob = Math.sin(this.bob * BOB_RATE) * BOB_AMPLITUDE + this.group.position.set(this.out.x, this.out.y + bob, this.out.z) + this.group.scale.setScalar(this.out.scale || 1) + } + + private update(dt: number): void { + if (!this.playing) return + this.t += dt + this.bob += dt + this.place() + } + + /** Fully tear down (scene node + GPU resources). */ + dispose(): void { + if (this.group) { + this.world.scene.remove(this.group) + this.group = null + } + for (const m of this.mats) m.dispose() + for (const geo of this.geos) geo.dispose() + this.mats.length = 0 + this.geos.length = 0 + this.playing = false + } +} diff --git a/src/ghost/recorder.ts b/src/ghost/recorder.ts new file mode 100644 index 0000000..863fa69 --- /dev/null +++ b/src/ghost/recorder.ts @@ -0,0 +1,90 @@ +/** + * GhostRecorder — records the blob root's position + uniform scale at 15Hz + * between `race:started` and `race:finished`, and on a new best writes a + * quantised, base64 track to localStorage `blobbo:ghost`. + * + * It is a fixed-step System (added via installGhost) so the 15Hz grid is + * deterministic (every 4th 60Hz step) rather than tied to render cadence. + * + * Consumes: race:started, race:finished {time,best}. Emits: nothing. + */ +import type { Blob, System, World } from '../contracts' +import { + FLOATS_PER_SAMPLE, + MAX_SAMPLES, + RECORD_HZ, + quantize, + saveGhost, + type GhostTrack, +} from './format' + +export class GhostRecorder implements System { + private readonly buf = new Float32Array(MAX_SAMPLES * FLOATS_PER_SAMPLE) + private count = 0 + private acc = 0 + private recording = false + private readonly period = 1 / RECORD_HZ + + constructor( + private readonly world: World, + private readonly blob: Blob, + ) { + world.events.on('race:started', () => this.begin()) + world.events.on('race:finished', (p) => this.finish(p)) + } + + private begin(): void { + this.count = 0 + this.acc = 0 + this.recording = true + this.capture() // anchor sample at t = 0 + } + + private capture(): void { + if (this.count >= MAX_SAMPLES) { + this.recording = false // 5-minute cap reached; keep what we have + return + } + // The blob root (group) carries world position (synced to the rigid body + // each fixed step) and the `size` uniform scale — exactly what we replay. + const g = this.blob.group + const o = this.count * FLOATS_PER_SAMPLE + this.buf[o] = g.position.x + this.buf[o + 1] = g.position.y + this.buf[o + 2] = g.position.z + this.buf[o + 3] = g.scale.x + this.count++ + } + + update(dt: number): void { + if (!this.recording) return + this.acc += dt + while (this.acc >= this.period) { + this.acc -= this.period + this.capture() + if (!this.recording) break + } + } + + private finish(p?: { time?: number; best?: number }): void { + if (!this.recording) return + this.recording = false + this.capture() // pin the exact finish pose + + const time = p?.time ?? this.count / RECORD_HZ + const best = p?.best + // game.ts sets `best` to the new time BEFORE emitting on a record, so a new + // best arrives as time === best. `<=` catches that; a slower run has + // best < time and is skipped. + const isBest = best == null || time <= best + 1e-6 + if (!isBest || this.count < 2) return + + const track: GhostTrack = { + time, + hz: RECORD_HZ, + count: this.count, + data: quantize(this.buf, this.count), + } + saveGhost(track) + } +} diff --git a/src/ghost/verify-drift.ts b/src/ghost/verify-drift.ts new file mode 100644 index 0000000..74b5618 --- /dev/null +++ b/src/ghost/verify-drift.ts @@ -0,0 +1,104 @@ +/** + * Numeric acceptance for Lane H: ghost replay drift vs the recorded path must be + * < one blob radius at all sample points. This mirrors the demo's deterministic + * circle, records it at 15Hz through the REAL quantize(), then replays it + * through the REAL sampleAt() and reports the worst-case position error. + * + * Not imported by any entry (headless-only). Run it with: + * node_modules/.bin/esbuild src/ghost/verify-drift.ts --bundle --platform=node \ + * --format=esm --outfile=/tmp/verify.mjs && node /tmp/verify.mjs + */ +import { + FLOATS_PER_SAMPLE, + RECORD_HZ, + quantize, + sampleAt, + type GhostSample, + type GhostTrack, +} from './format' + +// --- the exact same path the demo drives (ball on a circle) --- +const R = 8 +const CY = 1.0 +const RUN_SECONDS = 20 +const OMEGA = (2 * Math.PI) / RUN_SECONDS +const BLOB_RADIUS = 0.5 + +function pathAt(t: number, out: GhostSample): GhostSample { + const th = OMEGA * t - Math.PI / 2 + out.x = R * Math.cos(th) + out.y = CY + out.z = R * Math.sin(th) + out.scale = 1 + 0.15 * Math.sin(2 * OMEGA * t) + return out +} + +// --- record at 15Hz exactly as GhostRecorder does (grid times i/hz) --- +const count = Math.floor(RUN_SECONDS * RECORD_HZ) + 1 +const buf = new Float32Array(count * FLOATS_PER_SAMPLE) +const s: GhostSample = { x: 0, y: 0, z: 0, scale: 1 } +for (let i = 0; i < count; i++) { + pathAt(i / RECORD_HZ, s) + const o = i * FLOATS_PER_SAMPLE + buf[o] = s.x + buf[o + 1] = s.y + buf[o + 2] = s.z + buf[o + 3] = s.scale +} + +const track: GhostTrack = { time: RUN_SECONDS, hz: RECORD_HZ, count, data: quantize(buf, count) } + +// --- replay and measure drift --- +const truth: GhostSample = { x: 0, y: 0, z: 0, scale: 1 } +const ghost: GhostSample = { x: 0, y: 0, z: 0, scale: 1 } + +function driftAt(t: number): number { + pathAt(t, truth) + sampleAt(track, t, ghost) + return Math.hypot(truth.x - ghost.x, truth.y - ghost.y, truth.z - ghost.z) +} + +// (a) exactly at every recorded sample point (the acceptance wording) +let maxAtSamples = 0 +let worstSampleT = 0 +for (let i = 0; i < count; i++) { + const t = i / RECORD_HZ + const d = driftAt(t) + if (d > maxAtSamples) { + maxAtSamples = d + worstSampleT = t + } +} + +// (b) densely between samples too (interpolation error), 1ms resolution +let maxDense = 0 +let worstDenseT = 0 +for (let t = 0; t <= RUN_SECONDS; t += 0.001) { + const d = driftAt(t) + if (d > maxDense) { + maxDense = d + worstDenseT = t + } +} + +// scale error, for completeness +let maxScaleErr = 0 +for (let t = 0; t <= RUN_SECONDS; t += 0.001) { + pathAt(t, truth) + sampleAt(track, t, ghost) + maxScaleErr = Math.max(maxScaleErr, Math.abs(truth.scale - ghost.scale)) +} + +const rawBytes = track.data.byteLength +const b64Bytes = Math.ceil(rawBytes / 3) * 4 + +console.log('--- Lane H ghost drift verification ---') +console.log(`samples: ${count} @ ${RECORD_HZ}Hz storage: ${rawBytes} B int16 → ~${b64Bytes} B base64`) +console.log(`blob radius (budget): ${BLOB_RADIUS} m`) +console.log(`max drift at sample points : ${maxAtSamples.toFixed(4)} m (t=${worstSampleT.toFixed(3)}s)`) +console.log(`max drift densely (1ms) : ${maxDense.toFixed(4)} m (t=${worstDenseT.toFixed(3)}s)`) +console.log(`max scale error : ${maxScaleErr.toFixed(5)}`) + +const pass = maxDense < BLOB_RADIUS && maxAtSamples < BLOB_RADIUS +console.log(pass ? `PASS — drift < ${BLOB_RADIUS} m everywhere` : 'FAIL — drift exceeds one blob radius') +if (!pass) throw new Error('ghost drift exceeds one blob radius') diff --git a/src/ui/title.ts b/src/ui/title.ts new file mode 100644 index 0000000..12b1172 --- /dev/null +++ b/src/ui/title.ts @@ -0,0 +1,273 @@ +/** + * installTitle — the five-second onboarding card + the audio gate. + * + * Full-screen overlay on boot: bold CSS bubble-letter BLOBBO wordmark with + * rainbow paint-drip accents, a one-line pitch, a three-row controls card, and + * a fat PLAY button. If the world is still booting (a `ready` promise was + * handed in), PLAY is replaced by a wobbling "loading…" blob dot until it + * resolves. + * + * On the first click / keypress / gamepad button it emits **`ui:play`** exactly + * once (synchronously, inside the user gesture, so a listening AudioContext can + * unlock), fades the overlay out and never shows it again. + * + * No external fonts or assets — everything is inline CSS. Readable on any + * background thanks to the dimming backdrop. + */ +import type { World } from '../contracts' +import { PALETTE } from '../contracts' +import { ghostTime, hasGhost } from '../ghost/format' + +export interface TitleOptions { + /** Resolves when Rapier/world finished booting. While pending: loading state. */ + ready?: Promise +} + +export interface TitleHandle { + /** Resolves once the player has started (ui:play fired). */ + done: Promise + /** Force-remove the overlay (teardown / tests). Does not emit ui:play. */ + remove(): void +} + +const STYLE_ID = 'blobbo-title-style' +const DRIP_COLORS = [PALETTE.red, PALETTE.yellow, PALETTE.green, PALETTE.blue, PALETTE.pink] + +function injectStyle(): void { + if (document.getElementById(STYLE_ID)) return + const el = document.createElement('style') + el.id = STYLE_ID + el.textContent = ` +#blobbo-title{position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column; + align-items:center;justify-content:center;gap:26px;padding:24px;box-sizing:border-box; + background:radial-gradient(120% 120% at 50% 30%,rgba(24,30,54,.72),rgba(10,12,24,.92)); + backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px); + font-family:'Comic Sans MS','Chalkboard SE','Baloo 2',system-ui,sans-serif; + color:#fff;text-align:center;opacity:1;transition:opacity .4s ease} +#blobbo-title.hide{opacity:0;pointer-events:none} +.blobbo-word{display:flex;gap:.01em;line-height:1; + font-weight:900;font-size:clamp(58px,15vw,168px);filter:drop-shadow(0 8px 14px rgba(0,0,0,.45))} +.blobbo-word span{position:relative;color:#fff; + -webkit-text-stroke:.085em #16264a;paint-order:stroke fill; + text-shadow:0 .05em 0 #16264a,0 .11em .02em rgba(0,0,0,.4); + transform:rotate(var(--rot,0deg)); + animation:blobbo-pop .5s cubic-bezier(.2,1.5,.4,1) both;animation-delay:var(--d,0s)} +.blobbo-drip{position:absolute;left:50%;bottom:.04em;width:.15em;height:.2em; + transform:translateX(-50%);background:var(--c,#fff);border-radius:0 0 55% 55%/0 0 70% 70%; + box-shadow:0 .05em 0 var(--c,#fff);z-index:-1; + animation:blobbo-drip 2.8s ease-in-out infinite;animation-delay:var(--dd,0s)} +@keyframes blobbo-pop{from{transform:translateY(.25em) rotate(var(--rot,0deg)) scale(.6);opacity:0} + to{transform:translateY(0) rotate(var(--rot,0deg)) scale(1);opacity:1}} +@keyframes blobbo-drip{0%,100%{transform:translateX(-50%) scaleY(1)}55%{transform:translateX(-50%) scaleY(1.7)}} +.blobbo-pitch{font-size:clamp(17px,3.2vw,26px);font-weight:700;letter-spacing:.02em; + color:#eaf2ff;text-shadow:0 2px 6px rgba(0,0,0,.6);margin-top:-6px} +.blobbo-controls{display:flex;flex-wrap:wrap;gap:10px 18px;justify-content:center; + background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.16);border-radius:14px; + padding:12px 18px;font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:600} +.blobbo-controls .row{display:flex;align-items:center;gap:8px;white-space:nowrap} +.blobbo-controls .key{display:inline-block;min-width:20px;padding:2px 8px;border-radius:6px; + background:#33436b;color:#fff;box-shadow:0 2px 0 rgba(0,0,0,.4);text-align:center} +.blobbo-best{font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:700; + color:#ffd60a;min-height:1.2em} +.blobbo-play{cursor:pointer;border:none;font-family:inherit;font-weight:900; + font-size:clamp(24px,5vw,40px);color:#16264a;letter-spacing:.04em; + padding:.35em 1.3em;border-radius:999px; + background:linear-gradient(180deg,#fff,#ffe14d); + box-shadow:0 8px 0 #b98a00,0 12px 24px rgba(0,0,0,.4); + transform:translateY(0);transition:transform .08s ease,box-shadow .08s ease; + animation:blobbo-breathe 2.2s ease-in-out infinite} +.blobbo-play:hover{transform:translateY(-2px)} +.blobbo-play:active{transform:translateY(6px);box-shadow:0 2px 0 #b98a00,0 4px 10px rgba(0,0,0,.4)} +@keyframes blobbo-breathe{0%,100%{scale:1}50%{scale:1.05}} +.blobbo-loading{display:flex;flex-direction:column;align-items:center;gap:12px; + font-family:ui-monospace,Menlo,monospace;font-size:16px;font-weight:700;color:#eaf2ff} +.blobbo-dot{width:34px;height:34px;border-radius:50%; + background:radial-gradient(circle at 35% 30%,#fff,#bfe6ff 60%,#7ab6e6); + animation:blobbo-wobble 1s ease-in-out infinite} +@keyframes blobbo-wobble{0%,100%{transform:translateY(0) scale(1,1)} + 25%{transform:translateY(-10px) scale(.92,1.1)} + 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)} +` + document.head.appendChild(el) +} + +function buildWordmark(): HTMLElement { + const word = document.createElement('div') + word.className = 'blobbo-word' + word.setAttribute('aria-label', 'BLOBBO') + const letters = 'BLOBBO'.split('') + // deterministic-ish tilts + a few drips so it reads hand-made, not random. + const tilts = [-5, 4, -3, 6, -4, 3] + const dripAt = new Set([0, 2, 4]) // under B, O, B + letters.forEach((ch, i) => { + const span = document.createElement('span') + span.textContent = ch + span.style.setProperty('--rot', `${tilts[i]}deg`) + span.style.setProperty('--d', `${i * 0.06}s`) + if (dripAt.has(i)) { + const drip = document.createElement('span') + drip.className = 'blobbo-drip' + drip.style.setProperty('--c', DRIP_COLORS[i % DRIP_COLORS.length]) + drip.style.setProperty('--dd', `${i * 0.35}s`) + span.appendChild(drip) + } + word.appendChild(span) + }) + return word +} + +function buildControls(): HTMLElement { + const box = document.createElement('div') + box.className = 'blobbo-controls' + const rows: Array<[string, string]> = [ + ['WASD / stick', 'move'], + ['Space / A', 'jump'], + ['R / Start', 'restart'], + ] + for (const [keys, action] of rows) { + const row = document.createElement('div') + row.className = 'row' + const k = document.createElement('span') + k.className = 'key' + k.textContent = keys + const a = document.createElement('span') + a.textContent = action + row.append(k, a) + box.appendChild(row) + } + return box +} + +export function installTitle(world: World, opts: TitleOptions = {}): TitleHandle { + injectStyle() + + const overlay = document.createElement('div') + overlay.id = 'blobbo-title' + + overlay.appendChild(buildWordmark()) + + const pitch = document.createElement('div') + pitch.className = 'blobbo-pitch' + pitch.textContent = 'Race. Get painted. Paint is power.' + overlay.appendChild(pitch) + + overlay.appendChild(buildControls()) + + // saved best + ghost status + const best = document.createElement('div') + best.className = 'blobbo-best' + if (hasGhost()) { + const t = ghostTime() + best.textContent = t != null ? `best ${t.toFixed(2)}s · 👻 ghost ready` : '👻 ghost ready' + } + overlay.appendChild(best) + + // action slot: either the loading blob or the PLAY button + const slot = document.createElement('div') + overlay.appendChild(slot) + + const hint = document.createElement('div') + hint.className = 'blobbo-hint' + overlay.appendChild(hint) + + document.body.appendChild(overlay) + + // ---- lifecycle ----------------------------------------------------------- + let armed = false // becomes true once boot completes → input can start play + let started = false + let resolveDone: () => void = () => {} + const done = new Promise((res) => (resolveDone = res)) + + const showLoading = (): void => { + slot.innerHTML = '' + const box = document.createElement('div') + box.className = 'blobbo-loading' + const dot = document.createElement('div') + dot.className = 'blobbo-dot' + const label = document.createElement('div') + label.textContent = 'loading…' + box.append(dot, label) + slot.appendChild(box) + } + + const playBtn = document.createElement('button') + playBtn.className = 'blobbo-play' + playBtn.type = 'button' + playBtn.textContent = 'PLAY' + + const showPlay = (): void => { + slot.innerHTML = '' + slot.appendChild(playBtn) + hint.textContent = 'click PLAY · or press any key · or a gamepad button' + } + + // ---- the single start path ---------------------------------------------- + const start = (): void => { + if (!armed || started) return + started = true + cleanup() + // Emit synchronously within the user gesture so audio can unlock here. + world.events.emit('ui:play') + overlay.classList.add('hide') + resolveDone() + // remove from DOM after the fade so it can never reappear or catch input + window.setTimeout(() => overlay.remove(), 450) + } + + // input wiring (removed on start so ui:play can only fire once) + const onKey = (e: KeyboardEvent): void => { + if (e.repeat) return + start() + } + const onClick = (): void => start() + let padRaf = 0 + const pollPad = (): void => { + if (started) return + const pads = typeof navigator !== 'undefined' && navigator.getGamepads ? navigator.getGamepads() : null + if (pads) { + for (const p of pads) { + if (!p || !p.connected) continue + if (p.buttons.some((b) => b.pressed)) { + start() + return + } + break + } + } + padRaf = requestAnimationFrame(pollPad) + } + + const cleanup = (): void => { + window.removeEventListener('keydown', onKey) + playBtn.removeEventListener('click', onClick) + if (padRaf) cancelAnimationFrame(padRaf) + } + + playBtn.addEventListener('click', onClick) + + const arm = (): void => { + if (armed) return + armed = true + showPlay() + window.addEventListener('keydown', onKey) + padRaf = requestAnimationFrame(pollPad) + } + + if (opts.ready) { + showLoading() + hint.textContent = 'waking up the physics…' + opts.ready.then(arm, arm) // arm even if boot rejects; the game decides what to do + } else { + arm() + } + + return { + done, + remove(): void { + cleanup() + overlay.remove() + }, + } +}