Files
newbury-nights/public/hunt.js
T

180 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* hunt.js — Newbury Nights hunt loop (Phase 3, standalone demo)
*
* Core v1 mechanics:
* - Spawn: rarity-weighted draw from the roster, placed in a forward arc.
* - Colour lure: the wheel sets the "lured" colour; matching ghosts spawn more
* often and are worth more, mirroring the original's GhostColor mechanic.
* - Capture: aim the reticle, hold to charge, release to fire. Charge time
* scales with the ghost's chargePips; misses let it flee (escape timing).
* - Hauntometer: every active/escaped ghost raises the meter; captures lower
* it. As the meter climbs, spawn rate and rarity bias escalate — the session
* gets harder the longer the haunt goes unchecked.
*
* Engine-agnostic: takes a THREE instance + a scene + the createGhostVisual
* factory. No AR/session code here — the page wires camera/anchor.
*/
import { createGhostVisual } from './ghost-visual.js';
const RARITY_BASE_WEIGHT = { Common: 50, Rare: 30, Epic: 15, Legendary: 5 };
const RARITY_SCORE = { Common: 10, Rare: 25, Epic: 60, Legendary: 150 };
const COLORS = ['Red', 'Blue', 'Yellow'];
export function createHunt(THREE, scene, opts = {}) {
const roster = opts.roster || [];
let maxActive = opts.maxActive ?? 4;
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
let haunt = 0; // 0..100 hauntometer
let captured = []; // captured ghost ids this session
const active = new Map(); // id -> { ghost, visual, hp, state, fleeAt }
let nextId = 1;
let spawnTimer = 0;
// --- weighted roster draw, biased by lure + haunt escalation ---
function pickGhost() {
// haunt escalation: higher haunt tilts the draw toward rarer ghosts
const escal = 1 + (haunt / 100) * 1.5; // up to 2.5x rare bias
const pool = [];
for (const g of roster) {
let w = RARITY_BASE_WEIGHT[g.rarity] || 1;
// rarer ghosts get heavier as haunt climbs
if (g.rarity !== 'Common') w *= escal;
// lure: matching colour spawns ~2x more often
if (luredColor && g.color === luredColor) w *= 2;
pool.push([g, w]);
}
const total = pool.reduce((s, [, w]) => s + w, 0);
let r = Math.random() * total;
for (const [g, w] of pool) { r -= w; if (r <= 0) return g; }
return pool[pool.length - 1][0];
}
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
);
}
function spawn() {
if (active.size >= maxActive) return;
const g = pickGhost();
const visual = createGhostVisual(THREE, { color: g.color, fx: opts.fx !== false });
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
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) } });
}
// --- capture attempt: called by the page with a charge level 0..1 ---
function fireAt(id, charge) {
const a = active.get(id);
if (!a) return { hit: false };
// damage = charge, but the ghost needs enough charge relative to chargePips
const needed = 0.4 + (a.ghost.chargePips || 2) * 0.12; // 0.5-1.0
const dmg = charge >= needed ? charge * 1.6 : charge * 0.4;
a.hp -= dmg;
if (a.hp <= 0) return capture(id);
a.state = 'hit';
onEvent({ type: 'hit', id, ghost: a.ghost, hpFrac: Math.max(0, a.hp / a.maxHp) });
return { hit: true, captured: false };
}
function capture(id) {
const a = active.get(id);
if (!a) return { hit: false };
a.visual.setState('capture');
const colorBonus = luredColor && a.ghost.color === luredColor ? 1.5 : 1;
const score = Math.round((RARITY_SCORE[a.ghost.rarity] || 10) * colorBonus);
captured.push(a.ghost.id);
haunt = Math.max(0, haunt - 10); // captures calm the haunt
setTimeout(() => { scene.remove(a.visual.object3d); a.visual.dispose(); }, 500);
active.delete(id);
onEvent({ type: 'capture', id, ghost: a.ghost, score, captured: captured.length, haunt });
return { hit: true, captured: true, score };
}
function flee(id) {
const a = active.get(id);
if (!a) return;
a.visual.setState('capture'); // reuse shrink-out
setTimeout(() => { scene.remove(a.visual.object3d); a.visual.dispose(); }, 400);
active.delete(id);
haunt = Math.min(100, haunt + 8); // an escape feeds the haunt
onEvent({ type: 'flee', id, ghost: a.ghost, haunt });
}
function setLure(color) {
luredColor = COLORS.includes(color) ? color : null;
onEvent({ type: 'lure', color: luredColor });
}
function update(dt, elapsed) {
// spawn cadence accelerates with haunt
const interval = Math.max(1.2, 4.5 - (haunt / 100) * 3.0);
spawnTimer += dt;
if (spawnTimer >= interval) { spawnTimer = 0; spawn(); }
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);
}
}
function listActive() {
return [...active.entries()].map(([id, a]) => ({
id, ghost: a.ghost, object3d: a.visual.object3d,
hpFrac: Math.max(0, a.hp / a.maxHp),
}));
}
return {
update, spawn, fireAt, capture, flee, setLure, listActive,
setMaxActive(n) { maxActive = Math.max(1, n | 0); },
get maxActive() { return maxActive; },
get haunt() { return haunt; },
get captured() { return captured.slice(); },
get luredColor() { return luredColor; },
};
}