/** * ghost-visual.js — Newbury Nights shared ghost visual (Phase 2) * * Replicates the original's architecture: ONE shared base body, recoloured by a * per-colour gradient (recovered from GhostType_* assets). No per-ghost models. * * Tier 0 (always): procedural wisp mesh + vertical gradient material. * Tier 1 (progressive enhancement): FX billboards (trail / glow / smoke) * layered on top when textures + GPU budget allow. * Special: two roster ghosts can use real meshes (Ghost_Baseball/Bat) via * the `meshLoader` hook. * * Engine-agnostic about your spawn/capture logic — this only produces the * Object3D you place in the scene. Drop into your Three.js r160 app. * * Usage: * import { createGhostVisual, GHOST_GRADIENTS } from './ghost-visual.js'; * const g = createGhostVisual(THREE, { color: 'Red', fx: true }); * scene.add(g.object3d); * // each frame: * g.update(dt, elapsed); */ export const GHOST_GRADIENTS = { Red: { top: '#F65151', bottom: '#FF2678' }, // GhostType_Angry Yellow: { top: '#B57F0B', bottom: '#FFF35D' }, // GhostType_Crazy Blue: { top: '#529EFF', bottom: '#51EAF1' }, // GhostType_Sad }; // Shared ghost mesh (Ghost_Bat) loaded once and cloned per-ghost. Set via // setSharedMesh() before spawning; if null, ghosts fall back to the lathe wisp. let SHARED_MESH = null; export function setSharedMesh(mesh) { SHARED_MESH = mesh || null; } // Y-range of the shared mesh, for mapping the vertical gradient onto it. let SHARED_MESH_Y = { min: -1.83, max: 1.83 }; export function setSharedMeshYRange(min, max) { SHARED_MESH_Y = { min, max }; } const WISP_VERT = ` varying float vY; varying vec3 vPos; uniform float uTime; void main() { vec3 p = position; // gentle billow so the wisp breathes float w = sin(uTime * 1.6 + position.y * 3.0) * 0.04; p.x += w * (0.5 + uv.y); vY = uv.y; vPos = position; // object-space, for radial falloff in frag gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); } `; const WISP_FRAG = ` varying float vY; varying vec3 vPos; uniform vec3 uTop; uniform vec3 uBottom; uniform float uAlpha; uniform float uTime; void main() { vec3 col = mix(uBottom, uTop, vY); // vertical gradient (head bright) // breathing shimmer, never lets the body vanish float shimmer = 0.78 + 0.22 * sin(uTime * 2.0 + vY * 6.2831); float a = uAlpha * mix(0.55, 1.0, vY) * shimmer; gl_FragColor = vec4(col, a); } `; // Gradient shader for the real mesh: maps the vertical gradient onto the mesh's // object-space Y range (passed as uYMin/uYMax) and keeps it solid + glowy. const MESH_VERT = ` varying float vT; uniform float uYMin; uniform float uYMax; uniform float uTime; void main() { vT = clamp((position.y - uYMin) / max(0.001, (uYMax - uYMin)), 0.0, 1.0); vec3 p = position; // subtle billow so the ghost feels alive p.x += sin(uTime * 1.5 + position.y * 2.0) * 0.03; gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); } `; const MESH_FRAG = ` varying float vT; uniform vec3 uTop; uniform vec3 uBottom; uniform float uAlpha; uniform float uTime; void main() { vec3 col = mix(uBottom, uTop, vT); float shimmer = 0.85 + 0.15 * sin(uTime * 2.0 + vT * 6.2831); gl_FragColor = vec4(col * shimmer, uAlpha); } `; function hexToRGB(THREE, hex) { return new THREE.Color(hex); } /** * Build the shared wisp body (a tapered, double-sided plane-ish blob). * Kept cheap: a subdivided cone/teardrop so the gradient + billow read well. */ function buildWispGeometry(THREE) { // teardrop-ish body: rounded head (sphere-like top) tapering to a wispy tail. // Lathe a profile curve so it reads as a ghost rather than a hard cone. const pts = []; const N = 12; for (let i = 0; i <= N; i++) { const t = i / N; // 0 = tail bottom, 1 = head top const y = -0.6 + t * 1.2; // height -0.6 .. 0.6 // radius: thin pointed tail, bulging rounded head const r = Math.sin(t * Math.PI * 0.92) * 0.42 + t * 0.06; pts.push(new THREE.Vector2(Math.max(0.001, r), y)); } const geo = new THREE.LatheGeometry(pts, 20); return geo; } export function createGhostVisual(THREE, opts = {}) { const color = opts.color && GHOST_GRADIENTS[opts.color] ? opts.color : 'Blue'; const grad = GHOST_GRADIENTS[color]; const wantFx = opts.fx !== false; const group = new THREE.Group(); group.name = `ghost-${color}`; // --- Tier 0: ghost body. Prefer the real shared mesh (Ghost_Bat), recoloured // by the gradient; fall back to the procedural lathe wisp if no mesh. --- const uniforms = { uTime: { value: 0 }, uTop: { value: hexToRGB(THREE, grad.top) }, uBottom: { value: hexToRGB(THREE, grad.bottom) }, uAlpha: { value: 1.0 }, }; let body, mat; if (SHARED_MESH) { // clone the shared mesh geometry and give it the mesh gradient material mat = new THREE.ShaderMaterial({ vertexShader: MESH_VERT, fragmentShader: MESH_FRAG, uniforms: { ...uniforms, uYMin: { value: SHARED_MESH_Y.min }, uYMax: { value: SHARED_MESH_Y.max }, }, transparent: true, depthWrite: true, side: THREE.DoubleSide, blending: THREE.NormalBlending, }); // SHARED_MESH may be a Group (from OBJLoader) or a Mesh; find the first mesh geometry let srcGeo = null; SHARED_MESH.traverse?.(o => { if (!srcGeo && o.isMesh) srcGeo = o.geometry; }); if (!srcGeo && SHARED_MESH.isMesh) srcGeo = SHARED_MESH.geometry; body = new THREE.Mesh(srcGeo, mat); // normalise scale: mesh is ~3.6 units tall; scale to ~1.2 like the wisp const targetH = 1.4; const meshH = (SHARED_MESH_Y.max - SHARED_MESH_Y.min) || 3.66; body.scale.setScalar(targetH / meshH); // keep the alpha-driven uniforms reachable for animation uniforms.uTime = mat.uniforms.uTime; uniforms.uAlpha = mat.uniforms.uAlpha; } else { mat = new THREE.ShaderMaterial({ vertexShader: WISP_VERT, fragmentShader: WISP_FRAG, uniforms, transparent: true, depthWrite: false, side: THREE.DoubleSide, blending: THREE.NormalBlending, }); body = new THREE.Mesh(buildWispGeometry(THREE), mat); } group.add(body); // --- always-on soft glow halo (procedural, no texture needed) so the ghost // never looks like a bare cone even before FX textures attach --- const haloMat = new THREE.SpriteMaterial({ color: hexToRGB(THREE, grad.bottom), transparent: true, opacity: 0.35, depthWrite: false, blending: THREE.AdditiveBlending, }); const halo = new THREE.Sprite(haloMat); halo.scale.setScalar(1.5); halo.position.y = 0.1; group.add(halo); // --- Tier 1: FX billboards (progressive enhancement) --- const fxSprites = []; function addFxBillboard(texture, scale, yOffset, opacity, blend) { const m = new THREE.SpriteMaterial({ map: texture, color: new THREE.Color(grad.top), transparent: true, opacity, depthWrite: false, blending: blend ?? THREE.AdditiveBlending, }); const s = new THREE.Sprite(m); s.scale.setScalar(scale); s.position.y = yOffset; group.add(s); fxSprites.push(s); return s; } // caller passes a loaded-texture map when fx enabled; see attachFx() let fxAttached = false; function attachFx(textures) { if (!wantFx || fxAttached || !textures) return; // give the procedural halo a real soft-glow texture if provided if (textures.glow) { haloMat.map = textures.glow; haloMat.opacity = 0.55; haloMat.needsUpdate = true; } if (textures.trail) addFxBillboard(textures.trail, 1.0, -0.45, 0.4); if (textures.smoke) addFxBillboard(textures.smoke, 0.8, 0.3, 0.25); fxAttached = true; } // --- optional special mesh override (Ghost_Baseball / Ghost_Bat) --- function useSpecialMesh(mesh) { if (!mesh) return; body.visible = false; mesh.traverse?.(o => { if (o.isMesh && o.material) { o.material.color = new THREE.Color(grad.top); } }); group.add(mesh); } // --- spawn / capture animation helpers --- let state = 'idle'; let stateT = 0; function setState(s) { state = s; stateT = 0; } function update(dt, elapsed) { uniforms.uTime.value = elapsed; stateT += dt; // bob: offset the BODY around the group, so the group's spawn position // stays fixed (previously this accumulated onto group.y and the ghost // drifted up out of view every frame). body.position.y = Math.sin(elapsed * 1.8) * 0.08; halo.position.y = 0.1 + Math.sin(elapsed * 1.8) * 0.08; // halo bobs with body // billboard FX face camera handled by Sprite automatically if (state === 'spawn') { const k = Math.min(stateT / 0.6, 1); group.scale.setScalar(k); uniforms.uAlpha.value = k; if (k >= 1) setState('idle'); } else if (state === 'capture') { const k = Math.min(stateT / 0.5, 1); group.scale.setScalar(1 - k); uniforms.uAlpha.value = 1 - k; } } // start hidden, play spawn group.scale.setScalar(0); setState('spawn'); return { object3d: group, color, update, setState, // 'idle' | 'spawn' | 'capture' attachFx, // call with {glow, trail, smoke} THREE.Textures useSpecialMesh, // call with a loaded Ghost_Baseball/Bat mesh setColor(next) { const g2 = GHOST_GRADIENTS[next]; if (!g2) return; uniforms.uTop.value.set(g2.top); uniforms.uBottom.value.set(g2.bottom); haloMat.color.set(g2.bottom); fxSprites.forEach(s => s.material.color.set(g2.top)); }, dispose() { body.geometry?.dispose?.(); mat.dispose?.(); haloMat.dispose?.(); fxSprites.forEach(s => s.material?.map?.dispose?.()); }, }; }