From e1f18303a618f7f586a738cf60bfd8981faf8aeb Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 17 Jun 2026 16:39:18 +1000 Subject: [PATCH] Add game client logic (scan, AR hunt, roster) --- public/js/game.js | 543 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 public/js/game.js diff --git a/public/js/game.js b/public/js/game.js new file mode 100644 index 0000000..6804d67 --- /dev/null +++ b/public/js/game.js @@ -0,0 +1,543 @@ +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])); +}