Add game controller: spawning, aiming, trapping, scoring, UI flow
This commit is contained in:
+264
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user