Phase 3 update: orbit ghosts, parallax, FX, shader fix [build v8]

This commit is contained in:
2026-06-23 14:53:36 +10:00
parent 70dc130647
commit 802ae7fab9
3 changed files with 343 additions and 26 deletions
+31 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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.64.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);
}