/** * js/combat/Aiming.js — the gunner's arithmetic, kept OUT of the combat system * so it can be tested like a spreadsheet cell. * * Two things live here, both pure (no Phaser, no config reads, no RNG) and both * owned by the CLASS LAYER's initial bonuses (data/classes.json → perks): * * intercept() — the Wraith's ADVANCED TARGETING COMPUTER: the angle to fire * so a bolt launched at `speed` meets a MOVING hostile where it * WILL be, not where it is. Classic lead solve: with the target * at p, moving at v (constant over the bolt's short flight) and * the bolt at s, find t > 0 with |p + v·t| = s·t from the muzzle. * That is a quadratic in t; the smallest positive root is the * first crossing. When there is none (the target outruns the * bolt) or the intercept sits beyond the computer's lead cap, * it FALLS BACK to raw aim — the nose never snaps into nonsense. * * barrels() — the Vanguard's DUAL CANNON: where a volley's bolts leave the * hull, symmetric about the centreline and squared away from it * so twin muzzles never spawn inside each other. * * The combat system decides WHETHER either applies (the class ledger's perk * flags); this file only answers the maths question it asks. */ /** A raw aim angle (muzzle → target). The fallback for every degenerate case. */ export function rawAngle(sx, sy, tx, ty) { return Math.atan2(ty - sy, tx - sx); } /** * Solve the intercept. * * @param {number} sx muzzle x * @param {number} sy muzzle y * @param {number} tx target x (now) * @param {number} ty target y (now) * @param {number} tvx target vx (px/s) * @param {number} tvy target vy (px/s) * @param {number} speed bolt speed (px/s) * @param {number} [maxLead] the longest flight time the solve may commit to * (seconds; data/classes.json → perks.targetingComputer.maxLead). 0/absent = * unlimited. * @returns {{angle: number, t: number, x: number, y: number, solved: boolean, lead: number}} * angle the heading to fire on (radians) — the intercept when solved, else raw * t the flight time the answer assumed (seconds; 0 for a stationary target) * x,y the predicted meeting point (what a reticle should draw) * solved true when a real intercept was found (false = raw aim, out of range * or beyond maxLead — the honest "no solution" answer) * lead the angular correction applied (radians, signed) — 0 when the nose * did not move; useful for the HUD and for tests */ export function intercept(sx, sy, tx, ty, tvx = 0, tvy = 0, speed = 0, maxLead = 0) { const raw = rawAngle(sx, sy, tx, ty); const s = Number(speed); if (!(s > 0)) return { angle: raw, t: 0, x: tx, y: ty, solved: false, lead: 0 }; const dx = tx - sx; const dy = ty - sy; const vx = Number(tvx) || 0; const vy = Number(tvy) || 0; // |p + v·t|² = (s·t)² → (v·v − s²)t² + 2(p·v)t + p·p = 0 const a = vx * vx + vy * vy - s * s; const b = 2 * (dx * vx + dy * vy); const c = dx * dx + dy * dy; let t = NaN; if (Math.abs(a) < 1e-9) { // the bolt and the target close at a constant rate (parallel-ish): linear case t = b !== 0 ? -c / b : NaN; } else { const disc = b * b - 4 * a * c; if (disc >= 0) { const r = Math.sqrt(disc); const t1 = (-b - r) / (2 * a); const t2 = (-b + r) / (2 * a); // the FIRST crossing forward in time (a negative root is a shot that would // have had to be fired in the past) const pos = [t1, t2].filter((v) => Number.isFinite(v) && v > 0); if (pos.length) t = Math.min(...pos); } } const cap = Number(maxLead) > 0 ? Number(maxLead) : Infinity; if (!Number.isFinite(t) || t <= 0 || t > cap) { // No intercept the computer may commit to: aim at the ship as it is now. return { angle: raw, t: 0, x: tx, y: ty, solved: false, lead: 0 }; } const px = tx + vx * t; const py = ty + vy * t; const angle = Math.atan2(py - sy, px - sx); return { angle, t, x: px, y: py, solved: true, lead: angleDiff(angle, raw) }; } /** Signed shortest angular difference a − b (radians, −π…π). */ export function angleDiff(a, b) { let d = (a - b) % (Math.PI * 2); if (d > Math.PI) d -= Math.PI * 2; if (d < -Math.PI) d += Math.PI * 2; return d; } /** * Where a volley's bolts leave the hull: `count` muzzles symmetric about the * firing line, each `offset` px off the centreline in turn (n = 2 → ±offset), and * `nose` px forward along it. Angles are the FIRING heading, so the caller just * adds the jitter/spread it wants afterwards. * * @param {number} angle firing heading (radians) * @param {number} count barrels (data/classes.json → perks.dualCannon.barrels) * @param {number} offset px each barrel sits off the centreline (barrelOffset) * @param {number} nose how far ahead of the hull the bolt spawns (px) * @returns {Array<{x: number, y: number, angle: number}>} muzzle points, left to right */ export function barrels(angle, count = 1, offset = 0, nose = 0) { const n = Math.max(1, Math.round(Number(count) || 1)); const off = Number(offset) || 0; const fx = Math.cos(angle); const fy = Math.sin(angle); // the unit vector 90° to the firing line (screen y-down: "left" of the muzzle) const lx = -fy; const ly = fx; const out = []; for (let i = 0; i < n; i++) { const lateral = (i - (n - 1) / 2) * off * 2; // n=2 → −offset … +offset out.push({ x: fx * nose + lx * lateral, y: fy * nose + ly * lateral, angle }); } return out; }