807 lines
28 KiB
JavaScript
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));
|
|
}
|