orbit/js/combat/Aiming.js

131 lines
5.6 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.

/**
* 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;
}