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