BLOBBO/src/ghost/player.ts
Claude 324d7d4568 Lane I: asset runtime — manifest, registry, slot hooks, IndexedDB overrides
Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).

- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
  GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
  layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
  no collider, physics or logic line is touched. Animated sub-parts (plate cap,
  belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
  cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
  would have 404'd in production. public/ is vite's default publicDir, so this
  needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
  raycast against the fitted body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:37:08 +10:00

163 lines
5.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'
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
}
}