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

655 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}