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 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('') ||
'
No ghosts match those filters.
'; } function ghostCard(g) { const colorVar = `var(--${g.type})`; const thumb = g.image ? `