import * as THREE from "three"; import { AREngine } from "./ar-engine.js"; import { Ghost } from "./ghost.js"; /* ============ DOM ============ */ const $ = (id) => document.getElementById(id); const titleScreen = $("title-screen"); const huntScreen = $("hunt-screen"); const capabilityEl = $("capability"); const startBtn = $("start-btn"); const howBtn = $("how-btn"); const howto = $("howto"); const howtoClose = $("howto-close"); const scanPrompt = $("scan-prompt"); const hud = $("hud"); const scoreEl = $("score"); const nearbyEl = $("nearby"); const exitBtn = $("exit-btn"); const reticle = $("reticle"); const captureProgressEl = $("capture-progress"); const lockLabel = $("lock-label"); const dirHint = $("dir-hint"); const trapBtn = $("trap-btn"); const captureFlash = $("capture-flash"); const toast = $("toast"); const canvas = $("gl-canvas"); const video = $("camera-feed"); /* ============ STATE ============ */ let engine = null; let detectedMode = "none"; let ghosts = []; let score = 0; let scanned = false; let scanStart = 0; let trapping = false; let lockedGhost = null; let spawnTimer = 0; const CAPTURE_RATE = 0.55; // progress per second while locked const DECAY_RATE = 0.9; // progress lost per second when not locked const AIM_CONE = 0.92; // dot-product threshold for "on target" const MAX_GHOSTS = 5; const RING_CIRC = 2 * Math.PI * 54; const _camDir = new THREE.Vector3(); const _toGhost = new THREE.Vector3(); const _ghostPos = new THREE.Vector3(); const _camPos = new THREE.Vector3(); const _right = new THREE.Vector3(); /* ============ CAPABILITY CHECK ============ */ (async function init() { detectedMode = await AREngine.detect(); if (detectedMode === "xr") { capabilityEl.textContent = "✓ Full AR ready — world tracking supported"; capabilityEl.className = "capability full"; startBtn.disabled = false; } else if (detectedMode === "fallback") { capabilityEl.textContent = "✓ Camera AR mode — gyro look-around (no WebXR on this device)"; capabilityEl.className = "capability fallback"; startBtn.disabled = false; } else { capabilityEl.textContent = "⚠ No camera/AR available. Try a phone with a rear camera."; capabilityEl.className = "capability none"; startBtn.disabled = true; } })(); /* ============ UI WIRING ============ */ howBtn.addEventListener("click", () => howto.classList.add("open")); howtoClose.addEventListener("click", () => howto.classList.remove("open")); startBtn.addEventListener("click", startHunt); exitBtn.addEventListener("click", endHunt); // Trap button — pointer + touch const holdOn = (e) => { e.preventDefault(); trapping = true; trapBtn.classList.add("holding"); }; const holdOff = (e) => { e.preventDefault(); trapping = false; trapBtn.classList.remove("holding"); }; trapBtn.addEventListener("pointerdown", holdOn); trapBtn.addEventListener("pointerup", holdOff); trapBtn.addEventListener("pointercancel", holdOff); trapBtn.addEventListener("pointerleave", holdOff); /* ============ START / END ============ */ async function startHunt() { titleScreen.classList.remove("active"); huntScreen.classList.add("active"); engine = new AREngine(canvas, video); engine.onFrame = frameUpdate; engine.onEnd(() => resetToTitle()); try { await engine.start(detectedMode); } catch (e) { console.error(e); showToast("AR FAILED"); setTimeout(endHunt, 1500); return; } // Start scan phase scanned = false; scanStart = performance.now(); scanPrompt.classList.remove("hidden"); hud.classList.add("hidden"); score = 0; scoreEl.textContent = "0"; } function endHunt() { ghosts.forEach((g) => g.dispose()); ghosts = []; if (engine) engine.stop(); resetToTitle(); } function resetToTitle() { huntScreen.classList.remove("active"); titleScreen.classList.add("active"); hud.classList.add("hidden"); } /* ============ SPAWNING ============ */ function spawnGhost() { if (!engine) return; // Spawn in a ring around the camera at random bearing/elevation engine.camera.getWorldPosition(_camPos); const bearing = Math.random() * Math.PI * 2; const dist = 1.2 + Math.random() * 1.6; const elev = (Math.random() - 0.4) * 0.8; const pos = new THREE.Vector3( _camPos.x + Math.sin(bearing) * dist, _camPos.y + elev, _camPos.z + Math.cos(bearing) * dist ); ghosts.push(new Ghost(engine.scene, pos)); } /* ============ FRAME LOOP ============ */ function frameUpdate(dt, camera) { dt = Math.min(dt, 0.05); // Scan phase: wait ~3s of "mapping" then begin if (!scanned) { if (performance.now() - scanStart > 3000) { scanned = true; scanPrompt.classList.add("hidden"); hud.classList.remove("hidden"); // initial wave spawnGhost(); spawnGhost(); } return; } // Periodic spawns spawnTimer -= dt; if (spawnTimer <= 0 && ghosts.length < MAX_GHOSTS) { spawnGhost(); spawnTimer = 3 + Math.random() * 3; } // Camera forward + right camera.getWorldDirection(_camDir); camera.getWorldPosition(_camPos); _right.crossVectors(_camDir, camera.up).normalize(); // Update ghosts & find best aim target let best = null; let bestDot = AIM_CONE; let anyNearby = 0; for (const g of ghosts) { g.update(dt, camera); if (g.captured || g.escaped) continue; anyNearby++; g.getWorldPosition(_ghostPos); _toGhost.subVectors(_ghostPos, _camPos).normalize(); const dot = _toGhost.dot(_camDir); if (dot > bestDot) { bestDot = dot; best = g; } } // Aiming / trapping logic lockedGhost = best; const onTarget = !!best; reticle.classList.toggle("locked", onTarget && trapping); if (onTarget) { lockLabel.textContent = "LOCK: " + best.tint.name; } // Capture progress if (best) { if (trapping) { best.captureProgress += CAPTURE_RATE * dt; if (best.captureProgress >= 1) { const name = best.capture(); score++; scoreEl.textContent = String(score); fireCapture(name); // remove after brief implode handled by update scaling setTimeout(() => { best.dispose(); ghosts = ghosts.filter((x) => x !== best); }, 250); } best.setCaptureProgress(best.captureProgress); } else { best.captureProgress = Math.max(0, best.captureProgress - DECAY_RATE * dt); best.setCaptureProgress(best.captureProgress); } updateRing(best.captureProgress); } else { updateRing(0); } // Clean up escaped ghosts const escaped = ghosts.filter((g) => g.escaped); escaped.forEach((g) => { g.dispose(); }); if (escaped.length) ghosts = ghosts.filter((g) => !g.escaped); // Direction hint to nearest off-screen ghost updateDirHint(anyNearby, best); nearbyEl.textContent = String(anyNearby); } /* ============ HUD HELPERS ============ */ function updateRing(p) { captureProgressEl.style.strokeDashoffset = String(RING_CIRC * (1 - p)); } function updateDirHint(nearby, best) { if (best || nearby === 0) { dirHint.classList.add("hidden"); return; } // Find nearest ghost not on screen, point left/right let nearest = null, nd = Infinity; for (const g of ghosts) { if (g.captured || g.escaped) continue; g.getWorldPosition(_ghostPos); const d = _ghostPos.distanceTo(_camPos); if (d < nd) { nd = d; nearest = g; } } if (!nearest) { dirHint.classList.add("hidden"); return; } nearest.getWorldPosition(_ghostPos); _toGhost.subVectors(_ghostPos, _camPos).normalize(); const side = _toGhost.dot(_right); dirHint.classList.remove("hidden"); dirHint.classList.toggle("right", side > 0); } function fireCapture(name) { captureFlash.classList.remove("fire"); void captureFlash.offsetWidth; captureFlash.classList.add("fire"); showToast(name + " TRAPPED!"); if (navigator.vibrate) navigator.vibrate([30, 40, 80]); } function showToast(msg) { toast.textContent = msg; toast.classList.remove("show"); void toast.offsetWidth; toast.classList.add("show"); }