Files
newbury-nights/public/hunt.html
T

606 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Newbury Nights - Hunt Demo</title>
<style>
:root {
--ink: #0a0c14;
--ink-2: #11131f;
--mist: #c7ccd9;
--mist-dim: #6b7184;
--red: #FF2678;
--red-top: #F65151;
--yellow: #FFF35D;
--yellow-top: #B57F0B;
--blue: #51EAF1;
--blue-top: #529EFF;
--haze: rgba(199,204,217,0.08);
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0; height: 100%; overflow: hidden;
background: var(--ink); color: var(--mist);
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;
user-select: none;
}
#scene, #cam { position: fixed; inset: 0; width: 100%; height: 100%; }
#cam { object-fit: cover; z-index: 0; background: #000; }
#scene { z-index: 1; }
canvas { display: block; }
#hud { position: fixed; inset: 0; z-index: 3; pointer-events: none; }
.pointer { pointer-events: auto; }
#haunt {
position: fixed; left: 0; top: 0; bottom: 0; width: 8px; z-index: 4;
}
#haunt-fill {
position: absolute; left: 0; right: 0; bottom: 0; height: 0%;
background: linear-gradient(0deg, var(--blue), #fff2);
box-shadow: 0 0 18px var(--blue); transition: height 0.4s ease, background 0.6s, box-shadow 0.6s;
}
#haunt-label {
position: fixed; left: 14px; top: 14px; z-index: 5;
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--mist-dim);
}
#haunt-pct { color: var(--mist); font-size: 13px; }
#readout {
position: fixed; right: 14px; top: 14px; z-index: 5; text-align: right;
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mist-dim);
}
#readout b { color: var(--mist); font-size: 15px; letter-spacing: 0; }
#reticle {
position: fixed; left: 50%; top: 50%; width: 64px; height: 64px;
transform: translate(-50%, -50%); z-index: 4; pointer-events: none;
}
#reticle .ring {
position: absolute; inset: 0; border: 1.5px solid var(--haze); border-radius: 50%;
}
#reticle .charge {
position: absolute; inset: 4px; border-radius: 50%;
background: conic-gradient(var(--blue) 0deg, transparent 0deg);
opacity: 0.85;
}
#reticle .dot { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px;
transform: translate(-50%,-50%); background: var(--mist); border-radius: 50%; }
#reticle.locked .ring { border-color: var(--mist); }
#wheel {
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
z-index: 5; display: flex; gap: 14px; pointer-events: auto;
}
.lure {
width: 52px; height: 52px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.14);
background: var(--c); position: relative; cursor: pointer;
transition: transform 0.15s, box-shadow 0.2s, border-color 0.2s; opacity: 0.55;
}
.lure[data-on="1"] { opacity: 1; transform: scale(1.12);
border-color: #fff; box-shadow: 0 0 22px var(--c); }
.lure.red { --c: var(--red); }
.lure.blue { --c: var(--blue); }
.lure.yellow { --c: var(--yellow); }
#wheel-label { position: fixed; left: 50%; bottom: 88px; transform: translateX(-50%);
z-index: 5; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
color: var(--mist-dim); }
#fire {
position: fixed; right: 22px; bottom: 30px; z-index: 6;
width: 84px; height: 84px; border-radius: 50%;
background: radial-gradient(circle at 50% 40%, var(--ink-2), #05060c);
border: 2px solid rgba(255,255,255,0.16); color: var(--mist);
font: inherit; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
pointer-events: auto; cursor: pointer; touch-action: none;
}
#fire:active { border-color: #fff; }
#toasts { position: fixed; left: 50%; top: 64px; transform: translateX(-50%);
z-index: 6; display: flex; flex-direction: column; gap: 6px; align-items: center; }
.toast { font-size: 12px; letter-spacing: 0.06em; padding: 6px 12px;
background: rgba(10,12,20,0.7); border: 1px solid var(--haze); border-radius: 2px;
color: var(--mist); animation: rise 2.4s forwards; }
.toast .nm { color: #fff; }
@keyframes rise { 0% { opacity: 0; transform: translateY(6px);} 12%{opacity:1;transform:none;}
80%{opacity:1;} 100%{opacity:0;} }
#start { position: fixed; inset: 0; z-index: 10; background:
radial-gradient(120% 80% at 50% 0%, #14182a 0%, var(--ink) 60%);
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px; }
#start h1 { font-size: clamp(30px, 9vw, 52px); margin: 0; letter-spacing: -0.01em;
font-weight: 600; color: #fff; line-height: 0.98; }
#start h1 .n2 { display: block; color: var(--blue); font-style: italic; }
#start p { max-width: 30ch; color: var(--mist-dim); font-size: 13px; line-height: 1.6;
letter-spacing: 0.02em; }
#start .sub { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
color: var(--mist-dim); margin-bottom: 18px; }
.btn { margin-top: 22px; pointer-events: auto; cursor: pointer; font: inherit;
font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase;
padding: 14px 28px; color: var(--ink); background: var(--mist);
border: none; border-radius: 2px; }
.btn.ghost { background: transparent; color: var(--mist); border: 1px solid var(--haze);
margin-top: 10px; }
#note { position: fixed; bottom: 8px; left: 0; right: 0; text-align: center; z-index: 10;
font-size: 9px; letter-spacing: 0.14em; color: #3a3f4f; }
@media (prefers-reduced-motion: reduce) { .toast { animation-duration: 0.1s; } }
/* ---- debug overlay ---- */
#dbg-toggle { position: fixed; left: 14px; bottom: 14px; z-index: 7; pointer-events: auto;
font: inherit; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
background: rgba(10,12,20,0.7); color: var(--mist-dim); border: 1px solid var(--haze);
border-radius: 2px; padding: 8px 10px; cursor: pointer; }
#dbg { position: fixed; left: 14px; bottom: 52px; z-index: 7; width: min(320px, 86vw);
max-height: 46vh; background: rgba(8,10,16,0.92); border: 1px solid var(--haze);
border-radius: 4px; padding: 10px; display: none; pointer-events: auto;
font-size: 10px; line-height: 1.5; color: var(--mist); }
#dbg.open { display: block; }
#dbg .row { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px;
color: var(--mist-dim); }
#dbg .row b { color: var(--mist); }
#dbg .ctl { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
#dbg .ctl button { font: inherit; font-size: 12px; width: 24px; height: 24px;
background: var(--ink-2); color: var(--mist); border: 1px solid var(--haze);
border-radius: 2px; cursor: pointer; }
#dbg-log { max-height: 28vh; overflow-y: auto; border-top: 1px solid var(--haze);
padding-top: 6px; font-family: ui-monospace, monospace; }
#dbg-log div { white-space: nowrap; }
#dbg-log .spawn { color: var(--yellow); }
#dbg-log .detect { color: var(--blue); }
#dbg-log .capture { color: #7CFFA0; }
#dbg-log .flee { color: var(--red); }
/* off-screen direction arrow */
#arrow { position: fixed; z-index: 6; width: 0; height: 0; pointer-events: none;
display: none; filter: drop-shadow(0 0 6px var(--c, var(--blue))); }
</style>
</head>
<body>
<video id="cam" autoplay muted playsinline></video>
<div id="scene"></div>
<div id="hud">
<div id="haunt"><div id="haunt-fill"></div></div>
<div id="haunt-label">Haunt <span id="haunt-pct">0%</span></div>
<div id="readout"><b id="caught">0</b><br>caught &middot; <span id="score">0</span> pts</div>
<div id="reticle"><div class="ring"></div><div class="charge"></div><div class="dot"></div></div>
<div id="wheel-label">Lure</div>
<div id="wheel" class="pointer">
<div class="lure red" data-color="Red"></div>
<div class="lure blue" data-color="Blue"></div>
<div class="lure yellow" data-color="Yellow"></div>
</div>
<button id="fire">Hold<br>to charge</button>
<div id="toasts"></div>
</div>
<!-- off-screen ghost direction arrow -->
<svg id="arrow" width="40" height="40" viewBox="0 0 40 40"><path d="M20 4 L34 32 L20 24 L6 32 Z" fill="var(--c, #51EAF1)"/></svg>
<!-- debug overlay -->
<button id="dbg-toggle">Debug</button>
<div id="dbg">
<div class="row"><span>active / max</span><b><span id="dbg-active">0</span> / <span id="dbg-max">4</span></b></div>
<div class="ctl"><span style="color:var(--mist-dim)">max active</span>
<button id="dbg-minus">-</button><b id="dbg-maxval">4</b><button id="dbg-plus">+</button>
<label style="margin-left:auto;color:var(--mist-dim)"><input type="checkbox" id="dbg-beacon" checked> beacons</label>
</div>
<div class="row"><span>haunt</span><b id="dbg-haunt">0%</b></div>
<div class="row"><span>server log</span><b id="dbg-srv">off</b></div>
<div id="dbg-log"></div>
</div>
<div id="start">
<div class="sub">A spiritual successor - fan tribute</div>
<h1>Newbury<span class="n2">Nights</span></h1>
<p>Raise your phone to the room. Spirits gather in the dark - pick a lure colour,
aim, and charge your shot to catch them before the haunt overtakes the night.</p>
<button class="btn" id="go-ar">Begin hunt (AR)</button>
<button class="btn ghost" id="go-gyro">No AR - use motion</button>
</div>
<div id="note">Fan-made tribute. Not affiliated with or endorsed by the LEGO Group.</div>
<div id="ver" style="position:fixed;right:6px;bottom:6px;z-index:11;font-size:9px;color:#2e3340;letter-spacing:0.1em;">build v8</div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { createGhostVisual } from './ghost-visual.js?v=8';
import { createHunt } from './hunt.js?v=8';
console.log('%cNewbury Nights hunt — build v8 loaded (orbit ghosts + parallax)', 'color:#51EAF1');
const $ = s => document.querySelector(s);
let roster = [];
try {
roster = await (await fetch('./ghosts.enriched.json?v=8')).json();
} catch (e) {
roster = [
{ id:'basic-r', name:'Wisp', color:'Red', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
{ id:'basic-b', name:'Wisp', color:'Blue', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
{ id:'basic-y', name:'Wisp', color:'Yellow', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
];
}
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
$('#scene').appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 50);
scene.add(new THREE.HemisphereLight(0x8899ff, 0x111122, 1.1));
// ---- load FX textures (glow/trail/smoke) so ghosts look wispy, not like cones ----
const texLoader = new THREE.TextureLoader();
function loadTex(path){
return new Promise(res => texLoader.load(path, t => res(t), undefined, () => res(null)));
}
const fxTextures = {
glow: await loadTex('./ghosts/fx/FX_Glow.png?v=8'),
trail: await loadTex('./ghosts/fx/FX_WispyTrail2.png?v=8'),
smoke: await loadTex('./ghosts/fx/smokepuff1.png?v=8'),
};
console.log('FX textures loaded:', Object.fromEntries(Object.entries(fxTextures).map(([k,v])=>[k, !!v])));
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
});
const hunt = createHunt(THREE, scene, {
roster, fx: true, fxTextures,
onEvent: (e) => {
if (e.type === 'spawn') { spawnBeacon(e); dbgLog('spawn', e); }
if (e.type === 'capture') { toast(`Caught <span class="nm">${e.ghost.name}</span> +${e.score}`); dbgLog('capture', e); }
if (e.type === 'flee') { toast(`<span class="nm">${e.ghost.name}</span> slipped away`); dbgLog('flee', e); }
if (e.type === 'capture' || e.type === 'spawn' || e.type === 'flee') {
score = scoreFromCaptures();
$('#caught').textContent = hunt.captured.length;
$('#score').textContent = score;
}
logToServer(e);
}
});
// ---- debug + telemetry ----
const SESSION = Math.random().toString(36).slice(2, 8);
let serverLog = false; // toggled on if the endpoint answers
let serverLogDisabled = false; // turns off after the first failure (e.g. static file server)
let beaconsOn = true;
const seenInView = new Set(); // ids already "detected" (entered the frustum)
const beacons = new Map(); // id -> beacon mesh
// spawn beacon: a bright, always-visible marker at the spawn point so you can
// see WHERE a ghost is even if the wisp itself fails to render.
function spawnBeacon(e){
if (!beaconsOn) return;
// find the matching active ghost's object3d to read its live position
const a = hunt.listActive().find(x => x.id === e.id);
const pos = a ? a.object3d.position : new THREE.Vector3(e.pos?.x||0, e.pos?.y||0, e.pos?.z||0);
const colMap = { Red:0xFF2678, Blue:0x51EAF1, Yellow:0xFFF35D };
const col = colMap[e.ghost?.color] || 0xffffff;
const grp = new THREE.Group();
// bright core
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 12, 12),
new THREE.MeshBasicMaterial({ color: col })
);
// halo ring billboard
const ring = new THREE.Sprite(new THREE.SpriteMaterial({ color: col, transparent:true, opacity:0.6 }));
ring.scale.setScalar(0.9);
grp.add(core); grp.add(ring);
grp.position.copy(pos);
grp.userData.born = performance.now();
scene.add(grp);
beacons.set(e.id, grp);
}
function updateBeacons(){
const now = performance.now();
for (const [id, g] of beacons) {
const age = (now - g.userData.born) / 1000;
const pulse = 0.7 + 0.3 * Math.sin(age * 6);
g.children[0].scale.setScalar(pulse);
g.children[1].material.opacity = 0.5 * pulse;
// beacon fades after 3s (ghost should be found by then)
if (age > 3) { scene.remove(g); beacons.delete(id); }
// also drop the beacon once the ghost is gone
if (!hunt.listActive().some(a => a.id === id) && age > 0.5) { scene.remove(g); beacons.delete(id); }
}
}
// detection: fire once when a ghost first enters the camera view
function checkDetections(){
for (const a of hunt.listActive()) {
const p = a.object3d.position.clone().project(camera);
const inView = p.z < 1 && Math.abs(p.x) < 1 && Math.abs(p.y) < 1;
if (inView && !seenInView.has(a.id)) {
seenInView.add(a.id);
const e = { type:'detect', id:a.id, ghost:a.ghost };
dbgLog('detect', e); logToServer(e);
}
}
}
const dbgLogEl = () => $('#dbg-log');
function dbgLog(kind, e){
const el = dbgLogEl(); if (!el) return;
const ts = new Date().toLocaleTimeString().slice(0,8);
const nm = e.ghost?.name || '';
const extra = e.pos ? ` @${e.pos.x},${e.pos.y},${e.pos.z}` : (e.score ? ` +${e.score}` : '');
const line = document.createElement('div');
line.className = kind;
line.textContent = `${ts} ${kind.toUpperCase().padEnd(7)} ${nm}${extra}`;
el.prepend(line);
while (el.childNodes.length > 80) el.removeChild(el.lastChild);
}
async function logToServer(e){
if (serverLogDisabled) return; // don't spam a server that can't receive POSTs
try {
const r = await fetch('/api/hunt-log', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ ...e, ghost: e.ghost?.name, color: e.ghost?.color,
rarity: e.ghost?.rarity, session: SESSION })
});
if (r.ok) { if (!serverLog) { serverLog = true; $('#dbg-srv').textContent = 'on'; } }
else { serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a (static)'; }
} catch(_) {
serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a'; // no backend (static demo) — stop trying
}
}
const RARITY_SCORE = { Common:10, Rare:25, Epic:60, Legendary:150 };
let score = 0;
function scoreFromCaptures() {
const byId = Object.fromEntries(roster.map(g => [g.id, g]));
return hunt.captured.reduce((s,id)=> s + (RARITY_SCORE[byId[id]?.rarity]||10), 0);
}
document.querySelectorAll('.lure').forEach(el => {
el.addEventListener('click', () => {
const on = el.dataset.on === '1';
document.querySelectorAll('.lure').forEach(x => x.dataset.on='0');
if (!on) { el.dataset.on='1'; hunt.setLure(el.dataset.color); reticleColor(el.dataset.color); }
else { hunt.setLure(null); reticleColor(null); }
});
});
function reticleColor(c){
const map = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
document.documentElement.style.setProperty('--blue', map[c] || '#51EAF1');
}
function currentTarget() {
const list = hunt.listActive();
let best=null, bestD=Infinity;
for (const a of list) {
const p = a.object3d.position.clone().project(camera);
if (p.z > 1) continue;
const d = Math.hypot(p.x, p.y);
if (d < bestD) { bestD = d; best = a; }
}
return bestD < 0.35 ? best : null;
}
let charging=false, charge=0;
const fireBtn = $('#fire');
const chargeEl = $('#reticle .charge');
function setCharge(v){
charge = Math.max(0, Math.min(1, v));
chargeEl.style.background = `conic-gradient(var(--blue) ${charge*360}deg, transparent ${charge*360}deg)`;
}
const press = (e)=>{ e.preventDefault(); charging=true; };
const release = (e)=>{
e.preventDefault();
if (!charging) return; charging=false;
const t = currentTarget();
if (t) hunt.fireAt(t.id, charge);
setCharge(0);
};
fireBtn.addEventListener('pointerdown', press);
fireBtn.addEventListener('pointerup', release);
fireBtn.addEventListener('pointercancel', release);
function toast(html){
const d = document.createElement('div'); d.className='toast'; d.innerHTML=html;
$('#toasts').appendChild(d); setTimeout(()=>d.remove(), 2400);
}
function paintHaunt(){
const h = hunt.haunt;
$('#haunt-fill').style.height = h + '%';
$('#haunt-pct').textContent = Math.round(h) + '%';
const col = h < 50 ? 'var(--blue)' : h < 80 ? 'var(--yellow)' : 'var(--red)';
$('#haunt-fill').style.background = `linear-gradient(0deg, ${col}, #fff2)`;
}
let usingXR = false, xrSession = null, gyroQuat = null;
async function startAR(){
if (navigator.xr && await navigator.xr.isSessionSupported?.('immersive-ar')) {
try {
xrSession = await navigator.xr.requestSession('immersive-ar', { optionalFeatures:['local-floor','hit-test'] });
renderer.xr.enabled = true;
await renderer.xr.setSession(xrSession);
usingXR = true; beginLoop(); hideStart(); return;
} catch(e){ /* fall through to gyro */ }
}
startGyro();
}
async function startGyro(){
hideStart();
try {
const stream = await navigator.mediaDevices.getUserMedia({ video:{ facingMode:'environment' }, audio:false });
$('#cam').srcObject = stream;
} catch(e){ /* no camera - black bg is fine */ }
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) {
try { await DeviceOrientationEvent.requestPermission(); } catch(e){}
}
// iOS requires a separate permission for motion (accelerometer) used by parallax
if (typeof DeviceMotionEvent !== 'undefined' && DeviceMotionEvent.requestPermission) {
try { await DeviceMotionEvent.requestPermission(); } catch(e){}
}
gyroQuat = new THREE.Quaternion();
let gotGyro = false;
const euler = new THREE.Euler();
const zee = new THREE.Vector3(0,0,1);
const q0 = new THREE.Quaternion();
const q1 = new THREE.Quaternion(-Math.sqrt(0.5),0,0,Math.sqrt(0.5));
addEventListener('deviceorientation', (ev)=>{
if (ev.alpha == null && ev.beta == null && ev.gamma == null) return;
gotGyro = true;
const a = THREE.MathUtils.degToRad(ev.alpha||0);
const b = THREE.MathUtils.degToRad(ev.beta||0);
const g = THREE.MathUtils.degToRad(ev.gamma||0);
const orient = THREE.MathUtils.degToRad(screen.orientation?.angle||0);
euler.set(b, a, -g, 'YXZ');
gyroQuat.setFromEuler(euler);
gyroQuat.multiply(q1);
gyroQuat.multiply(q0.setFromAxisAngle(zee, -orient));
}, true);
// if no gyro data arrives within 1s (desktop / no sensor), enable drag-look
setTimeout(() => { if (!gotGyro) enableDragLook(); }, 1000);
enableParallax();
beginLoop();
}
// ---- fake parallax: a subtle camera sway from device motion / tilt ----
// Cannot give true positional tracking (iOS Safari has no WebXR), but reading
// devicemotion lets us add a small depth-giving sway when you tilt or step.
const parallax = { x: 0, y: 0, tx: 0, ty: 0 };
function enableParallax(){
// tilt-based offset (works from deviceorientation beta/gamma we already get)
addEventListener('deviceorientation', (ev) => {
if (ev.beta == null && ev.gamma == null) return;
// map tilt to a tiny lateral/vertical target offset (clamped, metres)
parallax.tx = Math.max(-0.12, Math.min(0.12, (ev.gamma || 0) / 90 * 0.12));
parallax.ty = Math.max(-0.08, Math.min(0.08, ((ev.beta || 0) - 80) / 90 * 0.08));
}, true);
// motion-based micro-impulse (a step/jolt nudges the sway)
if (typeof DeviceMotionEvent !== 'undefined') {
addEventListener('devicemotion', (ev) => {
const a = ev.accelerationIncludingGravity || ev.acceleration;
if (!a) return;
parallax.tx += Math.max(-0.02, Math.min(0.02, (a.x || 0) * 0.002));
parallax.ty += Math.max(-0.02, Math.min(0.02, (a.y || 0) * 0.002));
parallax.tx = Math.max(-0.15, Math.min(0.15, parallax.tx));
parallax.ty = Math.max(-0.12, Math.min(0.12, parallax.ty));
}, true);
}
}
function hideStart(){ $('#start').style.display='none'; }
$('#go-ar').addEventListener('click', startAR);
$('#go-gyro').addEventListener('click', startGyro);
// ---- desktop fallback: drag to look around (no gyro on desktop) ----
let dragYaw = 0, dragPitch = 0, dragging = false, lastX = 0, lastY = 0;
let usingDrag = false;
function enableDragLook(){
usingDrag = true;
const el = renderer.domElement;
const down = (e) => { dragging = true; lastX = e.clientX ?? e.touches?.[0]?.clientX; lastY = e.clientY ?? e.touches?.[0]?.clientY; };
const move = (e) => {
if (!dragging) return;
const x = e.clientX ?? e.touches?.[0]?.clientX, y = e.clientY ?? e.touches?.[0]?.clientY;
dragYaw -= (x - lastX) * 0.005;
dragPitch -= (y - lastY) * 0.005;
dragPitch = Math.max(-1.2, Math.min(1.2, dragPitch));
lastX = x; lastY = y;
};
const up = () => { dragging = false; };
el.addEventListener('mousedown', down); addEventListener('mousemove', move); addEventListener('mouseup', up);
el.addEventListener('touchstart', down, {passive:true}); addEventListener('touchmove', move, {passive:true}); addEventListener('touchend', up);
}
// ---- debug panel controls ----
$('#dbg-toggle').addEventListener('click', () => $('#dbg').classList.toggle('open'));
function setMax(n){
hunt.setMaxActive(n);
$('#dbg-maxval').textContent = hunt.maxActive;
$('#dbg-max').textContent = hunt.maxActive;
}
$('#dbg-minus').addEventListener('click', () => setMax(hunt.maxActive - 1));
$('#dbg-plus').addEventListener('click', () => setMax(hunt.maxActive + 1));
$('#dbg-beacon').addEventListener('change', (e) => { beaconsOn = e.target.checked; });
setMax(hunt.maxActive);
// ---- off-screen direction arrow: points to the nearest off-screen ghost ----
const arrowEl = $('#arrow');
function updateArrow(){
const list = hunt.listActive();
let target = null, bestZ = Infinity;
for (const a of list) {
const p = a.object3d.position.clone().project(camera);
const onScreen = p.z < 1 && Math.abs(p.x) < 0.95 && Math.abs(p.y) < 0.95;
if (onScreen) continue;
if (p.z < bestZ) { bestZ = p.z; target = { a, p }; }
}
if (!target) { arrowEl.style.display = 'none'; return; }
// direction from screen centre toward the (clamped) projected point.
// behind-camera points (z>1) get flipped so the arrow still makes sense.
let { x, y, z } = target.p;
if (z > 1) { x = -x; y = -y; }
const ang = Math.atan2(y, x); // screen angle (y up)
const r = Math.min(innerWidth, innerHeight) * 0.36;
const cx = innerWidth/2 + Math.cos(ang) * r;
const cy = innerHeight/2 - Math.sin(ang) * r; // CSS y is down
const colMap = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
arrowEl.style.setProperty('--c', colMap[target.a.ghost.color] || '#51EAF1');
arrowEl.querySelector('path').setAttribute('fill', colMap[target.a.ghost.color] || '#51EAF1');
arrowEl.style.display = 'block';
arrowEl.style.left = (cx - 20) + 'px';
arrowEl.style.top = (cy - 20) + 'px';
arrowEl.style.transform = `rotate(${-ang + Math.PI/2}rad)`; // point outward
}
const clock = new THREE.Clock();
function frame(){
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
if (charging) setCharge(charge + dt * 0.9);
if (usingXR) {
// In AR, the device pose drives renderer.xr.getCamera(). Sync our logic
// camera (used for targeting/detection/arrows) from it so position AND
// rotation both track as you physically move the phone.
const xrCam = renderer.xr.getCamera();
xrCam.getWorldPosition(camera.position);
xrCam.getWorldQuaternion(camera.quaternion);
camera.updateMatrixWorld();
} else if (usingDrag) {
const e = new THREE.Euler(dragPitch, dragYaw, 0, 'YXZ');
camera.quaternion.setFromEuler(e);
} else if (gyroQuat) {
camera.quaternion.copy(gyroQuat);
}
// fake parallax sway (non-XR only): ease the camera a touch toward the
// tilt/motion target, applied in view space so it reads as gentle depth.
if (!usingXR) {
parallax.x += (parallax.tx - parallax.x) * Math.min(1, dt * 4);
parallax.y += (parallax.ty - parallax.y) * Math.min(1, dt * 4);
// decay the motion impulse back toward the tilt baseline
parallax.tx *= 0.92; parallax.ty *= 0.92;
const right = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0,1,0).applyQuaternion(camera.quaternion);
camera.position.copy(right.multiplyScalar(parallax.x).add(up.multiplyScalar(parallax.y)));
}
hunt.update(dt, t);
updateBeacons();
checkDetections();
updateArrow();
$('#dbg-active').textContent = hunt.listActive().length;
$('#dbg-haunt').textContent = Math.round(hunt.haunt) + '%';
$('#reticle').classList.toggle('locked', !!currentTarget());
paintHaunt();
renderer.render(scene, camera);
}
function beginLoop(){ renderer.setAnimationLoop(frame); }
</script>
</body>
</html>