/** * 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' import { assets } from '../assets/registry' 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) // Slot `ghost.body`, falling back to whatever is in `blob.body` so a custom // blob automatically gets a matching ghost. Materials are cloned per // instance by the registry, so forcing them translucent here cannot make // the real blob see-through. const reg = assets() const slot = reg.has('ghost.body') ? 'ghost.body' : 'blob.body' reg.attachSlot(slot, g, { onSwap: (asset) => { body.visible = false asset.traverse((o) => { const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined if (!m) return m.transparent = true m.opacity = GHOST_OPACITY m.depthWrite = false if (m.isMeshStandardMaterial) { m.color.set('#eaf6ff') m.emissive.set('#bfe6ff') m.emissiveIntensity = 0.5 m.map = null // the real blob's paint canvas is not the ghost's } this.mats.push(m) }) }, }) 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 } }