orbit/js/combat/Combat.js

319 lines
13 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.

/**
* Combat rules — the pure arithmetic shared by the sandbox's actors
* (the player's armor, the enemy craft, and whatever system ports out of
* the range into the real game). No Phaser, no scene, so it runs in bare
* Node: dev/combat.test.mjs exercises exactly this file.
*
* An armor block is `{ shields, hull }` (both ≥ 0): a hit spends the
* shields first, then the hull, and the craft is dead at hull ≤ 0 — the
* same rule for the player (data/combat.json → playerShip) and the
* hostiles (→ enemy).
*/
/**
* Spend damage off an armor block (mutates it).
*
* @param {{shields: number, hull: number}} armor the block to hit
* @param {number} amount damage (negative/NaN treated as 0)
* @returns {{shieldHit: number, hullHit: number, dead: boolean}}
* shieldHit/hullHit = how much each layer absorbed; dead = hull ≤ 0.
*/
export function applyDamage(armor, amount) {
let rem = Math.max(0, Number(amount) || 0);
const shields = Math.max(0, armor.shields ?? 0);
const shieldHit = Math.min(shields, rem);
rem -= shieldHit;
const hull = Math.max(0, armor.hull ?? 0);
const hullHit = Math.min(hull, rem);
rem -= hullHit;
armor.shields = shields - shieldHit;
armor.hull = hull - hullHit;
return { shieldHit, hullHit, dead: armor.hull <= 0 };
}
/**
* One step of the combat ship's FLIGHT FEEL (the range's WASD handling —
* js/combat/CombatScene.js): a smoothed turn rate, a ramped throttle,
* and an instant brake. Pure — no Phaser — so it runs in bare Node
* (dev/combat.test.mjs) and the scene just reads the result onto the
* arcade body each frame.
*
* The feel state `st` is: `{ rot, turnVel, throttle, brake }`
* rot — the render heading (rad); the scene assigns it to the ship
* turnVel — the current turn velocity (rad/s), eased toward ±rotSpeed
* throttle — 0..1, eased toward 1 while `up`, toward 0 when released;
* the scene scales the weapon-free thrust by it
* brake — the instant reverse-throttle fraction while `down` (0..1)
*
* `input` is the boolean key state `{ left, right, up, down }`.
* `cfg` (data/combat.json → player): rotSpeed (rad/s sustained turn),
* rotApproach (1/s, how fast turnVel settles — smoothing), thrustRamp
* (1/s, throttle-up responsiveness), thrustDecay (1/s, throttle release),
* brake (fraction of FULL thrust, instant while down).
* Exponential approaches ⇒ frame-rate independent, no overshoot.
*
* @param {{rot?: number, turnVel?: number, throttle?: number, brake?: number}} st
* @param {{left?: boolean, right?: boolean, up?: boolean, down?: boolean}} input
* @param {number} dt seconds (clamped to ≤ 0.1 — a backgrounded tab can't fast-forward the feel)
* @param {object} cfg
* @returns the same `st` object, mutated
*/
export function handleFeel(st, input, dt, cfg) {
const d = Math.min(Math.max(Number(dt) || 0, 0), 0.1);
const cfg2 = cfg ?? {};
// Turn: the turn VELOCITY eases toward ±rotSpeed (a smoothed gyroscope —
// the first key-press builds turn, so a tap is a nudge and a hold is
// a full-rate spin). v(t) = T − (T − v₀)·e^(−r·t), and rot integrates
// that EXACTLY (∫₀ᵈ v dt) — so the feel is frame-rate independent by
// construction (two half-steps ≡ one full step, no Euler drift).
const dir = (input.right ? 1 : 0) - (input.left ? 1 : 0);
const T = dir * Math.max(0, Number(cfg2.rotSpeed) || 0);
const r = Math.max(0.1, Number(cfg2.rotApproach) || 12);
const v0 = st.turnVel || 0;
const q = Math.exp(-r * d);
const v1 = T - (T - v0) * q;
st.rot = (st.rot || 0) + (T * d - (T - v0) * (1 - q) / r);
st.turnVel = v1;
if (dir === 0 && Math.abs(st.turnVel) < 1e-4) st.turnVel = 0;
// Throttle: ramps up toward 1 while `up`; cuts quickly when released
// (no lingering kick after the key lets go).
const want = input.up ? 1 : 0;
const rate = want
? Math.max(0.1, Number(cfg2.thrustRamp) || 10)
: Math.max(0.1, Number(cfg2.thrustDecay) || 16);
st.throttle = want - (want - (st.throttle || 0)) * Math.exp(-rate * d);
if (want === 0 && st.throttle < 1e-3) st.throttle = 0;
// Brake: an INSTANT reverse throttle (S/Down) as a fraction of FULL
// thrust — a responsive stop, independent of the ramp.
st.brake = input.down ? Math.max(0, Number(cfg2.brake) || 0) : 0;
return st;
}
/**
* The render heading (rad) of a ship sprite: its rotation minus the
* art's facing offset (ship.artOffset — the sheet art may not face east,
* js/entities/Ship.js). A plain object with a .rotation is fine.
*/
export function headingOf(ship) {
return ship.rotation - (ship.artOffset || 0);
}
// ------------------------------------------------------- weapon geometry
//
// The pure geometry behind the hostile WEAPON PATTERNS (js/combat/
// Weapons.js dispatches on data/combat.json → weapon.pattern): the fan
// and ring angle sets, and the HEAT-SEEKING turn step. Pure, so the
// missile's dodge rule (a capped turn-rate) is testable in bare Node.
/** Wrap an angle to [-π, π) (mirror of Phaser.Math.Angle.Wrap). */
export function wrapAngle(a) {
const min = -Math.PI;
const range = Math.PI * 2;
return (((a - min) % range) + range) % range + min;
}
/**
* The FAN of a spray/burst volley: `count` shot angles spread over a
* total arc of `fan` rad, centered on `base`. count 1 = just `base`
* (a single bolt); the ends of the fan are inclusive. The caller adds
* its own aim jitter (weapon.spread) to each.
*
* @returns {number[]} the angles (rad), length `count`
*/
export function fanAngles(base, count, fan = 0) {
const n = Math.max(1, Math.floor(Number(count) || 1));
const f = Math.max(0, Number(fan) || 0);
if (n === 1) return [base];
const out = [];
for (let i = 0; i < n; i++) {
const t = i / (n - 1); // 0 → 1 across the volley
out.push(base - f / 2 + f * t);
}
return out;
}
/**
* The RING of a radial volley: `count` shots evenly around 2π, with a
* random `phase` so successive rings are never identical. A ring is
* symmetric (no center to aim at) — the caller's spread jitter still
* applies per bolt.
*
* @returns {number[]} the angles (rad), length `count`
*/
export function ringAngles(count, phase = 0) {
const n = Math.max(3, Math.floor(Number(count) || 8));
const p = Number(phase) || 0;
const out = [];
for (let i = 0; i < n; i++) out.push(p + (i * Math.PI * 2) / n);
return out;
}
/**
* One HEAT-SEEKING step (the missile's dodge rule, pure): turn the
* flight heading toward the target at AT MOST `turnRate` rad/s × dt —
* the cap is what makes the missile dodgeable (the player's sustained
* turn is combat.player.rotSpeed, comfortably above the missiles'
* missileTurn, so a hard pull out-turns the shot). With no target
* (the player is down) the heading holds and the missile flies true.
* Frame-rate independent: the step is bounded by turnRate×dt, and the
* exact-target case lands on the bearing with no overshoot.
*
* @param {number} heading the missile's current flight heading (rad)
* @param {number} sx the missile's x (world px)
* @param {number} sy the missile's y (world px)
* @param {number} tx the target's x
* @param {number} ty the target's y
* @param {number} turnRate the CAP on the turn rate (rad/s)
* @param {number} dt seconds
* @returns {number} the new heading (rad)
*/
export function homingStep(heading, sx, sy, tx, ty, turnRate, dt) {
if (turnRate <= 0 || dt <= 0) return heading;
const desired = Math.atan2(ty - sy, tx - sx);
const delta = wrapAngle(desired - heading);
const maxTurn = turnRate * dt;
if (Math.abs(delta) <= maxTurn) return desired;
return heading + Math.sign(delta) * maxTurn;
}
/**
* One step of the standoff-ring AI: advance the craft's orbit angle and
* return the point on the ring it should steer to (pure — the craft
* does the steering, js/combat/EnemyShip.js).
*
* @returns {{angle: number, x: number, y: number}} the new orbit angle + target
*/
export function orbitStep(px, py, orbitAngle, orbitDir, orbitSpeed, dt, orbitRadius) {
const angle = orbitAngle + orbitDir * orbitSpeed * dt;
return {
angle,
x: px + Math.cos(angle) * orbitRadius,
y: py + Math.sin(angle) * orbitRadius,
};
}
// ---------------------------------------------------- the readability layer
//
// The READ rules behind the readability layer (js/combat/CombatHud.js) —
// pure, so both contexts (the range AND the main game) share the same
// "what counts as the current target / where does the off-screen arrow
// land / what the feed shows" logic. data/combat.json → readability.*
// carries the numbers; CombatHud does the drawing.
/**
* The CURRENT TARGET (the one your guns are on): the nearest LIVE hostile
* to (x, y) within `range` px, or null. The range's lock indicator and the
* main game's auto-fire both run this rule, so "what's locked" never
* drifts between the two. Dead hostiles never qualify; a `qualifies`
* predicate (when given) narrows the pool further — the main game's
* auto-fire passes its on-screen rule (CombatSystem.craftVisible), so
* the guns only ever engage a hostile that's actually in view.
*
* @param {function(object):boolean} [qualifies] extra eligibility test (null = all live hostiles)
* @returns {object|null} the target (anything with .x/.y/.dead)
*/
export function pickTarget(enemies, x, y, range = Infinity, qualifies = null) {
let best = null;
let bestD = Infinity;
const r2 = range * range;
for (const e of enemies || []) {
if (!e || e.dead) continue;
if (qualifies && !qualifies(e)) continue;
const dx = e.x - x;
const dy = e.y - y;
const d2 = dx * dx + dy * dy;
if (d2 <= r2 && d2 < bestD) {
bestD = d2;
best = e;
}
}
return best;
}
/**
* The ON-SCREEN RULE (pure): is a craft with a visual circle ((x, y),
* radius r) at least PARTIALLY inside the camera's visible world rect
* (`view` — {x, y, width, height})? A ship sliding off the edge is still
* visible — and still in the fight — while any part of it is on screen;
* it is "off-screen" only once fully out. A null/zero view (a scene
* without a camera — the headless tests) = no constraint: everything
* counts as on screen.
*
* This is the combat rule "a hostile must be VISUALLY ON SCREEN to fire
* or be fired upon" (js/combat/CombatSystem.js): off-screen hostiles
* hold their lances, the cannon won't engage them, and their tracers
* pass through. The scene-side craftVisible() feeds its camera's
* worldView in here.
*
* @param {number} x the craft's x (world px)
* @param {number} y the craft's y (world px)
* @param {number} r the craft's visual radius (world px)
* @param {object|null} view the camera's visible world rect (or null)
* @returns {boolean} true when the craft is (partly) on screen
*/
export function inView(x, y, r, view) {
if (!view || !(Number(view.width) > 0) || !(Number(view.height) > 0)) return true;
const rr = Math.max(0, Number(r) || 0);
const L = Number(view.x) || 0;
const T = Number(view.y) || 0;
const R = L + Number(view.width);
const B = T + Number(view.height);
return x + rr >= L && x - rr <= R && y + rr >= T && y - rr <= B;
}
/**
* One kill-feed entry lands (pure state step): unshift {label, t: now},
* drop anything older than `life` ms, cap at `max` lines. Returns a NEW
* array (the caller's stays put — the same rule the range and the main
* game's feed both run).
*
* @returns {Array<{label: string, t: number}>} the next feed state
*/
export function feedPush(feed, label, now, max = 5, life = 3000) {
const kept = (feed || []).filter((e) => e && now - e.t < life);
return [{ label, t: now }, ...kept].slice(0, Math.max(1, max));
}
/**
* Where the OFF-SCREEN arrow for a hostile sits: the point where the ray
* from the viewport's center to the target's SCREEN position (sx, sy)
* meets the viewport's edge, inset by `pad` px — plus the angle to aim
* the arrow at the target. Returns null while the target is on screen
* (nothing to point at). Pure geometry — no scene, no Phaser.
*
* @param {number} vw the viewport's width (px)
* @param {number} vh the viewport's height (px)
* @param {number} sx the target's x in SCREEN space
* @param {number} sy the target's y in SCREEN space
* @param {number} [pad] the inset from the screen edge (px)
* @returns {{x: number, y: number, angle: number}|null}
*/
export function offscreenArrow(vw, vh, sx, sy, pad = 28) {
const px = Math.max(0, pad);
const ix = px;
const iy = px;
const iw = Math.max(1, vw - 2 * px);
const ih = Math.max(1, vh - 2 * px);
// On screen (inside the inset rect)? Nothing to point at.
if (sx >= ix && sx <= ix + iw && sy >= iy && sy <= iy + ih) return null;
const cx = ix + iw / 2;
const cy = iy + ih / 2;
const dx = sx - cx;
const dy = sy - cy;
if (dx === 0 && dy === 0) return null; // exactly on center — degenerate
// Ray from the center: smallest t that puts us on the inset rect's edge.
let t = Infinity;
if (dx !== 0) t = Math.min(t, (dx > 0 ? iw / 2 / dx : -iw / 2 / dx));
if (dy !== 0) t = Math.min(t, (dy > 0 ? ih / 2 / dy : -ih / 2 / dy));
if (!Number.isFinite(t) || t <= 0) return null;
const x = cx + t * dx;
const y = cy + t * dy;
return { x, y, angle: Math.atan2(dy, dx) };
}