214 lines
7.2 KiB
JavaScript
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?.());
|
|
},
|
|
};
|
|
}
|