Phase 3 update: orbit ghosts, parallax, FX, shader fix [build v8]
This commit is contained in:
+31
-6
@@ -65,9 +65,18 @@ function hexToRGB(THREE, hex) { return new THREE.Color(hex); }
|
||||
* Kept cheap: a subdivided cone/teardrop so the gradient + billow read well.
|
||||
*/
|
||||
function buildWispGeometry(THREE) {
|
||||
// teardrop: radius shrinks toward the tail (top in UV space = head)
|
||||
const geo = new THREE.CylinderGeometry(0.0, 0.5, 1.2, 18, 8, true);
|
||||
geo.translate(0, 0.1, 0);
|
||||
// teardrop-ish body: rounded head (sphere-like top) tapering to a wispy tail.
|
||||
// Lathe a profile curve so it reads as a ghost rather than a hard cone.
|
||||
const pts = [];
|
||||
const N = 12;
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = i / N; // 0 = tail bottom, 1 = head top
|
||||
const y = -0.6 + t * 1.2; // height -0.6 .. 0.6
|
||||
// radius: thin pointed tail, bulging rounded head
|
||||
const r = Math.sin(t * Math.PI * 0.92) * 0.42 + t * 0.06;
|
||||
pts.push(new THREE.Vector2(Math.max(0.001, r), y));
|
||||
}
|
||||
const geo = new THREE.LatheGeometry(pts, 20);
|
||||
return geo;
|
||||
}
|
||||
|
||||
@@ -98,6 +107,18 @@ export function createGhostVisual(THREE, opts = {}) {
|
||||
const body = new THREE.Mesh(buildWispGeometry(THREE), mat);
|
||||
group.add(body);
|
||||
|
||||
// --- always-on soft glow halo (procedural, no texture needed) so the ghost
|
||||
// never looks like a bare cone even before FX textures attach ---
|
||||
const haloMat = new THREE.SpriteMaterial({
|
||||
color: hexToRGB(THREE, grad.bottom),
|
||||
transparent: true, opacity: 0.35, depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const halo = new THREE.Sprite(haloMat);
|
||||
halo.scale.setScalar(1.5);
|
||||
halo.position.y = 0.1;
|
||||
group.add(halo);
|
||||
|
||||
// --- Tier 1: FX billboards (progressive enhancement) ---
|
||||
const fxSprites = [];
|
||||
function addFxBillboard(texture, scale, yOffset, opacity, blend) {
|
||||
@@ -121,8 +142,10 @@ export function createGhostVisual(THREE, opts = {}) {
|
||||
let fxAttached = false;
|
||||
function attachFx(textures) {
|
||||
if (!wantFx || fxAttached || !textures) return;
|
||||
if (textures.glow) addFxBillboard(textures.glow, 1.6, 0.1, 0.5);
|
||||
if (textures.trail) addFxBillboard(textures.trail, 1.2, -0.4, 0.35);
|
||||
// give the procedural halo a real soft-glow texture if provided
|
||||
if (textures.glow) { haloMat.map = textures.glow; haloMat.opacity = 0.55; haloMat.needsUpdate = true; }
|
||||
if (textures.trail) addFxBillboard(textures.trail, 1.0, -0.45, 0.4);
|
||||
if (textures.smoke) addFxBillboard(textures.smoke, 0.8, 0.3, 0.25);
|
||||
fxAttached = true;
|
||||
}
|
||||
|
||||
@@ -150,6 +173,7 @@ export function createGhostVisual(THREE, opts = {}) {
|
||||
// stays fixed (previously this accumulated onto group.y and the ghost
|
||||
// drifted up out of view every frame).
|
||||
body.position.y = Math.sin(elapsed * 1.8) * 0.08;
|
||||
halo.position.y = 0.1 + Math.sin(elapsed * 1.8) * 0.08; // halo bobs with body
|
||||
// billboard FX face camera handled by Sprite automatically
|
||||
if (state === 'spawn') {
|
||||
const k = Math.min(stateT / 0.6, 1);
|
||||
@@ -178,10 +202,11 @@ export function createGhostVisual(THREE, opts = {}) {
|
||||
const g2 = GHOST_GRADIENTS[next]; if (!g2) return;
|
||||
uniforms.uTop.value.set(g2.top);
|
||||
uniforms.uBottom.value.set(g2.bottom);
|
||||
haloMat.color.set(g2.bottom);
|
||||
fxSprites.forEach(s => s.material.color.set(g2.top));
|
||||
},
|
||||
dispose() {
|
||||
body.geometry?.dispose?.(); mat.dispose?.();
|
||||
body.geometry?.dispose?.(); mat.dispose?.(); haloMat.dispose?.();
|
||||
fxSprites.forEach(s => s.material?.map?.dispose?.());
|
||||
},
|
||||
};
|
||||
|
||||
+281
-8
@@ -3,7 +3,7 @@
|
||||
<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>
|
||||
<title>Newbury Nights - Hunt Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #0a0c14;
|
||||
@@ -126,6 +126,35 @@
|
||||
#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; } }
|
||||
|
||||
/* ---- debug overlay ---- */
|
||||
#dbg-toggle { position: fixed; left: 14px; bottom: 14px; z-index: 7; pointer-events: auto;
|
||||
font: inherit; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
background: rgba(10,12,20,0.7); color: var(--mist-dim); border: 1px solid var(--haze);
|
||||
border-radius: 2px; padding: 8px 10px; cursor: pointer; }
|
||||
#dbg { position: fixed; left: 14px; bottom: 52px; z-index: 7; width: min(320px, 86vw);
|
||||
max-height: 46vh; background: rgba(8,10,16,0.92); border: 1px solid var(--haze);
|
||||
border-radius: 4px; padding: 10px; display: none; pointer-events: auto;
|
||||
font-size: 10px; line-height: 1.5; color: var(--mist); }
|
||||
#dbg.open { display: block; }
|
||||
#dbg .row { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px;
|
||||
color: var(--mist-dim); }
|
||||
#dbg .row b { color: var(--mist); }
|
||||
#dbg .ctl { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
#dbg .ctl button { font: inherit; font-size: 12px; width: 24px; height: 24px;
|
||||
background: var(--ink-2); color: var(--mist); border: 1px solid var(--haze);
|
||||
border-radius: 2px; cursor: pointer; }
|
||||
#dbg-log { max-height: 28vh; overflow-y: auto; border-top: 1px solid var(--haze);
|
||||
padding-top: 6px; font-family: ui-monospace, monospace; }
|
||||
#dbg-log div { white-space: nowrap; }
|
||||
#dbg-log .spawn { color: var(--yellow); }
|
||||
#dbg-log .detect { color: var(--blue); }
|
||||
#dbg-log .capture { color: #7CFFA0; }
|
||||
#dbg-log .flee { color: var(--red); }
|
||||
|
||||
/* off-screen direction arrow */
|
||||
#arrow { position: fixed; z-index: 6; width: 0; height: 0; pointer-events: none;
|
||||
display: none; filter: drop-shadow(0 0 6px var(--c, var(--blue))); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -149,6 +178,22 @@
|
||||
<div id="toasts"></div>
|
||||
</div>
|
||||
|
||||
<!-- off-screen ghost direction arrow -->
|
||||
<svg id="arrow" width="40" height="40" viewBox="0 0 40 40"><path d="M20 4 L34 32 L20 24 L6 32 Z" fill="var(--c, #51EAF1)"/></svg>
|
||||
|
||||
<!-- debug overlay -->
|
||||
<button id="dbg-toggle">Debug</button>
|
||||
<div id="dbg">
|
||||
<div class="row"><span>active / max</span><b><span id="dbg-active">0</span> / <span id="dbg-max">4</span></b></div>
|
||||
<div class="ctl"><span style="color:var(--mist-dim)">max active</span>
|
||||
<button id="dbg-minus">-</button><b id="dbg-maxval">4</b><button id="dbg-plus">+</button>
|
||||
<label style="margin-left:auto;color:var(--mist-dim)"><input type="checkbox" id="dbg-beacon" checked> beacons</label>
|
||||
</div>
|
||||
<div class="row"><span>haunt</span><b id="dbg-haunt">0%</b></div>
|
||||
<div class="row"><span>server log</span><b id="dbg-srv">off</b></div>
|
||||
<div id="dbg-log"></div>
|
||||
</div>
|
||||
|
||||
<div id="start">
|
||||
<div class="sub">A spiritual successor - fan tribute</div>
|
||||
<h1>Newbury<span class="n2">Nights</span></h1>
|
||||
@@ -158,6 +203,7 @@
|
||||
<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>
|
||||
<div id="ver" style="position:fixed;right:6px;bottom:6px;z-index:11;font-size:9px;color:#2e3340;letter-spacing:0.1em;">build v8</div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
@@ -167,13 +213,14 @@
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { createGhostVisual } from '/ghost-visual.js';
|
||||
import { createHunt } from '/hunt.js';
|
||||
import { createGhostVisual } from './ghost-visual.js?v=8';
|
||||
import { createHunt } from './hunt.js?v=8';
|
||||
console.log('%cNewbury Nights hunt — build v8 loaded (orbit ghosts + parallax)', 'color:#51EAF1');
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
let roster = [];
|
||||
try {
|
||||
roster = await (await fetch('/ghosts.enriched.json')).json();
|
||||
roster = await (await fetch('./ghosts.enriched.json?v=8')).json();
|
||||
} catch (e) {
|
||||
roster = [
|
||||
{ id:'basic-r', name:'Wisp', color:'Red', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
|
||||
@@ -191,23 +238,124 @@ 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));
|
||||
|
||||
// ---- load FX textures (glow/trail/smoke) so ghosts look wispy, not like cones ----
|
||||
const texLoader = new THREE.TextureLoader();
|
||||
function loadTex(path){
|
||||
return new Promise(res => texLoader.load(path, t => res(t), undefined, () => res(null)));
|
||||
}
|
||||
const fxTextures = {
|
||||
glow: await loadTex('./ghosts/fx/FX_Glow.png?v=8'),
|
||||
trail: await loadTex('./ghosts/fx/FX_WispyTrail2.png?v=8'),
|
||||
smoke: await loadTex('./ghosts/fx/smokepuff1.png?v=8'),
|
||||
};
|
||||
console.log('FX textures loaded:', Object.fromEntries(Object.entries(fxTextures).map(([k,v])=>[k, !!v])));
|
||||
|
||||
addEventListener('resize', () => {
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
|
||||
});
|
||||
|
||||
const hunt = createHunt(THREE, scene, {
|
||||
roster, fx: true,
|
||||
roster, fx: true, fxTextures,
|
||||
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 === 'spawn') { spawnBeacon(e); dbgLog('spawn', e); }
|
||||
if (e.type === 'capture') { toast(`Caught <span class="nm">${e.ghost.name}</span> +${e.score}`); dbgLog('capture', e); }
|
||||
if (e.type === 'flee') { toast(`<span class="nm">${e.ghost.name}</span> slipped away`); dbgLog('flee', e); }
|
||||
if (e.type === 'capture' || e.type === 'spawn' || e.type === 'flee') {
|
||||
score = scoreFromCaptures();
|
||||
$('#caught').textContent = hunt.captured.length;
|
||||
$('#score').textContent = score;
|
||||
}
|
||||
logToServer(e);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- debug + telemetry ----
|
||||
const SESSION = Math.random().toString(36).slice(2, 8);
|
||||
let serverLog = false; // toggled on if the endpoint answers
|
||||
let serverLogDisabled = false; // turns off after the first failure (e.g. static file server)
|
||||
let beaconsOn = true;
|
||||
const seenInView = new Set(); // ids already "detected" (entered the frustum)
|
||||
const beacons = new Map(); // id -> beacon mesh
|
||||
|
||||
// spawn beacon: a bright, always-visible marker at the spawn point so you can
|
||||
// see WHERE a ghost is even if the wisp itself fails to render.
|
||||
function spawnBeacon(e){
|
||||
if (!beaconsOn) return;
|
||||
// find the matching active ghost's object3d to read its live position
|
||||
const a = hunt.listActive().find(x => x.id === e.id);
|
||||
const pos = a ? a.object3d.position : new THREE.Vector3(e.pos?.x||0, e.pos?.y||0, e.pos?.z||0);
|
||||
const colMap = { Red:0xFF2678, Blue:0x51EAF1, Yellow:0xFFF35D };
|
||||
const col = colMap[e.ghost?.color] || 0xffffff;
|
||||
const grp = new THREE.Group();
|
||||
// bright core
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.12, 12, 12),
|
||||
new THREE.MeshBasicMaterial({ color: col })
|
||||
);
|
||||
// halo ring billboard
|
||||
const ring = new THREE.Sprite(new THREE.SpriteMaterial({ color: col, transparent:true, opacity:0.6 }));
|
||||
ring.scale.setScalar(0.9);
|
||||
grp.add(core); grp.add(ring);
|
||||
grp.position.copy(pos);
|
||||
grp.userData.born = performance.now();
|
||||
scene.add(grp);
|
||||
beacons.set(e.id, grp);
|
||||
}
|
||||
function updateBeacons(){
|
||||
const now = performance.now();
|
||||
for (const [id, g] of beacons) {
|
||||
const age = (now - g.userData.born) / 1000;
|
||||
const pulse = 0.7 + 0.3 * Math.sin(age * 6);
|
||||
g.children[0].scale.setScalar(pulse);
|
||||
g.children[1].material.opacity = 0.5 * pulse;
|
||||
// beacon fades after 3s (ghost should be found by then)
|
||||
if (age > 3) { scene.remove(g); beacons.delete(id); }
|
||||
// also drop the beacon once the ghost is gone
|
||||
if (!hunt.listActive().some(a => a.id === id) && age > 0.5) { scene.remove(g); beacons.delete(id); }
|
||||
}
|
||||
}
|
||||
|
||||
// detection: fire once when a ghost first enters the camera view
|
||||
function checkDetections(){
|
||||
for (const a of hunt.listActive()) {
|
||||
const p = a.object3d.position.clone().project(camera);
|
||||
const inView = p.z < 1 && Math.abs(p.x) < 1 && Math.abs(p.y) < 1;
|
||||
if (inView && !seenInView.has(a.id)) {
|
||||
seenInView.add(a.id);
|
||||
const e = { type:'detect', id:a.id, ghost:a.ghost };
|
||||
dbgLog('detect', e); logToServer(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dbgLogEl = () => $('#dbg-log');
|
||||
function dbgLog(kind, e){
|
||||
const el = dbgLogEl(); if (!el) return;
|
||||
const ts = new Date().toLocaleTimeString().slice(0,8);
|
||||
const nm = e.ghost?.name || '';
|
||||
const extra = e.pos ? ` @${e.pos.x},${e.pos.y},${e.pos.z}` : (e.score ? ` +${e.score}` : '');
|
||||
const line = document.createElement('div');
|
||||
line.className = kind;
|
||||
line.textContent = `${ts} ${kind.toUpperCase().padEnd(7)} ${nm}${extra}`;
|
||||
el.prepend(line);
|
||||
while (el.childNodes.length > 80) el.removeChild(el.lastChild);
|
||||
}
|
||||
|
||||
async function logToServer(e){
|
||||
if (serverLogDisabled) return; // don't spam a server that can't receive POSTs
|
||||
try {
|
||||
const r = await fetch('/api/hunt-log', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ ...e, ghost: e.ghost?.name, color: e.ghost?.color,
|
||||
rarity: e.ghost?.rarity, session: SESSION })
|
||||
});
|
||||
if (r.ok) { if (!serverLog) { serverLog = true; $('#dbg-srv').textContent = 'on'; } }
|
||||
else { serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a (static)'; }
|
||||
} catch(_) {
|
||||
serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a'; // no backend (static demo) — stop trying
|
||||
}
|
||||
}
|
||||
const RARITY_SCORE = { Common:10, Rare:25, Epic:60, Legendary:150 };
|
||||
let score = 0;
|
||||
function scoreFromCaptures() {
|
||||
@@ -293,12 +441,19 @@ async function startGyro(){
|
||||
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) {
|
||||
try { await DeviceOrientationEvent.requestPermission(); } catch(e){}
|
||||
}
|
||||
// iOS requires a separate permission for motion (accelerometer) used by parallax
|
||||
if (typeof DeviceMotionEvent !== 'undefined' && DeviceMotionEvent.requestPermission) {
|
||||
try { await DeviceMotionEvent.requestPermission(); } catch(e){}
|
||||
}
|
||||
gyroQuat = new THREE.Quaternion();
|
||||
let gotGyro = false;
|
||||
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)=>{
|
||||
if (ev.alpha == null && ev.beta == null && ev.gamma == null) return;
|
||||
gotGyro = true;
|
||||
const a = THREE.MathUtils.degToRad(ev.alpha||0);
|
||||
const b = THREE.MathUtils.degToRad(ev.beta||0);
|
||||
const g = THREE.MathUtils.degToRad(ev.gamma||0);
|
||||
@@ -308,20 +463,138 @@ async function startGyro(){
|
||||
gyroQuat.multiply(q1);
|
||||
gyroQuat.multiply(q0.setFromAxisAngle(zee, -orient));
|
||||
}, true);
|
||||
// if no gyro data arrives within 1s (desktop / no sensor), enable drag-look
|
||||
setTimeout(() => { if (!gotGyro) enableDragLook(); }, 1000);
|
||||
enableParallax();
|
||||
beginLoop();
|
||||
}
|
||||
|
||||
// ---- fake parallax: a subtle camera sway from device motion / tilt ----
|
||||
// Cannot give true positional tracking (iOS Safari has no WebXR), but reading
|
||||
// devicemotion lets us add a small depth-giving sway when you tilt or step.
|
||||
const parallax = { x: 0, y: 0, tx: 0, ty: 0 };
|
||||
function enableParallax(){
|
||||
// tilt-based offset (works from deviceorientation beta/gamma we already get)
|
||||
addEventListener('deviceorientation', (ev) => {
|
||||
if (ev.beta == null && ev.gamma == null) return;
|
||||
// map tilt to a tiny lateral/vertical target offset (clamped, metres)
|
||||
parallax.tx = Math.max(-0.12, Math.min(0.12, (ev.gamma || 0) / 90 * 0.12));
|
||||
parallax.ty = Math.max(-0.08, Math.min(0.08, ((ev.beta || 0) - 80) / 90 * 0.08));
|
||||
}, true);
|
||||
// motion-based micro-impulse (a step/jolt nudges the sway)
|
||||
if (typeof DeviceMotionEvent !== 'undefined') {
|
||||
addEventListener('devicemotion', (ev) => {
|
||||
const a = ev.accelerationIncludingGravity || ev.acceleration;
|
||||
if (!a) return;
|
||||
parallax.tx += Math.max(-0.02, Math.min(0.02, (a.x || 0) * 0.002));
|
||||
parallax.ty += Math.max(-0.02, Math.min(0.02, (a.y || 0) * 0.002));
|
||||
parallax.tx = Math.max(-0.15, Math.min(0.15, parallax.tx));
|
||||
parallax.ty = Math.max(-0.12, Math.min(0.12, parallax.ty));
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
function hideStart(){ $('#start').style.display='none'; }
|
||||
|
||||
$('#go-ar').addEventListener('click', startAR);
|
||||
$('#go-gyro').addEventListener('click', startGyro);
|
||||
|
||||
// ---- desktop fallback: drag to look around (no gyro on desktop) ----
|
||||
let dragYaw = 0, dragPitch = 0, dragging = false, lastX = 0, lastY = 0;
|
||||
let usingDrag = false;
|
||||
function enableDragLook(){
|
||||
usingDrag = true;
|
||||
const el = renderer.domElement;
|
||||
const down = (e) => { dragging = true; lastX = e.clientX ?? e.touches?.[0]?.clientX; lastY = e.clientY ?? e.touches?.[0]?.clientY; };
|
||||
const move = (e) => {
|
||||
if (!dragging) return;
|
||||
const x = e.clientX ?? e.touches?.[0]?.clientX, y = e.clientY ?? e.touches?.[0]?.clientY;
|
||||
dragYaw -= (x - lastX) * 0.005;
|
||||
dragPitch -= (y - lastY) * 0.005;
|
||||
dragPitch = Math.max(-1.2, Math.min(1.2, dragPitch));
|
||||
lastX = x; lastY = y;
|
||||
};
|
||||
const up = () => { dragging = false; };
|
||||
el.addEventListener('mousedown', down); addEventListener('mousemove', move); addEventListener('mouseup', up);
|
||||
el.addEventListener('touchstart', down, {passive:true}); addEventListener('touchmove', move, {passive:true}); addEventListener('touchend', up);
|
||||
}
|
||||
|
||||
// ---- debug panel controls ----
|
||||
$('#dbg-toggle').addEventListener('click', () => $('#dbg').classList.toggle('open'));
|
||||
function setMax(n){
|
||||
hunt.setMaxActive(n);
|
||||
$('#dbg-maxval').textContent = hunt.maxActive;
|
||||
$('#dbg-max').textContent = hunt.maxActive;
|
||||
}
|
||||
$('#dbg-minus').addEventListener('click', () => setMax(hunt.maxActive - 1));
|
||||
$('#dbg-plus').addEventListener('click', () => setMax(hunt.maxActive + 1));
|
||||
$('#dbg-beacon').addEventListener('change', (e) => { beaconsOn = e.target.checked; });
|
||||
setMax(hunt.maxActive);
|
||||
|
||||
// ---- off-screen direction arrow: points to the nearest off-screen ghost ----
|
||||
const arrowEl = $('#arrow');
|
||||
function updateArrow(){
|
||||
const list = hunt.listActive();
|
||||
let target = null, bestZ = Infinity;
|
||||
for (const a of list) {
|
||||
const p = a.object3d.position.clone().project(camera);
|
||||
const onScreen = p.z < 1 && Math.abs(p.x) < 0.95 && Math.abs(p.y) < 0.95;
|
||||
if (onScreen) continue;
|
||||
if (p.z < bestZ) { bestZ = p.z; target = { a, p }; }
|
||||
}
|
||||
if (!target) { arrowEl.style.display = 'none'; return; }
|
||||
// direction from screen centre toward the (clamped) projected point.
|
||||
// behind-camera points (z>1) get flipped so the arrow still makes sense.
|
||||
let { x, y, z } = target.p;
|
||||
if (z > 1) { x = -x; y = -y; }
|
||||
const ang = Math.atan2(y, x); // screen angle (y up)
|
||||
const r = Math.min(innerWidth, innerHeight) * 0.36;
|
||||
const cx = innerWidth/2 + Math.cos(ang) * r;
|
||||
const cy = innerHeight/2 - Math.sin(ang) * r; // CSS y is down
|
||||
const colMap = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
|
||||
arrowEl.style.setProperty('--c', colMap[target.a.ghost.color] || '#51EAF1');
|
||||
arrowEl.querySelector('path').setAttribute('fill', colMap[target.a.ghost.color] || '#51EAF1');
|
||||
arrowEl.style.display = 'block';
|
||||
arrowEl.style.left = (cx - 20) + 'px';
|
||||
arrowEl.style.top = (cy - 20) + 'px';
|
||||
arrowEl.style.transform = `rotate(${-ang + Math.PI/2}rad)`; // point outward
|
||||
}
|
||||
|
||||
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);
|
||||
if (usingXR) {
|
||||
// In AR, the device pose drives renderer.xr.getCamera(). Sync our logic
|
||||
// camera (used for targeting/detection/arrows) from it so position AND
|
||||
// rotation both track as you physically move the phone.
|
||||
const xrCam = renderer.xr.getCamera();
|
||||
xrCam.getWorldPosition(camera.position);
|
||||
xrCam.getWorldQuaternion(camera.quaternion);
|
||||
camera.updateMatrixWorld();
|
||||
} else if (usingDrag) {
|
||||
const e = new THREE.Euler(dragPitch, dragYaw, 0, 'YXZ');
|
||||
camera.quaternion.setFromEuler(e);
|
||||
} else if (gyroQuat) {
|
||||
camera.quaternion.copy(gyroQuat);
|
||||
}
|
||||
// fake parallax sway (non-XR only): ease the camera a touch toward the
|
||||
// tilt/motion target, applied in view space so it reads as gentle depth.
|
||||
if (!usingXR) {
|
||||
parallax.x += (parallax.tx - parallax.x) * Math.min(1, dt * 4);
|
||||
parallax.y += (parallax.ty - parallax.y) * Math.min(1, dt * 4);
|
||||
// decay the motion impulse back toward the tilt baseline
|
||||
parallax.tx *= 0.92; parallax.ty *= 0.92;
|
||||
const right = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
|
||||
const up = new THREE.Vector3(0,1,0).applyQuaternion(camera.quaternion);
|
||||
camera.position.copy(right.multiplyScalar(parallax.x).add(up.multiplyScalar(parallax.y)));
|
||||
}
|
||||
hunt.update(dt, t);
|
||||
updateBeacons();
|
||||
checkDetections();
|
||||
updateArrow();
|
||||
$('#dbg-active').textContent = hunt.listActive().length;
|
||||
$('#dbg-haunt').textContent = Math.round(hunt.haunt) + '%';
|
||||
$('#reticle').classList.toggle('locked', !!currentTarget());
|
||||
paintHaunt();
|
||||
renderer.render(scene, camera);
|
||||
|
||||
+31
-12
@@ -27,6 +27,7 @@ export function createHunt(THREE, scene, opts = {}) {
|
||||
const spawnArcDeg = opts.spawnArcDeg ?? 90; // ±45° — tighter so ghosts land in front
|
||||
const fleeMinMs = opts.fleeMinMs ?? 12000; // longer discovery window
|
||||
const fleeMaxMs = opts.fleeMaxMs ?? 18000;
|
||||
const fxTextures = opts.fxTextures || null; // {glow, trail, smoke} THREE.Textures
|
||||
const onEvent = opts.onEvent || (() => {});
|
||||
|
||||
let luredColor = null; // set by the colour wheel
|
||||
@@ -55,15 +56,29 @@ export function createHunt(THREE, scene, opts = {}) {
|
||||
return pool[pool.length - 1][0];
|
||||
}
|
||||
|
||||
function placeInArc(obj) {
|
||||
const half = (spawnArcDeg * Math.PI / 180) / 2;
|
||||
const yaw = (Math.random() * 2 - 1) * half; // forward-facing arc
|
||||
const pitch = (Math.random() * 0.5 - 0.15); // slightly varied height
|
||||
const dist = 2.2 + Math.random() * 1.8; // 2.2-4m out
|
||||
obj.position.set(
|
||||
Math.sin(yaw) * dist,
|
||||
0.2 + Math.sin(pitch) * 1.2,
|
||||
-Math.cos(yaw) * dist
|
||||
function placeOnRing(a) {
|
||||
// Full 360° ring around the stationary player at roughly eye level.
|
||||
// Each ghost gets orbit params so it slowly circles + bobs; the player
|
||||
// stands still and pans to track them (works on iOS, no walking needed).
|
||||
a.azimuth = Math.random() * Math.PI * 2; // start anywhere around
|
||||
a.radius = 2.6 + Math.random() * 1.6; // 2.6–4.2m out
|
||||
a.height = -0.3 + Math.random() * 1.0; // around eye level
|
||||
a.orbitSpeed = (Math.random() < 0.5 ? -1 : 1) * (0.05 + Math.random() * 0.12); // rad/s, either direction
|
||||
a.bobPhase = Math.random() * Math.PI * 2;
|
||||
a.bobAmp = 0.1 + Math.random() * 0.2;
|
||||
a.radiusPhase = Math.random() * Math.PI * 2;
|
||||
a.radiusAmp = 0.2 + Math.random() * 0.4; // gentle in/out drift
|
||||
positionFromOrbit(a, 0);
|
||||
}
|
||||
|
||||
function positionFromOrbit(a, elapsed) {
|
||||
const az = a.azimuth + a.orbitSpeed * elapsed;
|
||||
const r = a.radius + Math.sin(elapsed * 0.5 + a.radiusPhase) * a.radiusAmp;
|
||||
const y = a.height + Math.sin(elapsed * 1.2 + a.bobPhase) * a.bobAmp;
|
||||
a.visual.object3d.position.set(
|
||||
Math.sin(az) * r,
|
||||
y,
|
||||
-Math.cos(az) * r
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,16 +86,18 @@ export function createHunt(THREE, scene, opts = {}) {
|
||||
if (active.size >= maxActive) return;
|
||||
const g = pickGhost();
|
||||
const visual = createGhostVisual(THREE, { color: g.color, fx: opts.fx !== false });
|
||||
placeInArc(visual.object3d);
|
||||
if (fxTextures) visual.attachFx(fxTextures);
|
||||
scene.add(visual.object3d);
|
||||
const id = nextId++;
|
||||
// hp derived from health pips/stat; charge to capture scales with chargePips
|
||||
const hp = (g.healthMax || 300) / 100; // ~2.6-4.5
|
||||
active.set(id, {
|
||||
const a = {
|
||||
ghost: g, visual, hp, maxHp: hp,
|
||||
state: 'idle',
|
||||
fleeAt: performance.now() + (fleeMinMs + Math.random() * (fleeMaxMs - fleeMinMs)), // escapes if ignored
|
||||
});
|
||||
};
|
||||
placeOnRing(a); // assign orbit + initial position
|
||||
active.set(id, a);
|
||||
haunt = Math.min(100, haunt + 4); // presence raises the meter
|
||||
const p = visual.object3d.position;
|
||||
onEvent({ type: 'spawn', id, ghost: g, haunt, pos: { x: +p.x.toFixed(2), y: +p.y.toFixed(2), z: +p.z.toFixed(2) } });
|
||||
@@ -137,6 +154,8 @@ export function createHunt(THREE, scene, opts = {}) {
|
||||
|
||||
const now = performance.now();
|
||||
for (const [id, a] of active) {
|
||||
// orbit-and-drift around the stationary player
|
||||
if (a.state !== 'capture') positionFromOrbit(a, elapsed);
|
||||
a.visual.update(dt, elapsed);
|
||||
if (now >= a.fleeAt) flee(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user