Lane H: ghost replay + title screen
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 <noreply@anthropic.com>
This commit is contained in:
parent
fedb43817c
commit
ae8a93c417
24
demos/lane-h.html
Normal file
24
demos/lane-h.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8" /><title>BLOBBO lane h demo — ghost + title</title>
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;height:100%;overflow:hidden;background:#0a0c18;font-family:system-ui,sans-serif}
|
||||||
|
#app{width:100%;height:100%}
|
||||||
|
#legend{position:fixed;bottom:12px;right:12px;padding:9px 12px;border-radius:8px;
|
||||||
|
background:rgba(20,24,40,.72);color:#eaf2ff;font-size:12px;line-height:1.55;pointer-events:none;z-index:5}
|
||||||
|
#legend b{color:#ffd60a}
|
||||||
|
#legend .k{display:inline-block;min-width:14px;padding:1px 5px;margin-right:6px;border-radius:4px;
|
||||||
|
background:#354569;color:#fff;font-weight:600;text-align:center}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<div id="legend">
|
||||||
|
<b>Lane H — ghost + title</b><br>
|
||||||
|
Title → PLAY starts run 1 (records). Later runs replay the ghost.<br>
|
||||||
|
<span class="k">G</span>wipe saved ghost<br>
|
||||||
|
<span class="k">R</span>restart the run<br>
|
||||||
|
<span class="k">P</span>print state snapshot
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/demo/lane-h.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
255
src/demo/lane-h.ts
Normal file
255
src/demo/lane-h.ts
Normal file
@ -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 =
|
||||||
|
`<b style="color:#ffd60a">BLOBBO — Lane H ghost demo</b><br>` +
|
||||||
|
`run ${runNo}${running ? ` ⏱ ${Math.min(runT, RUN_SECONDS).toFixed(2)}s` : ''}<br>` +
|
||||||
|
`ghost: ${hasGhost() ? `saved ✓${ghostThisRun ? ' · replaying' : ''}` : 'none'}<br>` +
|
||||||
|
`${ghostThisRun ? `live drift: ${maxDrift.toFixed(3)}m (< ${BLOB_RADIUS}m radius)<br>` : ''}` +
|
||||||
|
`<span style="opacity:.7">keys: G wipe ghost · R restart · P snapshot</span>`
|
||||||
|
}
|
||||||
|
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<string, unknown> {
|
||||||
|
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<void>((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')
|
||||||
|
}
|
||||||
184
src/ghost/format.ts
Normal file
184
src/ghost/format.ts
Normal file
@ -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<GhostPayload>
|
||||||
|
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<GhostPayload>
|
||||||
|
return typeof p.t === 'number' ? p.t : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/ghost/index.ts
Normal file
17
src/ghost/index.ts
Normal file
@ -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'
|
||||||
31
src/ghost/install.ts
Normal file
31
src/ghost/install.ts
Normal file
@ -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 }
|
||||||
|
}
|
||||||
135
src/ghost/player.ts
Normal file
135
src/ghost/player.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/ghost/recorder.ts
Normal file
90
src/ghost/recorder.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/ghost/verify-drift.ts
Normal file
104
src/ghost/verify-drift.ts
Normal file
@ -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')
|
||||||
273
src/ui/title.ts
Normal file
273
src/ui/title.ts
Normal file
@ -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<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitleHandle {
|
||||||
|
/** Resolves once the player has started (ui:play fired). */
|
||||||
|
done: Promise<void>
|
||||||
|
/** 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<void>((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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user