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