Files
newbury-nights/public/js/game.js
T

546 lines
19 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 };
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.spawnNext();
this.loop();
},
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 needs a permission gesture; we request on first blast/scan tap)
this._onOrient = (e) => {
if (e.alpha == null) return;
this.orientation = { alpha: e.alpha, beta: e.beta, gamma: e.gamma, active: true };
};
addEventListener('deviceorientation', this._onOrient);
},
async requestGyro() {
const DOE = window.DeviceOrientationEvent;
if (DOE && typeof DOE.requestPermission === 'function') {
try { await DOE.requestPermission(); } catch { /* ignore */ }
}
},
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 = async (e) => {
e.preventDefault();
await this.requestGyro();
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.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 at random yaw around the player, slight height + distance
const yaw = Math.random() * Math.PI * 2;
const dist = 4 + Math.random() * 2;
group.position.set(Math.sin(yaw) * dist, (Math.random() - 0.4) * 1.5, -Math.cos(yaw) * dist);
group.userData = {
...group.userData,
data, isBoss,
hp: data.health, maxHp: data.health,
bobPhase: Math.random() * Math.PI * 2,
};
this.scene.add(group);
this.ghosts.push(group);
},
scanGloom(type) {
// "deglooming": reveal/aggro the nearest ghost matching this color.
// Mirrors the source loop: scan a color → uncover a gloom spot of that type.
const match = this.ghosts.find((g) => g.userData.data.type === type && !g.userData.revealed);
if (match) {
match.userData.revealed = true;
this.toast(`${type.toUpperCase()} gloom uncovered`, 700);
} else {
// small chance of a Nagging-Nathan-style jumpscare on an empty scan
if (Math.random() < 0.12) this.jumpscare();
else { this.gloom += 5; this.updateGloom(); this.toast('+5 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;
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) removeEventListener('deviceorientation', this._onOrient);
if (this._blastEls) {
removeEventListener('touchend', this._blastEls.endBlast);
removeEventListener('mouseup', this._blastEls.endBlast);
}
if (this.renderer) { this.renderer.dispose?.(); }
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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}