544 lines
19 KiB
JavaScript
544 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'),
|
|
};
|
|
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('') ||
|
|
'<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) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|