Files
newbury-nights/public/ghost-visual.js
T

214 lines
7.2 KiB
JavaScript

/**
* 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
};
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);
}
`;
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: wisp body + gradient material ---
const uniforms = {
uTime: { value: 0 },
uTop: { value: hexToRGB(THREE, grad.top) },
uBottom: { value: hexToRGB(THREE, grad.bottom) },
uAlpha: { value: 1.0 },
};
const mat = new THREE.ShaderMaterial({
vertexShader: WISP_VERT,
fragmentShader: WISP_FRAG,
uniforms,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
blending: THREE.NormalBlending,
});
const 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?.());
},
};
}