314 lines
9.5 KiB
JavaScript
314 lines
9.5 KiB
JavaScript
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");
|
|
}
|