Files
newbury-nights/public/hunt.html
T

333 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Newbury Nights - Hunt</title>
<style>
:root {
--ink: #0a0c14;
--ink-2: #11131f;
--mist: #c7ccd9;
--mist-dim: #6b7184;
--red: #FF2678;
--red-top: #F65151;
--yellow: #FFF35D;
--yellow-top: #B57F0B;
--blue: #51EAF1;
--blue-top: #529EFF;
--haze: rgba(199,204,217,0.08);
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0; height: 100%; overflow: hidden;
background: var(--ink); color: var(--mist);
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;
user-select: none;
}
#scene, #cam { position: fixed; inset: 0; width: 100%; height: 100%; }
#cam { object-fit: cover; z-index: 0; background: #000; }
#scene { z-index: 1; }
canvas { display: block; }
#hud { position: fixed; inset: 0; z-index: 3; pointer-events: none; }
.pointer { pointer-events: auto; }
#haunt {
position: fixed; left: 0; top: 0; bottom: 0; width: 8px; z-index: 4;
}
#haunt-fill {
position: absolute; left: 0; right: 0; bottom: 0; height: 0%;
background: linear-gradient(0deg, var(--blue), #fff2);
box-shadow: 0 0 18px var(--blue); transition: height 0.4s ease, background 0.6s, box-shadow 0.6s;
}
#haunt-label {
position: fixed; left: 14px; top: 14px; z-index: 5;
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--mist-dim);
}
#haunt-pct { color: var(--mist); font-size: 13px; }
#readout {
position: fixed; right: 14px; top: 14px; z-index: 5; text-align: right;
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mist-dim);
}
#readout b { color: var(--mist); font-size: 15px; letter-spacing: 0; }
#reticle {
position: fixed; left: 50%; top: 50%; width: 64px; height: 64px;
transform: translate(-50%, -50%); z-index: 4; pointer-events: none;
}
#reticle .ring {
position: absolute; inset: 0; border: 1.5px solid var(--haze); border-radius: 50%;
}
#reticle .charge {
position: absolute; inset: 4px; border-radius: 50%;
background: conic-gradient(var(--blue) 0deg, transparent 0deg);
opacity: 0.85;
}
#reticle .dot { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px;
transform: translate(-50%,-50%); background: var(--mist); border-radius: 50%; }
#reticle.locked .ring { border-color: var(--mist); }
#wheel {
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
z-index: 5; display: flex; gap: 14px; pointer-events: auto;
}
.lure {
width: 52px; height: 52px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.14);
background: var(--c); position: relative; cursor: pointer;
transition: transform 0.15s, box-shadow 0.2s, border-color 0.2s; opacity: 0.55;
}
.lure[data-on="1"] { opacity: 1; transform: scale(1.12);
border-color: #fff; box-shadow: 0 0 22px var(--c); }
.lure.red { --c: var(--red); }
.lure.blue { --c: var(--blue); }
.lure.yellow { --c: var(--yellow); }
#wheel-label { position: fixed; left: 50%; bottom: 88px; transform: translateX(-50%);
z-index: 5; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
color: var(--mist-dim); }
#fire {
position: fixed; right: 22px; bottom: 30px; z-index: 6;
width: 84px; height: 84px; border-radius: 50%;
background: radial-gradient(circle at 50% 40%, var(--ink-2), #05060c);
border: 2px solid rgba(255,255,255,0.16); color: var(--mist);
font: inherit; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
pointer-events: auto; cursor: pointer; touch-action: none;
}
#fire:active { border-color: #fff; }
#toasts { position: fixed; left: 50%; top: 64px; transform: translateX(-50%);
z-index: 6; display: flex; flex-direction: column; gap: 6px; align-items: center; }
.toast { font-size: 12px; letter-spacing: 0.06em; padding: 6px 12px;
background: rgba(10,12,20,0.7); border: 1px solid var(--haze); border-radius: 2px;
color: var(--mist); animation: rise 2.4s forwards; }
.toast .nm { color: #fff; }
@keyframes rise { 0% { opacity: 0; transform: translateY(6px);} 12%{opacity:1;transform:none;}
80%{opacity:1;} 100%{opacity:0;} }
#start { position: fixed; inset: 0; z-index: 10; background:
radial-gradient(120% 80% at 50% 0%, #14182a 0%, var(--ink) 60%);
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px; }
#start h1 { font-size: clamp(30px, 9vw, 52px); margin: 0; letter-spacing: -0.01em;
font-weight: 600; color: #fff; line-height: 0.98; }
#start h1 .n2 { display: block; color: var(--blue); font-style: italic; }
#start p { max-width: 30ch; color: var(--mist-dim); font-size: 13px; line-height: 1.6;
letter-spacing: 0.02em; }
#start .sub { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
color: var(--mist-dim); margin-bottom: 18px; }
.btn { margin-top: 22px; pointer-events: auto; cursor: pointer; font: inherit;
font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase;
padding: 14px 28px; color: var(--ink); background: var(--mist);
border: none; border-radius: 2px; }
.btn.ghost { background: transparent; color: var(--mist); border: 1px solid var(--haze);
margin-top: 10px; }
#note { position: fixed; bottom: 8px; left: 0; right: 0; text-align: center; z-index: 10;
font-size: 9px; letter-spacing: 0.14em; color: #3a3f4f; }
@media (prefers-reduced-motion: reduce) { .toast { animation-duration: 0.1s; } }
</style>
</head>
<body>
<video id="cam" autoplay muted playsinline></video>
<div id="scene"></div>
<div id="hud">
<div id="haunt"><div id="haunt-fill"></div></div>
<div id="haunt-label">Haunt <span id="haunt-pct">0%</span></div>
<div id="readout"><b id="caught">0</b><br>caught &middot; <span id="score">0</span> pts</div>
<div id="reticle"><div class="ring"></div><div class="charge"></div><div class="dot"></div></div>
<div id="wheel-label">Lure</div>
<div id="wheel" class="pointer">
<div class="lure red" data-color="Red"></div>
<div class="lure blue" data-color="Blue"></div>
<div class="lure yellow" data-color="Yellow"></div>
</div>
<button id="fire">Hold<br>to charge</button>
<div id="toasts"></div>
</div>
<div id="start">
<div class="sub">A spiritual successor - fan tribute</div>
<h1>Newbury<span class="n2">Nights</span></h1>
<p>Raise your phone to the room. Spirits gather in the dark - pick a lure colour,
aim, and charge your shot to catch them before the haunt overtakes the night.</p>
<button class="btn" id="go-ar">Begin hunt (AR)</button>
<button class="btn ghost" id="go-gyro">No AR - use motion</button>
</div>
<div id="note">Fan-made tribute. Not affiliated with or endorsed by the LEGO Group.</div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { createGhostVisual } from '/ghost-visual.js';
import { createHunt } from '/hunt.js';
const $ = s => document.querySelector(s);
let roster = [];
try {
roster = await (await fetch('/ghosts.enriched.json')).json();
} catch (e) {
roster = [
{ id:'basic-r', name:'Wisp', color:'Red', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
{ id:'basic-b', name:'Wisp', color:'Blue', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
{ id:'basic-y', name:'Wisp', color:'Yellow', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
];
}
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
$('#scene').appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 50);
scene.add(new THREE.HemisphereLight(0x8899ff, 0x111122, 1.1));
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
});
const hunt = createHunt(THREE, scene, {
roster, fx: true,
onEvent: (e) => {
if (e.type === 'capture') toast(`Caught <span class="nm">${e.ghost.name}</span> +${e.score}`);
if (e.type === 'flee') toast(`<span class="nm">${e.ghost.name}</span> slipped away`);
if (e.type === 'capture' || e.type === 'spawn' || e.type === 'flee') {
score = scoreFromCaptures();
$('#caught').textContent = hunt.captured.length;
$('#score').textContent = score;
}
}
});
const RARITY_SCORE = { Common:10, Rare:25, Epic:60, Legendary:150 };
let score = 0;
function scoreFromCaptures() {
const byId = Object.fromEntries(roster.map(g => [g.id, g]));
return hunt.captured.reduce((s,id)=> s + (RARITY_SCORE[byId[id]?.rarity]||10), 0);
}
document.querySelectorAll('.lure').forEach(el => {
el.addEventListener('click', () => {
const on = el.dataset.on === '1';
document.querySelectorAll('.lure').forEach(x => x.dataset.on='0');
if (!on) { el.dataset.on='1'; hunt.setLure(el.dataset.color); reticleColor(el.dataset.color); }
else { hunt.setLure(null); reticleColor(null); }
});
});
function reticleColor(c){
const map = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
document.documentElement.style.setProperty('--blue', map[c] || '#51EAF1');
}
function currentTarget() {
const list = hunt.listActive();
let best=null, bestD=Infinity;
for (const a of list) {
const p = a.object3d.position.clone().project(camera);
if (p.z > 1) continue;
const d = Math.hypot(p.x, p.y);
if (d < bestD) { bestD = d; best = a; }
}
return bestD < 0.35 ? best : null;
}
let charging=false, charge=0;
const fireBtn = $('#fire');
const chargeEl = $('#reticle .charge');
function setCharge(v){
charge = Math.max(0, Math.min(1, v));
chargeEl.style.background = `conic-gradient(var(--blue) ${charge*360}deg, transparent ${charge*360}deg)`;
}
const press = (e)=>{ e.preventDefault(); charging=true; };
const release = (e)=>{
e.preventDefault();
if (!charging) return; charging=false;
const t = currentTarget();
if (t) hunt.fireAt(t.id, charge);
setCharge(0);
};
fireBtn.addEventListener('pointerdown', press);
fireBtn.addEventListener('pointerup', release);
fireBtn.addEventListener('pointercancel', release);
function toast(html){
const d = document.createElement('div'); d.className='toast'; d.innerHTML=html;
$('#toasts').appendChild(d); setTimeout(()=>d.remove(), 2400);
}
function paintHaunt(){
const h = hunt.haunt;
$('#haunt-fill').style.height = h + '%';
$('#haunt-pct').textContent = Math.round(h) + '%';
const col = h < 50 ? 'var(--blue)' : h < 80 ? 'var(--yellow)' : 'var(--red)';
$('#haunt-fill').style.background = `linear-gradient(0deg, ${col}, #fff2)`;
}
let usingXR = false, xrSession = null, gyroQuat = null;
async function startAR(){
if (navigator.xr && await navigator.xr.isSessionSupported?.('immersive-ar')) {
try {
xrSession = await navigator.xr.requestSession('immersive-ar', { optionalFeatures:['local-floor','hit-test'] });
renderer.xr.enabled = true;
await renderer.xr.setSession(xrSession);
usingXR = true; beginLoop(); hideStart(); return;
} catch(e){ /* fall through to gyro */ }
}
startGyro();
}
async function startGyro(){
hideStart();
try {
const stream = await navigator.mediaDevices.getUserMedia({ video:{ facingMode:'environment' }, audio:false });
$('#cam').srcObject = stream;
} catch(e){ /* no camera - black bg is fine */ }
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) {
try { await DeviceOrientationEvent.requestPermission(); } catch(e){}
}
gyroQuat = new THREE.Quaternion();
const euler = new THREE.Euler();
const zee = new THREE.Vector3(0,0,1);
const q0 = new THREE.Quaternion();
const q1 = new THREE.Quaternion(-Math.sqrt(0.5),0,0,Math.sqrt(0.5));
addEventListener('deviceorientation', (ev)=>{
const a = THREE.MathUtils.degToRad(ev.alpha||0);
const b = THREE.MathUtils.degToRad(ev.beta||0);
const g = THREE.MathUtils.degToRad(ev.gamma||0);
const orient = THREE.MathUtils.degToRad(screen.orientation?.angle||0);
euler.set(b, a, -g, 'YXZ');
gyroQuat.setFromEuler(euler);
gyroQuat.multiply(q1);
gyroQuat.multiply(q0.setFromAxisAngle(zee, -orient));
}, true);
beginLoop();
}
function hideStart(){ $('#start').style.display='none'; }
$('#go-ar').addEventListener('click', startAR);
$('#go-gyro').addEventListener('click', startGyro);
const clock = new THREE.Clock();
function frame(){
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
if (charging) setCharge(charge + dt * 0.9);
if (!usingXR && gyroQuat) camera.quaternion.copy(gyroQuat);
hunt.update(dt, t);
$('#reticle').classList.toggle('locked', !!currentTarget());
paintHaunt();
renderer.render(scene, camera);
}
function beginLoop(){ renderer.setAnimationLoop(frame); }
</script>
</body>
</html>