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 };