diff --git a/public/js/ghost.js b/public/js/ghost.js new file mode 100644 index 0000000..bdf6e00 --- /dev/null +++ b/public/js/ghost.js @@ -0,0 +1,174 @@ +import * as THREE from "three"; + +// Procedural fallback tints (used when a ghost has no GIF asset) +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" }, +]; + +/** + * Ghost. + * @param scene THREE.Scene + * @param position THREE.Vector3 + * @param spec optional { name, gif_url, scale, rarity } from the API. + * When gif_url is present the ghost renders as an animated + * billboard; otherwise it falls back to the procedural wisp. + */ +export class Ghost { + constructor(scene, position, spec = null) { + this.scene = scene; + this.spec = spec; + this.tint = GHOST_TINTS[Math.floor(Math.random() * GHOST_TINTS.length)]; + this.displayName = spec?.name?.toUpperCase() || this.tint.name; + this.userScale = spec?.scale ? Number(spec.scale) : 1.0; + + 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 + ); + + if (spec?.gif_url) this._buildBillboard(spec.gif_url); + else this._buildProcedural(); + + scene.add(this.group); + } + + /* ---------- GIF / image billboard ---------- */ + _buildBillboard(url) { + // An drives the texture; animated GIFs keep animating in the , + // and we re-upload the frame to the GPU each render via texture.needsUpdate. + this.img = new Image(); + this.img.crossOrigin = "anonymous"; + this.img.src = url; + + const tex = new THREE.Texture(this.img); + tex.colorSpace = THREE.SRGBColorSpace; + tex.minFilter = THREE.LinearFilter; + this.texture = tex; + this._imgReady = false; + this.img.onload = () => { tex.needsUpdate = true; this._imgReady = true; this._fitAspect(); }; + + const mat = new THREE.MeshBasicMaterial({ + map: tex, transparent: true, side: THREE.DoubleSide, depthWrite: false, + }); + const geo = new THREE.PlaneGeometry(0.5, 0.5); + this.sprite = new THREE.Mesh(geo, mat); + this.group.add(this.sprite); + this.group.scale.setScalar(this.userScale); + + // Soft glow halo behind the sprite for a supernatural feel + const haloMat = new THREE.MeshBasicMaterial({ + color: this.tint.glow, transparent: true, opacity: 0.12, + blending: THREE.AdditiveBlending, depthWrite: false, + }); + this.halo = new THREE.Mesh(new THREE.CircleGeometry(0.35, 24), haloMat); + this.halo.position.z = -0.02; + this.group.add(this.halo); + + this.mode = "billboard"; + } + + _fitAspect() { + if (!this.img?.naturalWidth) return; + const aspect = this.img.naturalWidth / this.img.naturalHeight; + const h = 0.5, w = h * aspect; + this.sprite.geometry.dispose(); + this.sprite.geometry = new THREE.PlaneGeometry(w, h); + } + + /* ---------- Procedural wisp fallback ---------- */ + _buildProcedural() { + const t = this.tint; + const bodyGeo = new THREE.SphereGeometry(0.16, 24, 24); + bodyGeo.scale(1, 1.15, 1); + this.body = new THREE.Mesh(bodyGeo, new THREE.MeshBasicMaterial({ color: t.core, transparent: true, opacity: 0.55 })); + this.group.add(this.body); + + const core = new THREE.Mesh(new THREE.SphereGeometry(0.1, 16, 16), + new THREE.MeshBasicMaterial({ color: t.glow, transparent: true, opacity: 0.35 })); + core.position.y = 0.02; this.group.add(core); + + this.halo = new THREE.Mesh(new THREE.SphereGeometry(0.26, 20, 20), + new THREE.MeshBasicMaterial({ color: t.glow, transparent: true, opacity: 0.12, blending: THREE.AdditiveBlending, side: THREE.BackSide })); + this.group.add(this.halo); + + this.tendrils = []; + for (let i = 0; i < 3; i++) { + const td = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.22, 8), + new THREE.MeshBasicMaterial({ color: t.core, transparent: true, opacity: 0.4 })); + td.position.set((i - 1) * 0.06, -0.18, 0); td.rotation.x = Math.PI; + this.group.add(td); this.tendrils.push(td); + } + + 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); + + this.mode = "procedural"; + } + + update(dt, camera) { + if (this.captured || this.escaped) return; + const now = performance.now(); + const age = now - this.spawnTime; + const tt = now * 0.001; + + // Bob + sway + 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; + this.basePos.add(this.drift); + + if (this.mode === "billboard") { + // Keep pumping animated-GIF frames to the GPU + if (this._imgReady) this.texture.needsUpdate = true; + // Face the camera fully (billboard) + if (camera) this.group.quaternion.copy(camera.quaternion); + if (this.halo) this.halo.scale.setScalar(1 + Math.sin(tt * 2) * 0.08); + } else { + this.tendrils?.forEach((td, i) => { td.rotation.z = Math.sin(tt * 3 + i) * 0.3; }); + if (this.halo) this.halo.scale.setScalar(1 + Math.sin(tt * 2) * 0.08); + if (camera) this.group.lookAt(camera.position.x, this.group.position.y, camera.position.z); + } + + // Capture shrink/spin + if (this.captureProgress > 0) { + const s = (this.mode === "billboard" ? this.userScale : 1) * (1 - this.captureProgress * 0.5); + this.group.scale.setScalar(s); + if (this.mode === "procedural") this.group.rotation.y += dt * 6; + } + + if (age > this.lifespan && this.captureProgress < 0.05) this.escaped = true; + } + + getWorldPosition(target) { return this.group.getWorldPosition(target); } + setCaptureProgress(p) { this.captureProgress = Math.min(1, Math.max(0, p)); } + capture() { this.captured = true; return this.displayName; } + + dispose() { + this.scene.remove(this.group); + this.group.traverse((o) => { + if (o.geometry) o.geometry.dispose(); + if (o.material) o.material.dispose(); + }); + if (this.texture) this.texture.dispose(); + if (this.img) { this.img.onload = null; this.img.src = ""; } + } +} + +export { GHOST_TINTS };