From eb06e43eb3af5fa0ca8b4e14516d018691fb5759 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 2 Jun 2026 16:16:01 +1000 Subject: [PATCH] Add game controller: spawning, aiming, trapping, scoring, UI flow --- js/main.js | 264 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 js/main.js diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..48ebab0 --- /dev/null +++ b/js/main.js @@ -0,0 +1,264 @@ +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"); +}