fertig-classic-games/src/games/starcontrol/StarControlLogic.js

807 lines
28 KiB
JavaScript

// Pure Super Melee simulation for Star Control. No Phaser dependency — fully
// unit-testable headlessly via tools/verifyStarControl.js.
//
// Fidelity anchors (from The Ur-Quan Masters sources, sc2/src/uqm):
// - Battle advances in fixed 24 fps frames; every ship stat expressed as a
// "wait" is a frame counter with UQM semantics: WAIT = n means the action
// recurs every n+1 frames.
// - World units are UQM world units (1 display pixel = 4 world units,
// units.h DISPLAY_TO_WORLD). Arena is UQM's LOG_SPACE: 8192 x 7680.
// - Ships have 16 discrete facings for thrust/fire/sprites; the sim keeps a
// float facing for smooth turning at the UQM rate ((PI/8)/(turnWait+1) per
// frame) and quantizes where the original quantizes.
import { PRIMARIES, SPECIALS, PASSIVES } from './StarControlShips.js';
export const STEP_MS = 1000 / 24;
export const ARENA_W = 8192;
export const ARENA_H = 7680;
export const FACINGS = 16;
export const FACING_STEP = (Math.PI * 2) / FACINGS;
export const TUNE = {
MAX_FRAMES_PER_STEP: 6, // spiral-of-death cap after tab suspend
PLANET_RADIUS: 220, // world units (UQM planet ~29 display px)
PLANET_GRAVITY: 1.6e6, // accel = G / r^2 (world units / frame^2)
PLANET_GRAVITY_MIN_R: 420, // clamp radius so slingshots stay sane
PLANET_CREW_DAMAGE: 3, // crew lost on planet impact
PLANET_DAMAGE_COOLDOWN: 24, // frames between crew-damaging planet hits
PLANET_BOUNCE: 0.65, // velocity reflection factor off the planet
PLANET_EJECT: 36, // extra outward shove on impact (units/frame)
ASTEROID_COUNT: 5,
ASTEROID_RADIUS: 60,
ASTEROID_SPEED_MIN: 8, // units/frame
ASTEROID_SPEED_MAX: 24,
ASTEROID_RESPAWN_FRAMES: 240, // ~10 s
SHIP_COLLISION_DAMAGE_SPEED: 40, // relative speed (units/frame) for 1-crew hit
SHIP_COLLISION_RESTITUTION: 0.9,
SPAWN_SEPARATION: 3200, // new ship spawns this far from survivor
DEFAULT_SHIP_RADIUS: 100,
MAX_BATTLE_FRAMES: 24 * 60 * 5, // 5 min safety valve -> draw
};
// ---------------------------------------------------------------------------
// Seeded RNG (same generator the other games use).
export function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------------------------------------------------------------------------
// Toroidal arena math. Exported for the AI, the scene renderer and verify.
export function wrap(v, size) {
return ((v % size) + size) % size;
}
// Shortest signed delta from a to b on a wrapped axis.
export function tdelta(a, b, size) {
let d = (b - a) % size;
if (d > size / 2) d -= size;
else if (d < -size / 2) d += size;
return d;
}
export function tdist2(ax, ay, bx, by) {
const dx = tdelta(ax, bx, ARENA_W);
const dy = tdelta(ay, by, ARENA_H);
return dx * dx + dy * dy;
}
export function tdist(ax, ay, bx, by) {
return Math.sqrt(tdist2(ax, ay, bx, by));
}
// Toroidal midpoint (halfway along the short path from a to b).
export function tmid(ax, ay, bx, by) {
return {
x: wrap(ax + tdelta(ax, bx, ARENA_W) / 2, ARENA_W),
y: wrap(ay + tdelta(ay, by, ARENA_H) / 2, ARENA_H),
};
}
// True if the toroidal segment (x1,y1)->(x2,y2) passes within `r` of (cx,cy).
// Used for swept collision so fast shots can't tunnel through small ships.
export function segHitsCircle(x1, y1, x2, y2, cx, cy, r) {
const dx = tdelta(x1, x2, ARENA_W);
const dy = tdelta(y1, y2, ARENA_H);
const fx = tdelta(x1, cx, ARENA_W);
const fy = tdelta(y1, cy, ARENA_H);
const len2 = dx * dx + dy * dy;
let t = len2 > 0 ? (fx * dx + fy * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const ex = fx - dx * t;
const ey = fy - dy * t;
return ex * ex + ey * ey < r * r;
}
export function quantizeFacing(facing) {
return ((Math.round(facing / FACING_STEP) % FACINGS) + FACINGS) % FACINGS;
}
export function facingToRad(facing16) {
return facing16 * FACING_STEP;
}
// Angle needed to hit a moving target with a projectile of speed `shotSpeed`
// (world units/frame), from (sx, sy), toroidal-aware. Falls back to a direct
// bearing when no intercept exists. Used by homing shots and the AI.
export function interceptAngle(sx, sy, target, shotSpeed) {
const dx = tdelta(sx, target.x, ARENA_W);
const dy = tdelta(sy, target.y, ARENA_H);
const tvx = target.vx ?? 0;
const tvy = target.vy ?? 0;
const a = tvx * tvx + tvy * tvy - shotSpeed * shotSpeed;
const b = 2 * (dx * tvx + dy * tvy);
const c = dx * dx + dy * dy;
let t = -1;
if (Math.abs(a) < 1e-6) {
if (Math.abs(b) > 1e-6) t = -c / b;
} else {
const disc = b * b - 4 * a * c;
if (disc >= 0) {
const rt = Math.sqrt(disc);
const t1 = (-b - rt) / (2 * a);
const t2 = (-b + rt) / (2 * a);
t = t1 > 0 ? t1 : t2;
}
}
if (t <= 0 || !Number.isFinite(t)) return Math.atan2(dy, dx);
return Math.atan2(dy + tvy * t, dx + tvx * t);
}
// Signed smallest angular difference a -> b in (-PI, PI].
export function angleDelta(a, b) {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
else if (d <= -Math.PI) d += Math.PI * 2;
return d;
}
// ---------------------------------------------------------------------------
// Ship definition compilation / validation.
const REQUIRED_STATS = [
'maxCrew', 'maxEnergy', 'energyRegen', 'energyWait', 'maxThrust',
'thrustIncrement', 'thrustWait', 'turnWait', 'mass',
];
export function validateShipDef(key, def) {
const fail = (why) => { throw new Error(`ship "${key}": ${why}`); };
if (!def || typeof def !== 'object') fail('not an object');
if (!def.name) fail('missing name');
if (!['alliance', 'hierarchy', 'neutral'].includes(def.team)) fail(`bad team "${def.team}"`);
if (!(def.pointValue >= 0)) fail('bad pointValue');
if (!def.stats) fail('missing stats');
for (const s of REQUIRED_STATS) {
if (typeof def.stats[s] !== 'number' || def.stats[s] < 0) fail(`stats.${s} missing or negative`);
}
if (!def.primary || !PRIMARIES[def.primary.type]) fail(`unknown primary type "${def.primary?.type}"`);
if (!def.special || !SPECIALS[def.special.type]) fail(`unknown special type "${def.special?.type}"`);
for (const p of def.passives ?? []) {
if (!PASSIVES[p]) fail(`unknown passive "${p}"`);
}
for (const form of def.forms ?? []) {
if (form.primary && !PRIMARIES[form.primary.type]) fail(`unknown form primary "${form.primary.type}"`);
}
return true;
}
// Alternate-form support (Mmrnmhrm X-Form): a transform special stashes the
// active form's stat/weapon overrides in ship.fx; everything else reads
// through these accessors.
export function shipStats(ship) {
return ship.fx.formStats ? { ...ship.def.stats, ...ship.fx.formStats } : ship.def.stats;
}
export function shipPrimary(ship) {
return ship.fx.formPrimary ?? ship.def.primary;
}
export function compileShip(key, def) {
validateShipDef(key, def);
const s = def.stats;
return {
key,
...def,
passives: def.passives ?? [],
radius: def.radius
?? (def.sprite?.frameWidth
? Math.max(24, (Math.max(def.sprite.frameWidth, def.sprite.frameHeight) / 2) * 4 * 0.8)
: TUNE.DEFAULT_SHIP_RADIUS),
turnPerFrame: FACING_STEP / (s.turnWait + 1), // rad/frame; one 1/16 facing per (turnWait+1) frames
};
}
// json -> { key -> compiled def }. Throws with a named reason on bad entries.
export function compileShips(json) {
const out = {};
const ships = json?.ships ?? {};
for (const key of Object.keys(ships)) out[key] = compileShip(key, ships[key]);
return out;
}
// ---------------------------------------------------------------------------
// Battle state.
let nextEntityId = 1;
function makeShip(def, side, opts = {}) {
return {
id: nextEntityId++,
kind: 'ship',
side, // 0 or 1
def,
x: opts.x ?? 0, y: opts.y ?? 0,
px: opts.x ?? 0, py: opts.y ?? 0, // previous-frame position for render lerp
vx: opts.vx ?? 0, vy: opts.vy ?? 0,
facing: opts.facing ?? 0, // float radians
crew: opts.crew ?? def.stats.maxCrew,
energy: opts.energy ?? def.stats.maxEnergy,
alive: true,
waits: { turn: 0, thrust: 0, weapon: 0, special: 0, energy: 0 },
input: { left: false, right: false, thrust: false, fire: false, special: false },
// Registry-managed status effects / per-ship scratch state.
fx: {},
// Movement overrides registries may set for one frame (blazer, omni-thrust,
// probe drive, afterburner). Cleared by the sim each frame.
moveOverride: null,
};
}
function spawnAsteroid(state, edge) {
const rng = state.rng;
let x; let y;
if (edge) {
if (rng() < 0.5) { x = 0; y = rng() * ARENA_H; } else { x = rng() * ARENA_W; y = 0; }
} else {
// Initial placement: anywhere not too close to the planet or the ships.
do {
x = rng() * ARENA_W; y = rng() * ARENA_H;
} while (
tdist2(x, y, ARENA_W / 2, ARENA_H / 2) < 1400 * 1400
|| state.ships.some((sh) => tdist2(x, y, sh.x, sh.y) < 900 * 900)
);
}
const speed = TUNE.ASTEROID_SPEED_MIN + rng() * (TUNE.ASTEROID_SPEED_MAX - TUNE.ASTEROID_SPEED_MIN);
const ang = rng() * Math.PI * 2;
state.asteroids.push({
id: nextEntityId++, kind: 'asteroid',
x, y, px: x, py: y,
vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed,
spin: (rng() - 0.5) * 0.3, rot: rng() * Math.PI * 2,
radius: TUNE.ASTEROID_RADIUS, alive: true,
});
}
// Creates one 1v1 battle. `a`/`b`: { def, crew?, energy? } — crew/energy carry
// over for a surviving ship between battles. The survivor keeps its position
// and velocity via opts.keep = { side, x, y, vx, vy, facing }.
export function createBattle({ a, b, seed = 1, keep = null }) {
const rng = mulberry32(seed);
const state = {
seed,
rng,
frame: 0,
over: false,
winner: null, // null | 0 | 1 | 'draw'
ships: [],
projectiles: [],
asteroids: [],
events: [],
planet: { x: ARENA_W / 2, y: ARENA_H / 2, radius: TUNE.PLANET_RADIUS },
accumulatorMs: 0,
};
const mk = (spec, side, place) => {
const ship = makeShip(spec.def, side, {
...place, crew: spec.crew, energy: spec.energy,
});
for (const p of ship.def.passives) PASSIVES[p].init?.(state, ship);
SPECIALS[ship.def.special.type].init?.(state, ship);
PRIMARIES[ship.def.primary.type].init?.(state, ship);
return ship;
};
// Default entry: top and bottom of the arena, facing each other (UQM style).
const placeA = { x: ARENA_W / 2, y: ARENA_H * 0.18, facing: Math.PI / 2 };
const placeB = { x: ARENA_W / 2, y: ARENA_H * 0.82, facing: -Math.PI / 2 };
if (keep && keep.side === 0) {
Object.assign(placeA, { x: keep.x, y: keep.y, vx: keep.vx, vy: keep.vy, facing: keep.facing });
Object.assign(placeB, {
x: wrap(keep.x + TUNE.SPAWN_SEPARATION, ARENA_W),
y: wrap(keep.y + ARENA_H / 2, ARENA_H),
});
} else if (keep && keep.side === 1) {
Object.assign(placeB, { x: keep.x, y: keep.y, vx: keep.vx, vy: keep.vy, facing: keep.facing });
Object.assign(placeA, {
x: wrap(keep.x + TUNE.SPAWN_SEPARATION, ARENA_W),
y: wrap(keep.y + ARENA_H / 2, ARENA_H),
});
}
state.ships.push(mk(a, 0, placeA));
state.ships.push(mk(b, 1, placeB));
for (let i = 0; i < TUNE.ASTEROID_COUNT; i += 1) spawnAsteroid(state, false);
return state;
}
export function setInput(state, side, input) {
const ship = state.ships[side];
if (!ship || !ship.alive) return;
Object.assign(ship.input, input);
}
// ---------------------------------------------------------------------------
// Registry-facing sim services (the API weapon/special functions use).
export function emit(state, event) {
state.events.push(event);
}
export function spawnProjectile(state, owner, opts) {
const speed = opts.speed ?? 0;
const ang = opts.angle ?? owner.facing;
const p = {
id: nextEntityId++,
kind: opts.kind ?? 'shot',
type: opts.type ?? 'shot',
owner: owner.side,
x: wrap(opts.x ?? owner.x, ARENA_W),
y: wrap(opts.y ?? owner.y, ARENA_H),
vx: opts.vx ?? Math.cos(ang) * speed,
vy: opts.vy ?? Math.sin(ang) * speed,
px: 0, py: 0,
facing: ang,
damage: opts.damage ?? 1,
hp: opts.hp ?? 1,
life: opts.life ?? 24,
radius: opts.radius ?? 24,
gravity: opts.gravity ?? false,
collidesShips: opts.collidesShips ?? true,
collidesShots: opts.collidesShots ?? true,
behavior: opts.behavior ?? null,
data: opts.data ?? {},
alive: true,
};
p.px = p.x; p.py = p.y;
state.projectiles.push(p);
return p;
}
// Launch offset in ship-local space: dist world units along facing +
// perpendicular offset. Returns spawn {x, y}.
export function muzzle(ship, dist, side = 0) {
const f = facingToRad(quantizeFacing(ship.facing));
return {
x: wrap(ship.x + Math.cos(f) * dist + Math.cos(f + Math.PI / 2) * side, ARENA_W),
y: wrap(ship.y + Math.sin(f) * dist + Math.sin(f + Math.PI / 2) * side, ARENA_H),
angle: f,
};
}
export function spendEnergy(state, ship, amount) {
if (ship.energy < amount) return false;
ship.energy -= amount;
return true;
}
export function gainEnergy(state, ship, amount) {
ship.energy = Math.min(ship.def.stats.maxEnergy, ship.energy + amount);
}
// All crew loss funnels through here so shields/absorption/resurrection hook in.
export function damageShip(state, ship, dmg, kind = 'hit', source = null) {
if (!ship.alive || dmg <= 0) return 0;
const special = SPECIALS[ship.def.special.type];
if (special.absorbDamage) {
dmg = special.absorbDamage(state, ship, dmg, kind);
if (dmg <= 0) return 0;
}
const dealt = Math.min(ship.crew, dmg);
ship.crew -= dealt;
emit(state, { t: 'shipHit', side: ship.side, x: ship.x, y: ship.y, dmg: dealt, kind });
if (ship.crew <= 0) killShip(state, ship, kind);
return dealt;
}
export function gainCrew(state, ship, amount) {
ship.crew = Math.min(ship.def.stats.maxCrew, ship.crew + amount);
}
export function killShip(state, ship, kind = 'destroyed') {
if (!ship.alive) return;
ship.crew = 0;
// Passives get a chance to cancel death (Pkunk resurrection).
for (const p of ship.def.passives) {
if (PASSIVES[p].onDeath && PASSIVES[p].onDeath(state, ship)) {
emit(state, { t: 'resurrect', side: ship.side, x: ship.x, y: ship.y });
return;
}
}
ship.alive = false;
emit(state, { t: 'death', side: ship.side, x: ship.x, y: ship.y, kind });
}
export function enemyOf(state, ship) {
return state.ships[1 - ship.side];
}
// ---------------------------------------------------------------------------
// Frame update.
function applyThrustCapped(ship, ax, ay, maxThrust) {
// UQM rule: thrust may not push the speed past maxThrust, but external
// forces may; thrusting while faster than max keeps the higher of
// (previous speed, maxThrust).
const prevSpeed = Math.hypot(ship.vx, ship.vy);
ship.vx += ax; ship.vy += ay;
const speed = Math.hypot(ship.vx, ship.vy);
const cap = Math.max(prevSpeed, maxThrust);
if (speed > cap) {
const k = cap / speed;
ship.vx *= k; ship.vy *= k;
}
}
function shipFrame(state, ship) {
const w = ship.waits;
for (const k of Object.keys(w)) { if (w[k] > 0) w[k] -= 1; }
const special = SPECIALS[ship.def.special.type];
// Specials/passives that alter movement/state run before movement.
special.preprocess?.(state, ship);
for (const p of ship.def.passives) PASSIVES[p].preprocess?.(state, ship);
const s = shipStats(ship);
const pdef = shipPrimary(ship);
const primary = PRIMARIES[pdef.type];
const ov = ship.moveOverride;
const confused = (ship.fx.confusedFrames ?? 0) > 0;
if (ship.fx.confusedFrames > 0) ship.fx.confusedFrames -= 1;
// Turning (limpets slow it: each limpet adds +1 to the effective turnWait).
if (!(ov?.noTurn)) {
let dir = 0;
if (ship.input.left) dir -= 1;
if (ship.input.right) dir += 1;
if (confused && dir !== 0 && state.rng() < 0.5) dir = -dir;
if (confused && dir === 0 && state.rng() < 0.5) dir = state.rng() < 0.5 ? -1 : 1;
if (dir !== 0) {
const limpets = ship.fx.limpets ?? 0;
const turnPerFrame = FACING_STEP / (s.turnWait + 1 + limpets);
ship.facing += dir * turnPerFrame;
}
}
// Thrust.
const thrusting = ship.input.thrust || ov?.autoThrust;
if (!(ov?.noThrust) && thrusting && w.thrust === 0) {
w.thrust = (ov?.thrustWait ?? s.thrustWait) + 1;
const limpets = ship.fx.limpets ?? 0;
const thrustScale = 1 / (1 + limpets * 0.35);
let f = facingToRad(quantizeFacing(ship.facing));
if (ov?.reverseThrust) f += Math.PI;
const inc = (ov?.thrustIncrement ?? s.thrustIncrement) * thrustScale;
const max = (ov?.maxThrust ?? s.maxThrust) * thrustScale;
if (ov?.setVelocity) {
ship.vx = Math.cos(f) * max; ship.vy = Math.sin(f) * max;
} else {
applyThrustCapped(ship, Math.cos(f) * inc, Math.sin(f) * inc, max);
}
emit(state, { t: 'thrust', side: ship.side, x: ship.x, y: ship.y, facing: ship.facing });
}
// Primary weapon.
if (ship.input.fire && w.weapon === 0 && !(ov?.noFire)) {
const cost = pdef.energyCost;
if (primary.canFire?.(state, ship) !== false && ship.energy >= cost) {
spendEnergy(state, ship, cost);
w.weapon = pdef.refireWait + 1;
primary.fire(state, ship, pdef);
emit(state, { t: 'fire', side: ship.side, weapon: pdef.type, sfx: pdef.sfxClass });
}
}
primary.tick?.(state, ship, pdef);
// Special.
if (ship.input.special && w.special === 0) {
const cost = ship.def.special.energyCost;
if (special.canFire?.(state, ship) !== false && ship.energy >= cost) {
if (special.activate(state, ship, ship.def.special) !== false) {
spendEnergy(state, ship, cost);
w.special = ship.def.special.refireWait + 1;
emit(state, { t: 'special', side: ship.side, special: ship.def.special.type, sfx: ship.def.special.sfxClass });
}
}
}
special.tick?.(state, ship, ship.def.special);
// Energy regeneration (can be negative: Androsynth blazer degeneration).
if (w.energy === 0) {
w.energy = (ship.fx.energyWaitOverride ?? s.energyWait) + 1;
const regen = ship.fx.energyRegenOverride ?? s.energyRegen;
if (regen > 0) gainEnergy(state, ship, regen);
else if (regen < 0) ship.energy = Math.max(0, ship.energy + regen);
}
ship.moveOverride = null;
}
function gravityFrame(state, body) {
const dx = tdelta(body.x, state.planet.x, ARENA_W);
const dy = tdelta(body.y, state.planet.y, ARENA_H);
const r2 = Math.max(dx * dx + dy * dy, TUNE.PLANET_GRAVITY_MIN_R * TUNE.PLANET_GRAVITY_MIN_R);
const a = TUNE.PLANET_GRAVITY / r2;
const r = Math.sqrt(r2);
body.vx += (dx / r) * a;
body.vy += (dy / r) * a;
}
function integrate(body) {
body.px = body.x; body.py = body.y;
body.x = wrap(body.x + body.vx, ARENA_W);
body.y = wrap(body.y + body.vy, ARENA_H);
}
function planetCollision(state, ship) {
const d = tdist(ship.x, ship.y, state.planet.x, state.planet.y);
const minD = state.planet.radius + ship.def.radius * 0.6;
if (d >= minD) return;
const nx = tdelta(state.planet.x, ship.x, ARENA_W) / (d || 1);
const ny = tdelta(state.planet.y, ship.y, ARENA_H) / (d || 1);
const vDotN = ship.vx * nx + ship.vy * ny;
if (vDotN < 0) {
ship.vx -= (1 + TUNE.PLANET_BOUNCE) * vDotN * nx;
ship.vy -= (1 + TUNE.PLANET_BOUNCE) * vDotN * ny;
}
ship.vx += nx * TUNE.PLANET_EJECT;
ship.vy += ny * TUNE.PLANET_EJECT;
ship.x = wrap(state.planet.x + nx * minD, ARENA_W);
ship.y = wrap(state.planet.y + ny * minD, ARENA_H);
// Repeated scrapes within the cooldown window bounce but don't re-damage,
// so a gravity-pinned ship can't be ground to death in a second.
if (!(ship.fx.planetHitFrame > state.frame - TUNE.PLANET_DAMAGE_COOLDOWN)) {
ship.fx.planetHitFrame = state.frame;
damageShip(state, ship, TUNE.PLANET_CREW_DAMAGE, 'planet');
}
emit(state, { t: 'planetBounce', side: ship.side, x: ship.x, y: ship.y });
}
function shipShipCollision(state) {
const [A, B] = state.ships;
if (!A.alive || !B.alive) return;
if (A.fx.phasing || B.fx.phasing) return;
const minD = A.def.radius + B.def.radius;
// Swept: track A relative to B across the frame so fast closing speeds
// can't step through each other.
const relX1 = tdelta(B.px, A.px, ARENA_W);
const relY1 = tdelta(B.py, A.py, ARENA_H);
const relX2 = tdelta(B.x, A.x, ARENA_W);
const relY2 = tdelta(B.y, A.y, ARENA_H);
const swept = (() => {
const dx = relX2 - relX1;
const dy = relY2 - relY1;
const len2 = dx * dx + dy * dy;
let t = len2 > 0 ? -(relX1 * dx + relY1 * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const ex = relX1 + dx * t;
const ey = relY1 + dy * t;
return ex * ex + ey * ey < minD * minD;
})();
if (!swept) return;
const d2 = tdist2(A.x, A.y, B.x, B.y);
const d = Math.sqrt(d2) || 1;
const nx = tdelta(A.x, B.x, ARENA_W) / d;
const ny = tdelta(A.y, B.y, ARENA_H) / d;
const relV = (B.vx - A.vx) * nx + (B.vy - A.vy) * ny;
const mA = A.def.stats.mass || 1;
const mB = B.def.stats.mass || 1;
if (relV < 0) {
const j = (-(1 + TUNE.SHIP_COLLISION_RESTITUTION) * relV) / (1 / mA + 1 / mB);
A.vx -= (j / mA) * nx; A.vy -= (j / mA) * ny;
B.vx += (j / mB) * nx; B.vy += (j / mB) * ny;
}
// Positional separation, split by inverse mass (only when still overlapping).
const overlap = minD - d;
if (overlap > 0) {
const total = 1 / mA + 1 / mB;
A.x = wrap(A.x - nx * overlap * ((1 / mA) / total), ARENA_W);
A.y = wrap(A.y - ny * overlap * ((1 / mA) / total), ARENA_H);
B.x = wrap(B.x + nx * overlap * ((1 / mB) / total), ARENA_W);
B.y = wrap(B.y + ny * overlap * ((1 / mB) / total), ARENA_H);
}
if (Math.abs(relV) > TUNE.SHIP_COLLISION_DAMAGE_SPEED) {
// Contact-damage specials (Androsynth blazer, ZFP tongue handled as beam)
// hook in via onRam; default is 1 crew each.
const dmgA = SPECIALS[B.def.special.type].ramDamage?.(state, B) ?? 1;
const dmgB = SPECIALS[A.def.special.type].ramDamage?.(state, A) ?? 1;
damageShip(state, A, dmgA, 'collision');
damageShip(state, B, dmgB, 'collision');
}
emit(state, { t: 'shipsCollide', x: wrap(A.x + nx * A.def.radius, ARENA_W), y: wrap(A.y + ny * A.def.radius, ARENA_H) });
}
function projectileFrame(state, p) {
if (p.life > 0) p.life -= 1;
if (p.life === 0) { p.alive = false; return; }
p.behavior?.(state, p);
if (!p.alive) return;
if (p.gravity) gravityFrame(state, p);
integrate(p);
// Planet swallows shots.
if (tdist2(p.x, p.y, state.planet.x, state.planet.y) < state.planet.radius * state.planet.radius) {
p.alive = false;
emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: 'planet' });
return;
}
// Ships (swept over this frame's travel so fast shots can't tunnel).
if (p.collidesShips) {
for (const ship of state.ships) {
if (!ship.alive || ship.side === p.owner) continue;
if (ship.fx.phasing) continue;
const rr = ship.def.radius + p.radius;
if (segHitsCircle(p.px, p.py, p.x, p.y, ship.x, ship.y, rr)) {
if (p.data.onHitShip) { p.data.onHitShip(state, p, ship); } else {
damageShip(state, ship, p.damage, p.type);
}
// hp is durability against other shots; hitting a ship detonates the
// projectile outright unless it is explicitly piercing.
if (!p.data.piercing) p.alive = false;
emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: p.type, dmg: p.damage });
if (!p.alive) return;
}
}
}
// Asteroids block shots.
for (const ast of state.asteroids) {
if (!ast.alive) continue;
const rr = ast.radius + p.radius;
if (segHitsCircle(p.px, p.py, p.x, p.y, ast.x, ast.y, rr)) {
destroyAsteroid(state, ast, p.owner);
p.hp -= 1;
if (p.hp <= 0) { p.alive = false; return; }
}
}
}
export function destroyAsteroid(state, ast, bySide = null) {
if (!ast.alive) return;
ast.alive = false;
ast.respawnIn = TUNE.ASTEROID_RESPAWN_FRAMES;
emit(state, { t: 'asteroidBreak', x: ast.x, y: ast.y });
// Slylandro harvest passive.
for (const ship of state.ships) {
if (ship.alive && ship.side === bySide) {
for (const pv of ship.def.passives) PASSIVES[pv].onAsteroidKill?.(state, ship, ast);
}
}
}
function projectileProjectileCollisions(state) {
const ps = state.projectiles;
for (let i = 0; i < ps.length; i += 1) {
const a = ps[i];
if (!a.alive || !a.collidesShots) continue;
for (let j = i + 1; j < ps.length; j += 1) {
const b = ps[j];
if (!b.alive || !b.collidesShots || a.owner === b.owner) continue;
const rr = a.radius + b.radius;
if (tdist2(a.x, a.y, b.x, b.y) < rr * rr) {
a.hp -= b.damage > 0 ? Math.max(1, b.damage) : 1;
b.hp -= a.damage > 0 ? Math.max(1, a.damage) : 1;
if (a.hp <= 0) a.alive = false;
if (b.hp <= 0) b.alive = false;
emit(state, { t: 'shotHit', x: a.x, y: a.y, kind: 'clash' });
if (!a.alive) break;
}
}
}
}
function asteroidFrame(state, ast) {
if (!ast.alive) {
if (ast.respawnIn > 0) {
ast.respawnIn -= 1;
if (ast.respawnIn === 0) {
const idx = state.asteroids.indexOf(ast);
state.asteroids.splice(idx, 1);
spawnAsteroid(state, true);
}
}
return;
}
gravityFrame(state, ast);
ast.rot += ast.spin;
integrate(ast);
if (tdist2(ast.x, ast.y, state.planet.x, state.planet.y)
< (state.planet.radius + ast.radius) ** 2) {
destroyAsteroid(state, ast, null);
return;
}
for (const ship of state.ships) {
if (!ship.alive || ship.fx.phasing) continue;
const rr = ship.def.radius + ast.radius;
if (tdist2(ast.x, ast.y, ship.x, ship.y) < rr * rr) {
// Harmless bump: the rock shatters and nudges the ship.
ship.vx += ast.vx * 0.15; ship.vy += ast.vy * 0.15;
destroyAsteroid(state, ast, ship.side);
return;
}
}
}
function frame(state) {
state.frame += 1;
for (const ship of state.ships) {
if (ship.alive) shipFrame(state, ship);
}
for (const ship of state.ships) {
if (!ship.alive) continue;
if (!ship.fx.noGravity) gravityFrame(state, ship);
integrate(ship);
}
for (const ship of state.ships) {
if (ship.alive) planetCollision(state, ship);
}
shipShipCollision(state);
for (const p of state.projectiles) { if (p.alive) projectileFrame(state, p); }
projectileProjectileCollisions(state);
for (const ast of state.asteroids.slice()) asteroidFrame(state, ast);
state.projectiles = state.projectiles.filter((p) => p.alive);
if (!state.over) {
const deadA = !state.ships[0].alive;
const deadB = !state.ships[1].alive;
if (deadA || deadB) {
state.over = true;
state.winner = deadA && deadB ? 'draw' : (deadA ? 1 : 0);
emit(state, { t: 'battleOver', winner: state.winner });
} else if (state.frame >= TUNE.MAX_BATTLE_FRAMES) {
state.over = true;
state.winner = 'draw';
emit(state, { t: 'battleOver', winner: 'draw' });
}
}
}
// Advances the battle by dtMs of real time in fixed 24 fps frames and returns
// the events that occurred. `state.alpha` afterwards is the interpolation
// fraction [0..1) between the previous and current frame for rendering.
export function stepMatch(state, dtMs) {
if (!state.over) {
state.accumulatorMs += dtMs;
let frames = 0;
while (state.accumulatorMs >= STEP_MS && frames < TUNE.MAX_FRAMES_PER_STEP) {
state.accumulatorMs -= STEP_MS;
frame(state);
frames += 1;
if (state.over) break;
}
if (state.accumulatorMs >= STEP_MS) state.accumulatorMs %= STEP_MS;
}
state.alpha = Math.min(state.accumulatorMs / STEP_MS, 1);
const events = state.events;
state.events = [];
return events;
}
// ---------------------------------------------------------------------------
// Camera helpers (pure math so verify can cover them; the scene applies them).
export const ZOOM = {
CLOSE: 1920 / 2048, // world->screen px; ~UQM view 1
MED: 1920 / 4096, // ~UQM view 2
FAR: 1920 / 7680, // whole-arena width view
// Separation bands (world units) across which the zoom glides.
BAND_1: [900, 1900],
BAND_2: [2300, 4200],
SMOOTH_MS: 180,
};
export function smoothstep(a, b, x) {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
// Ship separation -> target zoom (screen px per world unit).
export function targetZoom(dist) {
const t1 = smoothstep(ZOOM.BAND_1[0], ZOOM.BAND_1[1], dist);
const t2 = smoothstep(ZOOM.BAND_2[0], ZOOM.BAND_2[1], dist);
const z = ZOOM.CLOSE + (ZOOM.MED - ZOOM.CLOSE) * t1;
return z + (ZOOM.FAR - z) * t2;
}
// Frame-rate independent exponential approach.
export function smoothTowards(current, target, dtMs, tauMs = ZOOM.SMOOTH_MS) {
return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
}