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