/** * 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 || []; const maxActive = opts.maxActive ?? 4; const spawnArcDeg = opts.spawnArcDeg ?? 120; // ±60° 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 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 spawn() { if (active.size >= maxActive) return; const g = pickGhost(); const visual = createGhostVisual(THREE, { color: g.color, fx: opts.fx !== false }); placeInArc(visual.object3d); 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, { ghost: g, visual, hp, maxHp: hp, state: 'idle', fleeAt: performance.now() + (6000 + Math.random() * 4000), // escapes if ignored }); haunt = Math.min(100, haunt + 4); // presence raises the meter onEvent({ type: 'spawn', id, ghost: g, haunt }); } // --- 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) { 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, get haunt() { return haunt; }, get captured() { return captured.slice(); }, get luredColor() { return luredColor; }, }; }