131 lines
5.6 KiB
JavaScript
131 lines
5.6 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|