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'), }; 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()); $$('[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('') || '

No ghosts match those filters.

'; } function ghostCard(g) { const colorVar = `var(--${g.type})`; const thumb = g.image ? `${escapeHtml(g.displayName)}` : ''; const setRef = g.setNumber ? `
Set ${g.setNumber} · ${escapeHtml(g.setName || '')}
` : ''; return `
${thumb}
${escapeHtml(g.displayName)} ${g.isBoss ? 'BOSS' : ''}
${'★'.repeat(g.rarity)}
HP ${g.health}DMG ${g.damage}
SPD ${g.speed}RNG ${g.range}
${g.ability ? `
⚡ ${escapeHtml(g.ability)}
` : ''} ${setRef}
`; } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }