161 lines
6.4 KiB
JavaScript
161 lines
6.4 KiB
JavaScript
/**
|
|
* 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 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() + (fleeMinMs + Math.random() * (fleeMaxMs - fleeMinMs)), // escapes if ignored
|
|
});
|
|
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) {
|
|
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; },
|
|
};
|
|
}
|