655 lines
23 KiB
JavaScript
655 lines
23 KiB
JavaScript
import * as THREE from 'three';
|
||
|
||
/* ============================================================
|
||
Newbury Nights — client game logic
|
||
- Screen routing (title / scan / hunt / roster)
|
||
- QR scan via BarcodeDetector (manual code fallback)
|
||
- AR hunt: camera passthrough + DeviceOrientation gyro look,
|
||
color-wheel gloom detection, blaster with overheat,
|
||
procedural wisp meshes + animated-GIF billboards.
|
||
============================================================ */
|
||
|
||
const $ = (s, r = document) => r.querySelector(s);
|
||
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
||
|
||
const screens = {
|
||
title: $('#screen-title'),
|
||
scan: $('#screen-scan'),
|
||
hunt: $('#screen-hunt'),
|
||
roster: $('#screen-roster'),
|
||
about: $('#screen-about'),
|
||
};
|
||
function show(name) {
|
||
Object.values(screens).forEach((s) => s.classList.add('hidden'));
|
||
screens[name].classList.remove('hidden');
|
||
}
|
||
|
||
/* ---------- Title nav ---------- */
|
||
$('#btn-scan-set').addEventListener('click', () => startScan());
|
||
$('#btn-freehunt').addEventListener('click', () => startHunt({ freeHunt: true }));
|
||
$('#btn-roster').addEventListener('click', () => openRoster());
|
||
$('#btn-about').addEventListener('click', () => show('about'));
|
||
$$('[data-back]').forEach((b) => b.addEventListener('click', () => returnHome()));
|
||
|
||
function returnHome() {
|
||
stopScan();
|
||
hunt.stop();
|
||
show('title');
|
||
}
|
||
|
||
/* ============================================================
|
||
SCAN
|
||
============================================================ */
|
||
let scanStream = null;
|
||
let scanLoop = null;
|
||
let detector = null;
|
||
|
||
async function startScan() {
|
||
show('scan');
|
||
$('#scan-error').classList.add('hidden');
|
||
const status = $('#scan-status');
|
||
const video = $('#scan-video');
|
||
|
||
if ('BarcodeDetector' in window) {
|
||
try {
|
||
const formats = await window.BarcodeDetector.getSupportedFormats();
|
||
if (formats.includes('qr_code')) {
|
||
detector = new window.BarcodeDetector({ formats: ['qr_code'] });
|
||
}
|
||
} catch { detector = null; }
|
||
}
|
||
|
||
try {
|
||
scanStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: 'environment' }, audio: false,
|
||
});
|
||
video.srcObject = scanStream;
|
||
await video.play();
|
||
status.textContent = detector ? 'Looking for a set code…' : 'Camera on — enter code below';
|
||
if (detector) tickScan(video);
|
||
} catch (e) {
|
||
status.textContent = 'Camera unavailable — enter the code below';
|
||
}
|
||
}
|
||
|
||
async function tickScan(video) {
|
||
if (!detector || !scanStream) return;
|
||
try {
|
||
const codes = await detector.detect(video);
|
||
if (codes.length) {
|
||
const raw = codes[0].rawValue?.trim();
|
||
if (raw) { resolveCode(raw); return; }
|
||
}
|
||
} catch { /* keep trying */ }
|
||
scanLoop = requestAnimationFrame(() => tickScan(video));
|
||
}
|
||
|
||
function stopScan() {
|
||
if (scanLoop) cancelAnimationFrame(scanLoop), (scanLoop = null);
|
||
if (scanStream) { scanStream.getTracks().forEach((t) => t.stop()); scanStream = null; }
|
||
}
|
||
|
||
$('#btn-manual-go').addEventListener('click', () => {
|
||
const code = $('#manual-code').value.trim();
|
||
if (code) resolveCode(code);
|
||
});
|
||
$('#manual-code').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') $('#btn-manual-go').click();
|
||
});
|
||
|
||
async function resolveCode(code) {
|
||
// Accept either a bare code or a URL whose last path/query segment is the code.
|
||
const cleaned = code.includes('/') ? code.split(/[\/?=]/).filter(Boolean).pop() : code;
|
||
try {
|
||
const res = await fetch(`/api/scan/${encodeURIComponent(cleaned)}`);
|
||
if (!res.ok) throw new Error('Unknown set code');
|
||
const data = await res.json();
|
||
stopScan();
|
||
startHunt({ setData: data });
|
||
} catch (e) {
|
||
const err = $('#scan-error');
|
||
err.textContent = `Couldn't find a set for "${cleaned}". Check the code and try again.`;
|
||
err.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
HUNT (AR)
|
||
============================================================ */
|
||
const TYPE_COLORS = { red: 0xff3b5c, yellow: 0xffc23b, blue: 0x3bb6ff };
|
||
|
||
// Detect VP9-with-alpha WebM support once. iOS Safari historically lacks
|
||
// reliable VP9-alpha, so those devices fall back to the GIF/WebP <img> path.
|
||
const SUPPORTS_WEBM_ALPHA = (() => {
|
||
try {
|
||
const v = document.createElement('video');
|
||
return !!v.canPlayType && v.canPlayType('video/webm; codecs="vp9"') !== '';
|
||
} catch {
|
||
return false;
|
||
}
|
||
})();
|
||
|
||
const hunt = {
|
||
running: false,
|
||
scene: null, camera: null, renderer: null, raf: null,
|
||
stream: null, video: null,
|
||
ghosts: [], // active ghost objects in the scene
|
||
target: null, // currently locked ghost
|
||
armedType: null, // color-wheel armed type
|
||
battery: 100,
|
||
gloom: 0,
|
||
overheat: 0,
|
||
blasting: false,
|
||
orientation: { alpha: 0, beta: 0, gamma: 0, active: false },
|
||
pool: [], // ghosts available to spawn (from set roster or freehunt)
|
||
boss: null,
|
||
remaining: 0, // spots left to clear (set mode)
|
||
freeHunt: false,
|
||
|
||
async start({ setData, freeHunt }) {
|
||
show('hunt');
|
||
this.running = true;
|
||
this.freeHunt = !!freeHunt;
|
||
this.battery = 100; this.gloom = 0; this.overheat = 0; this.target = null; this.armedType = null;
|
||
this.ghosts = [];
|
||
$('#result').classList.add('hidden');
|
||
$('#lockon').classList.add('hidden');
|
||
this.updateBattery(); this.updateGloom();
|
||
|
||
if (freeHunt) {
|
||
const res = await fetch('/api/freehunt?n=6');
|
||
this.pool = (await res.json()).spawns;
|
||
this.boss = null;
|
||
this.remaining = Infinity;
|
||
} else {
|
||
this.pool = setData.roster.filter((g) => !g.isBoss);
|
||
this.boss = setData.boss;
|
||
this.remaining = Math.min(5, this.pool.length); // spots to clear before boss
|
||
}
|
||
|
||
await this.initCamera();
|
||
this.initScene();
|
||
this.bindControls();
|
||
this.maybePromptMotion();
|
||
this.spawnNext();
|
||
this.loop();
|
||
},
|
||
|
||
// On iOS, surface an explicit "Enable Motion" button. Tapping it is the clean
|
||
// user gesture iOS needs to show its Motion & Orientation Access prompt.
|
||
maybePromptMotion() {
|
||
const overlay = $('#motion-gate');
|
||
if (!this.gyroNeedsPermission()) { overlay.classList.add('hidden'); return; }
|
||
overlay.classList.remove('hidden');
|
||
const btn = $('#motion-enable');
|
||
const onTap = async () => {
|
||
const ok = await this.requestGyro();
|
||
overlay.classList.add('hidden');
|
||
if (!ok) this.toast('Motion blocked — enable it in Settings › Safari', 2200);
|
||
};
|
||
btn.onclick = onTap;
|
||
this._motionEls = { overlay, btn };
|
||
},
|
||
|
||
async initCamera() {
|
||
this.video = $('#ar-video');
|
||
try {
|
||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: 'environment' }, audio: false,
|
||
});
|
||
this.video.srcObject = this.stream;
|
||
await this.video.play();
|
||
} catch { /* no camera → dark backdrop, gyro still works */ }
|
||
},
|
||
|
||
initScene() {
|
||
const canvas = $('#ar-canvas');
|
||
this.renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||
this.renderer.setSize(innerWidth, innerHeight);
|
||
|
||
this.scene = new THREE.Scene();
|
||
this.camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 100);
|
||
this.camera.position.set(0, 0, 0);
|
||
|
||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
||
const p = new THREE.PointLight(0x88aaff, 1.2, 50);
|
||
p.position.set(0, 2, 2);
|
||
this.scene.add(p);
|
||
|
||
this._onResize = () => {
|
||
this.camera.aspect = innerWidth / innerHeight;
|
||
this.camera.updateProjectionMatrix();
|
||
this.renderer.setSize(innerWidth, innerHeight);
|
||
};
|
||
addEventListener('resize', this._onResize);
|
||
|
||
// Gyro look. iOS Safari requires DeviceOrientationEvent.requestPermission()
|
||
// from an explicit user gesture (handled by the "Enable Motion" button in
|
||
// start()). Other browsers can attach the listener immediately.
|
||
this._onOrient = (e) => {
|
||
if (e.alpha == null) return;
|
||
this.orientation = { alpha: e.alpha, beta: e.beta, gamma: e.gamma, active: true };
|
||
};
|
||
if (!this.gyroNeedsPermission()) {
|
||
addEventListener('deviceorientation', this._onOrient);
|
||
this._gyroAttached = true;
|
||
}
|
||
},
|
||
|
||
gyroNeedsPermission() {
|
||
const DOE = window.DeviceOrientationEvent;
|
||
return !!(DOE && typeof DOE.requestPermission === 'function');
|
||
},
|
||
|
||
// Called from a clean tap (the Enable Motion overlay). On iOS this triggers
|
||
// the system "Motion & Orientation Access" prompt; once granted we attach the
|
||
// listener. Safe to call more than once.
|
||
async requestGyro() {
|
||
if (this._gyroAttached) return true;
|
||
const DOE = window.DeviceOrientationEvent;
|
||
try {
|
||
if (DOE && typeof DOE.requestPermission === 'function') {
|
||
const res = await DOE.requestPermission();
|
||
if (res !== 'granted') return false;
|
||
}
|
||
addEventListener('deviceorientation', this._onOrient);
|
||
this._gyroAttached = true;
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
|
||
bindControls() {
|
||
// color wheel
|
||
$$('.wheel-seg').forEach((seg) => {
|
||
seg.onclick = () => {
|
||
const type = seg.dataset.type;
|
||
this.armedType = type;
|
||
$$('.wheel-seg').forEach((s) => s.classList.remove('armed'));
|
||
seg.classList.add('armed');
|
||
$('#wheel-core').textContent = type.toUpperCase();
|
||
this.scanGloom(type);
|
||
};
|
||
});
|
||
|
||
const blast = $('#btn-blast');
|
||
const startBlast = (e) => {
|
||
e.preventDefault();
|
||
this.blasting = true;
|
||
};
|
||
const endBlast = () => { this.blasting = false; };
|
||
blast.addEventListener('touchstart', startBlast, { passive: false });
|
||
blast.addEventListener('mousedown', startBlast);
|
||
addEventListener('touchend', endBlast);
|
||
addEventListener('mouseup', endBlast);
|
||
this._blastEls = { blast, startBlast, endBlast };
|
||
},
|
||
|
||
/* spawn a wisp/billboard ghost at a random spot around the player */
|
||
spawnNext() {
|
||
if (!this.pool.length) return;
|
||
const data = this.pool[Math.floor(Math.random() * this.pool.length)];
|
||
this.addGhost(data);
|
||
},
|
||
|
||
addGhost(data, isBoss = false) {
|
||
const group = new THREE.Group();
|
||
const color = TYPE_COLORS[data.type] ?? 0xffffff;
|
||
|
||
let mesh;
|
||
let texture = null;
|
||
if (data.webm && SUPPORTS_WEBM_ALPHA) {
|
||
// WebM (VP9+alpha) billboard via VideoTexture. The browser decodes and
|
||
// updates the texture itself — no per-frame needsUpdate pumping needed.
|
||
const vid = document.createElement('video');
|
||
vid.crossOrigin = 'anonymous';
|
||
vid.muted = true; // required for autoplay
|
||
vid.loop = true;
|
||
vid.playsInline = true; // iOS: stay inline, don't fullscreen
|
||
vid.autoplay = true;
|
||
vid.preload = 'auto';
|
||
vid.src = data.webm;
|
||
texture = new THREE.VideoTexture(vid);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.minFilter = THREE.LinearFilter;
|
||
texture.magFilter = THREE.LinearFilter;
|
||
texture.generateMipmaps = false;
|
||
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, depthWrite: false });
|
||
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), mat);
|
||
// If the video errors (e.g. alpha unsupported despite canPlayType), swap
|
||
// to the GIF/image billboard if we have one.
|
||
vid.onerror = () => {
|
||
if (group.userData.videoFellBack) return;
|
||
group.userData.videoFellBack = true;
|
||
if (data.image) {
|
||
const img = document.createElement('img');
|
||
img.crossOrigin = 'anonymous';
|
||
img.src = data.image;
|
||
const t2 = new THREE.Texture(img);
|
||
img.onload = () => { t2.needsUpdate = true; };
|
||
mesh.material.map = t2;
|
||
mesh.material.needsUpdate = true;
|
||
group.userData.gifImg = img;
|
||
group.userData.gifTex = t2;
|
||
group.userData.vidEl = null;
|
||
}
|
||
};
|
||
const pr = vid.play();
|
||
if (pr && pr.catch) pr.catch(() => {});
|
||
group.userData.vidEl = vid;
|
||
} else if (data.image) {
|
||
// animated GIF billboard — texture.needsUpdate pumped each frame
|
||
const img = document.createElement('img');
|
||
img.crossOrigin = 'anonymous';
|
||
img.src = data.image;
|
||
texture = new THREE.Texture(img);
|
||
img.onload = () => { texture.needsUpdate = true; };
|
||
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide });
|
||
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), mat);
|
||
group.userData.gifImg = img;
|
||
group.userData.gifTex = texture;
|
||
} else {
|
||
// procedural wisp: glowing sphere + halo
|
||
const geo = new THREE.SphereGeometry(0.45, 24, 24);
|
||
const mat = new THREE.MeshStandardMaterial({
|
||
color, emissive: color, emissiveIntensity: 1.4, roughness: 0.4,
|
||
transparent: true, opacity: 0.92,
|
||
});
|
||
mesh = new THREE.Mesh(geo, mat);
|
||
const halo = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.62, 24, 24),
|
||
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.16, side: THREE.BackSide })
|
||
);
|
||
group.add(halo);
|
||
}
|
||
group.add(mesh);
|
||
|
||
// Place in a forward-facing arc so ghosts are findable without perfect
|
||
// aiming: yaw within ±60° of straight ahead, modest pitch, closer in.
|
||
const yaw = (Math.random() - 0.5) * (Math.PI * 2 / 3);
|
||
const pitch = (Math.random() - 0.5) * 0.5;
|
||
const dist = 3 + Math.random() * 1.5;
|
||
group.position.set(
|
||
Math.sin(yaw) * dist,
|
||
Math.sin(pitch) * dist,
|
||
-Math.cos(yaw) * dist
|
||
);
|
||
|
||
group.userData = {
|
||
...group.userData,
|
||
data, isBoss,
|
||
hp: data.health, maxHp: data.health,
|
||
bobPhase: Math.random() * Math.PI * 2,
|
||
revealed: true, // visible on spawn; the wheel now aggros/tints rather than uncloaks
|
||
};
|
||
this.scene.add(group);
|
||
this.ghosts.push(group);
|
||
},
|
||
|
||
scanGloom(type) {
|
||
// Ghosts are visible on spawn now, so the wheel acts as a "lure": scanning a
|
||
// colour pulls the nearest matching ghost toward your aim and charges gloom.
|
||
// Mismatched colour risks a jumpscare.
|
||
const matches = this.ghosts.filter((g) => g.userData.data.type === type);
|
||
if (matches.length) {
|
||
// pull the nearest match into a forward, easy-to-aim position
|
||
const g = matches[0];
|
||
const dist = 3;
|
||
g.position.set((Math.random() - 0.5) * 1.2, (Math.random() - 0.3) * 0.8, -dist);
|
||
g.userData.lured = true;
|
||
this.gloom += 5; this.updateGloom();
|
||
this.toast(`${type.toUpperCase()} ghost lured in`, 800);
|
||
} else {
|
||
// no ghost of that colour present — small chance of a jumpscare
|
||
if (Math.random() < 0.12) this.jumpscare();
|
||
else { this.gloom += 2; this.updateGloom(); this.toast('+2 gloom', 600); }
|
||
}
|
||
},
|
||
|
||
loop() {
|
||
if (!this.running) return;
|
||
this.raf = requestAnimationFrame(() => this.loop());
|
||
const t = performance.now() / 1000;
|
||
|
||
// camera yaw/pitch from gyro
|
||
if (this.orientation.active) {
|
||
const { alpha, beta, gamma } = this.orientation;
|
||
// simple mapping; good enough for look-around aiming
|
||
this.camera.rotation.set(
|
||
THREE.MathUtils.degToRad((beta ?? 0) - 90),
|
||
THREE.MathUtils.degToRad(alpha ?? 0),
|
||
THREE.MathUtils.degToRad(-(gamma ?? 0)),
|
||
'YXZ'
|
||
);
|
||
}
|
||
|
||
// animate ghosts: bob + face camera + pump GIF textures
|
||
for (const g of this.ghosts) {
|
||
g.position.y += Math.sin(t * 1.5 + g.userData.bobPhase) * 0.0025;
|
||
g.lookAt(this.camera.position);
|
||
if (g.userData.gifTex && g.userData.gifImg?.complete) g.userData.gifTex.needsUpdate = true;
|
||
}
|
||
|
||
// targeting: ghost nearest screen-center (and revealed) becomes the lock-on
|
||
this.updateTarget();
|
||
|
||
// blaster
|
||
if (this.blasting && this.overheat < 100 && this.target) {
|
||
const dmg = 0.9; // per-frame chip damage
|
||
this.target.userData.hp -= dmg;
|
||
this.overheat = Math.min(100, this.overheat + 0.8);
|
||
this.updateGhostHp();
|
||
if (this.target.userData.hp <= 0) this.captureTarget();
|
||
} else if (!this.blasting && this.overheat > 0) {
|
||
this.overheat = Math.max(0, this.overheat - 1.2); // cool down
|
||
}
|
||
if (this.overheat >= 100) this.blasting = false;
|
||
this.updateOverheat();
|
||
|
||
// ghosts attack: drain battery if a revealed ghost is in front of you
|
||
this.maybeTakeDamage();
|
||
|
||
this.renderer.render(this.scene, this.camera);
|
||
},
|
||
|
||
updateTarget() {
|
||
const center = new THREE.Vector2(0, 0);
|
||
let best = null, bestDist = 0.5; // within ~half-screen of center
|
||
const v = new THREE.Vector3();
|
||
for (const g of this.ghosts) {
|
||
if (!g.userData.revealed) continue;
|
||
v.copy(g.position).project(this.camera);
|
||
if (v.z > 1) continue; // behind camera
|
||
const d = Math.hypot(v.x - center.x, v.y - center.y);
|
||
if (d < bestDist) { bestDist = d; best = g; }
|
||
}
|
||
if (best !== this.target) {
|
||
this.target = best;
|
||
this.renderLockon();
|
||
}
|
||
},
|
||
|
||
renderLockon() {
|
||
const lock = $('#lockon');
|
||
if (!this.target) { lock.classList.add('hidden'); return; }
|
||
const d = this.target.userData.data;
|
||
lock.classList.remove('hidden');
|
||
// IMPORTANT: label reads displayName, not a hardcoded tint name
|
||
$('#lockon-name').textContent = d.displayName || d.name;
|
||
$('#lockon-name').className = `lockon-name display type-${d.type}`;
|
||
$('#lockon-type').textContent = d.type;
|
||
$('#lockon-rarity').textContent = '★'.repeat(d.rarity);
|
||
$('#lockon-ability').textContent = d.ability || '—';
|
||
this.updateGhostHp();
|
||
},
|
||
|
||
updateGhostHp() {
|
||
if (!this.target) return;
|
||
const pct = Math.max(0, (this.target.userData.hp / this.target.userData.maxHp) * 100);
|
||
$('#ghost-hp-fill').style.width = `${pct}%`;
|
||
},
|
||
|
||
captureTarget() {
|
||
const g = this.target;
|
||
const d = g.userData.data;
|
||
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
|
||
this.scene.remove(g);
|
||
this.ghosts = this.ghosts.filter((x) => x !== g);
|
||
this.target = null;
|
||
$('#lockon').classList.add('hidden');
|
||
this.gloom += 10 + d.rarity * 5;
|
||
this.updateGloom();
|
||
this.toast(`Captured ${d.displayName || d.name}!`, 900);
|
||
|
||
if (g.userData.isBoss) { this.win(d); return; }
|
||
|
||
if (!this.freeHunt) {
|
||
this.remaining -= 1;
|
||
if (this.remaining <= 0 && this.boss) {
|
||
// soul artifact → boss appears
|
||
this.toast('A Soul Artifact pulses…', 1100);
|
||
setTimeout(() => this.addGhost(this.boss, true), 1100);
|
||
return;
|
||
}
|
||
}
|
||
// respawn another ghost to keep the hunt going
|
||
setTimeout(() => this.spawnNext(), 600);
|
||
},
|
||
|
||
maybeTakeDamage() {
|
||
// a revealed ghost roughly centered + we're not blasting → it glooms us
|
||
if (this.blasting) return;
|
||
if (!this.target) return;
|
||
if (Math.random() < 0.004) {
|
||
const dmg = this.target.userData.data.rarity * 1.5; // 4★ hurts most
|
||
this.battery = Math.max(0, this.battery - dmg);
|
||
this.updateBattery();
|
||
this.flashDamage();
|
||
if (this.battery <= 0) this.lose();
|
||
}
|
||
},
|
||
|
||
/* ---- HUD updates ---- */
|
||
updateBattery() {
|
||
const fill = $('#battery-fill');
|
||
fill.style.width = `${this.battery}%`;
|
||
fill.classList.toggle('low', this.battery <= 30);
|
||
$('#battery-pct').textContent = `${Math.round(this.battery)}%`;
|
||
},
|
||
updateGloom() { $('#gloom-count').textContent = this.gloom; },
|
||
updateOverheat() {
|
||
$('#overheat-fill').style.width = `${this.overheat}%`;
|
||
$('#btn-blast').classList.toggle('overheated', this.overheat >= 100);
|
||
$('#btn-blast').textContent = this.overheat >= 100 ? 'OVERHEATED' : 'HOLD TO BLAST';
|
||
},
|
||
|
||
toast(msg, ms = 800, jump = false) {
|
||
const t = $('#toast');
|
||
t.textContent = msg;
|
||
t.className = `toast${jump ? ' jumpscare' : ''}`;
|
||
clearTimeout(this._toastT);
|
||
this._toastT = setTimeout(() => t.classList.add('hidden'), ms);
|
||
},
|
||
jumpscare() {
|
||
this.toast('BOO!', 700, true);
|
||
this.battery = Math.max(0, this.battery - 4);
|
||
this.updateBattery();
|
||
if (navigator.vibrate) navigator.vibrate(120);
|
||
},
|
||
flashDamage() {
|
||
if (navigator.vibrate) navigator.vibrate(40);
|
||
document.body.animate(
|
||
[{ boxShadow: 'inset 0 0 0 9999px rgba(255,59,92,.25)' }, { boxShadow: 'inset 0 0 0 9999px rgba(255,59,92,0)' }],
|
||
{ duration: 250 }
|
||
);
|
||
},
|
||
|
||
win(boss) {
|
||
$('#result-title').textContent = 'Set Cleared';
|
||
$('#result-body').textContent = `You captured ${boss.displayName || boss.name} and drove the gloom out. Gloom banked: ${this.gloom}.`;
|
||
$('#result').classList.remove('hidden');
|
||
this.running = false;
|
||
},
|
||
lose() {
|
||
$('#result-title').textContent = 'Battery Dead';
|
||
$('#result-body').textContent = 'The detector went dark and the ghosts slipped away. Recharge and try again.';
|
||
$('#result').classList.remove('hidden');
|
||
this.running = false;
|
||
},
|
||
|
||
stop() {
|
||
this.running = false;
|
||
if (this.raf) cancelAnimationFrame(this.raf);
|
||
if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; }
|
||
if (this._onResize) removeEventListener('resize', this._onResize);
|
||
if (this._onOrient && this._gyroAttached) {
|
||
removeEventListener('deviceorientation', this._onOrient);
|
||
this._gyroAttached = false;
|
||
}
|
||
if (this._motionEls) { this._motionEls.overlay.classList.add('hidden'); }
|
||
if (this._blastEls) {
|
||
removeEventListener('touchend', this._blastEls.endBlast);
|
||
removeEventListener('mouseup', this._blastEls.endBlast);
|
||
}
|
||
if (this.renderer) { this.renderer.dispose?.(); }
|
||
for (const g of this.ghosts) {
|
||
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
|
||
}
|
||
this.ghosts = []; this.target = null;
|
||
},
|
||
};
|
||
|
||
function startHunt(opts) { hunt.start(opts); }
|
||
|
||
/* ============================================================
|
||
ROSTER / GHOST INDEX
|
||
============================================================ */
|
||
async function openRoster() {
|
||
show('roster');
|
||
await loadRoster();
|
||
}
|
||
['#filter-type', '#filter-rarity', '#filter-boss'].forEach((sel) =>
|
||
$(sel).addEventListener('change', loadRoster)
|
||
);
|
||
|
||
async function loadRoster() {
|
||
const type = $('#filter-type').value;
|
||
const rarity = $('#filter-rarity').value;
|
||
const boss = $('#filter-boss').checked ? '1' : '';
|
||
const params = new URLSearchParams();
|
||
if (type) params.set('type', type);
|
||
if (rarity) params.set('rarity', rarity);
|
||
if (boss) params.set('boss', boss);
|
||
const res = await fetch(`/api/ghosts?${params}`);
|
||
const { ghosts } = await res.json();
|
||
const grid = $('#roster-grid');
|
||
grid.innerHTML = ghosts.map(ghostCard).join('') ||
|
||
'<p class="muted">No ghosts match those filters.</p>';
|
||
}
|
||
|
||
function ghostCard(g) {
|
||
const colorVar = `var(--${g.type})`;
|
||
const thumb = g.image
|
||
? `<img class="ghost-thumb" src="${g.image}" alt="${escapeHtml(g.displayName)}" loading="lazy">`
|
||
: '';
|
||
const setRef = g.setNumber ? `<div class="set-ref">Set ${g.setNumber} · ${escapeHtml(g.setName || '')}</div>` : '';
|
||
return `
|
||
<div class="ghost-card ${g.isBoss ? 'boss' : ''}">
|
||
<div class="accent" style="background:${colorVar}"></div>
|
||
${thumb}
|
||
<div class="gname">${escapeHtml(g.displayName)} ${g.isBoss ? '<span class="boss-badge">BOSS</span>' : ''}</div>
|
||
<div class="meta"><span class="dot ${g.type}"></span> <span class="stars">${'★'.repeat(g.rarity)}</span></div>
|
||
<div class="statline"><span>HP ${g.health}</span><span>DMG ${g.damage}</span></div>
|
||
<div class="statline"><span>SPD ${g.speed}</span><span>RNG ${g.range}</span></div>
|
||
${g.ability ? `<div class="set-ref" style="margin-top:6px">⚡ ${escapeHtml(g.ability)}</div>` : ''}
|
||
${setRef}
|
||
</div>`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|