150 lines
4.6 KiB
JavaScript
150 lines
4.6 KiB
JavaScript
import * as THREE from "three";
|
||
|
||
// Palette of ghost tints (matching the supernatural neon aesthetic)
|
||
const GHOST_TINTS = [
|
||
{ core: 0x9fe6ff, glow: 0x2ffbe6, name: "WISP" },
|
||
{ core: 0xff9fd0, glow: 0xff2d8f, name: "POLTERGEIST" },
|
||
{ core: 0xd4ff9f, glow: 0xb6ff3c, name: "PHANTOM" },
|
||
{ core: 0xc9b3ff, glow: 0x9b6dff, name: "SHADE" },
|
||
];
|
||
|
||
export class Ghost {
|
||
constructor(scene, position) {
|
||
this.scene = scene;
|
||
this.tint = GHOST_TINTS[Math.floor(Math.random() * GHOST_TINTS.length)];
|
||
this.group = new THREE.Group();
|
||
this.group.position.copy(position);
|
||
this.basePos = position.clone();
|
||
this.captured = false;
|
||
this.escaped = false;
|
||
this.captureProgress = 0;
|
||
this.spawnTime = performance.now();
|
||
this.lifespan = 14000 + Math.random() * 8000; // 14–22s before it flees
|
||
this.phase = Math.random() * Math.PI * 2;
|
||
this.bobSpeed = 0.6 + Math.random() * 0.5;
|
||
this.drift = new THREE.Vector3(
|
||
(Math.random() - 0.5) * 0.0006,
|
||
(Math.random() - 0.5) * 0.0003,
|
||
(Math.random() - 0.5) * 0.0006
|
||
);
|
||
|
||
this._build();
|
||
scene.add(this.group);
|
||
}
|
||
|
||
_build() {
|
||
const t = this.tint;
|
||
|
||
// Body — a softened rounded blob (sphere squished + tail)
|
||
const bodyGeo = new THREE.SphereGeometry(0.16, 24, 24);
|
||
bodyGeo.scale(1, 1.15, 1);
|
||
const bodyMat = new THREE.MeshBasicMaterial({
|
||
color: t.core, transparent: true, opacity: 0.55,
|
||
});
|
||
this.body = new THREE.Mesh(bodyGeo, bodyMat);
|
||
this.group.add(this.body);
|
||
|
||
// Inner glow core
|
||
const coreMat = new THREE.MeshBasicMaterial({
|
||
color: t.glow, transparent: true, opacity: 0.35,
|
||
});
|
||
const core = new THREE.Mesh(new THREE.SphereGeometry(0.1, 16, 16), coreMat);
|
||
core.position.y = 0.02;
|
||
this.group.add(core);
|
||
|
||
// Outer halo (sprite-like additive shell)
|
||
const haloMat = new THREE.MeshBasicMaterial({
|
||
color: t.glow, transparent: true, opacity: 0.12,
|
||
blending: THREE.AdditiveBlending, side: THREE.BackSide,
|
||
});
|
||
this.halo = new THREE.Mesh(new THREE.SphereGeometry(0.26, 20, 20), haloMat);
|
||
this.group.add(this.halo);
|
||
|
||
// Wispy tail tendrils
|
||
this.tendrils = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
const tendril = new THREE.Mesh(
|
||
new THREE.ConeGeometry(0.05, 0.22, 8),
|
||
new THREE.MeshBasicMaterial({ color: t.core, transparent: true, opacity: 0.4 })
|
||
);
|
||
tendril.position.set((i - 1) * 0.06, -0.18, 0);
|
||
tendril.rotation.x = Math.PI;
|
||
this.group.add(tendril);
|
||
this.tendrils.push(tendril);
|
||
}
|
||
|
||
// Eyes (two dark voids)
|
||
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x05070f });
|
||
const eyeGeo = new THREE.SphereGeometry(0.022, 10, 10);
|
||
const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
|
||
const eyeR = new THREE.Mesh(eyeGeo, eyeMat);
|
||
eyeL.position.set(-0.05, 0.03, 0.14);
|
||
eyeR.position.set(0.05, 0.03, 0.14);
|
||
this.group.add(eyeL, eyeR);
|
||
}
|
||
|
||
update(dt, camera) {
|
||
if (this.captured || this.escaped) return;
|
||
|
||
const now = performance.now();
|
||
const age = now - this.spawnTime;
|
||
|
||
// Bob + sway
|
||
const tt = now * 0.001;
|
||
this.group.position.x = this.basePos.x + Math.sin(tt * this.bobSpeed + this.phase) * 0.05;
|
||
this.group.position.y = this.basePos.y + Math.sin(tt * this.bobSpeed * 1.3 + this.phase) * 0.06;
|
||
this.group.position.z = this.basePos.z + Math.cos(tt * this.bobSpeed + this.phase) * 0.05;
|
||
|
||
// Slow wandering drift
|
||
this.basePos.add(this.drift);
|
||
|
||
// Tendril wiggle
|
||
this.tendrils.forEach((td, i) => {
|
||
td.rotation.z = Math.sin(tt * 3 + i) * 0.3;
|
||
});
|
||
|
||
// Halo pulse
|
||
const pulse = 1 + Math.sin(tt * 2) * 0.08;
|
||
this.halo.scale.setScalar(pulse);
|
||
|
||
// Always face the camera-ish (billboard the eyes)
|
||
if (camera) this.group.lookAt(camera.position.x, this.group.position.y, camera.position.z);
|
||
|
||
// If being captured, shrink & spin
|
||
if (this.captureProgress > 0) {
|
||
const s = 1 - this.captureProgress * 0.5;
|
||
this.group.scale.setScalar(s);
|
||
this.group.rotation.y += dt * 6;
|
||
}
|
||
|
||
// Flee when lifespan exceeded
|
||
if (age > this.lifespan && this.captureProgress < 0.05) {
|
||
this.escaped = true;
|
||
}
|
||
}
|
||
|
||
// Returns the ghost's world position
|
||
getWorldPosition(target) {
|
||
return this.group.getWorldPosition(target);
|
||
}
|
||
|
||
setCaptureProgress(p) {
|
||
this.captureProgress = Math.min(1, Math.max(0, p));
|
||
}
|
||
|
||
capture() {
|
||
this.captured = true;
|
||
return this.tint.name;
|
||
}
|
||
|
||
dispose() {
|
||
this.scene.remove(this.group);
|
||
this.group.traverse((o) => {
|
||
if (o.geometry) o.geometry.dispose();
|
||
if (o.material) o.material.dispose();
|
||
});
|
||
}
|
||
}
|
||
|
||
export { GHOST_TINTS };
|