Files

265 lines
7.9 KiB
JavaScript

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