import * as THREE from "three"; import { AREngine } from "./ar-engine.js"; import { Ghost } from "./ghost.js"; import { scanQR } from "./qr.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; let roster = []; // ghost specs fetched after a QR scan (empty = procedural ghosts) let activeSetTitle = null; 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", scanAndStart); const freeBtn = $("free-btn"); if (freeBtn) freeBtn.addEventListener("click", () => { roster = []; activeSetTitle = null; 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 scanAndStart() { const code = await scanQR(); if (!code) return; // user cancelled try { const res = await fetch("/api/scan/" + encodeURIComponent(code)); if (!res.ok) { const err = await res.json().catch(() => ({})); showTitleNote(err.error || "Unknown set code", true); return; } const data = await res.json(); roster = data.ghosts || []; activeSetTitle = data.set?.title || null; if (!roster.length) { showTitleNote("That set has no active ghosts yet", true); return; } startHunt(); } catch (e) { console.error(e); showTitleNote("Couldn't reach the server", true); } } function showTitleNote(msg, isError) { capabilityEl.textContent = (isError ? "⚠ " : "") + msg; capabilityEl.className = "capability " + (isError ? "none" : "fallback"); } 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 ); // If a set was scanned, spawn one of its ghosts (weighted toward common); // otherwise fall back to a procedural wisp. const spec = roster.length ? pickFromRoster() : null; ghosts.push(new Ghost(engine.scene, pos, spec)); } // Rarity weighting: common is most likely, legendary rare. function pickFromRoster() { const weight = (r) => (r === "legendary" ? 1 : r === "rare" ? 3 : 7); const total = roster.reduce((s, g) => s + weight(g.rarity), 0); let roll = Math.random() * total; for (const g of roster) { roll -= weight(g.rarity); if (roll <= 0) return g; } return roster[0]; } /* ============ 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.displayName; } // 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"); }