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>
163 lines
5.1 KiB
TypeScript
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
|
|
}
|
|
}
|