333 lines
13 KiB
HTML
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 · <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>
|