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>
136 lines
4.1 KiB
TypeScript
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
|
|
}
|
|
}
|