205 lines
8.3 KiB
JavaScript
205 lines
8.3 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------- 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.
|
||
*
|
||
* @returns {object|null} the target (anything with .x/.y/.dead)
|
||
*/
|
||
export function pickTarget(enemies, x, y, range = Infinity) {
|
||
let best = null;
|
||
let bestD = Infinity;
|
||
const r2 = range * range;
|
||
for (const e of enemies || []) {
|
||
if (!e || e.dead) 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;
|
||
}
|
||
|
||
/**
|
||
* 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) };
|
||
}
|