BLOBBO/src/ghost/player.ts
type-two ae8a93c417 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>
2026-07-18 11:47:34 +10:00

136 lines
4.1 KiB
TypeScript

/**
* 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
}
}