// Weapon, special-ability and passive behavior registries for Star Control // Super Melee. No Phaser dependency. Ship JSON entries reference these by // string type; all numbers come from the JSON so the functions here are pure // mechanics. Adding a new ship usually means JSON only — a new function lands // here only for a genuinely new mechanic. // // Contracts: // PRIMARIES[type] = { fire(state, ship, def), canFire?, init?, tick? } // SPECIALS[type] = { activate(state, ship, def) -> false aborts (no cost), // canFire?, init?, tick?, preprocess?, // absorbDamage?(state, ship, dmg, kind) -> remaining dmg, // ramDamage?(state, attacker) -> crew dmg dealt by contact } // PASSIVES[name] = { init?, preprocess?, onDeath? -> true cancels death, // onAsteroidKill? } // Distances/speeds are UQM world units (1 display px = 4 world units); waits // and lifetimes are 24 fps frame counts. import { wrap, tdelta, tdist, tdist2, angleDelta, interceptAngle, quantizeFacing, facingToRad, spawnProjectile, muzzle, damageShip, gainCrew, gainEnergy, killShip, enemyOf, emit, destroyAsteroid, shipStats, ARENA_W, ARENA_H, FACING_STEP, } from './StarControlLogic.js'; // --------------------------------------------------------------------------- // Shared helpers. // Steers a projectile toward a target angle at a limited turn rate and keeps // its velocity aligned with its facing. function steerProjectile(p, targetAngle, turnPerFrame, speed) { const d = angleDelta(p.facing, targetAngle); const step = Math.max(-turnPerFrame, Math.min(turnPerFrame, d)); p.facing += step; p.vx = Math.cos(p.facing) * speed; p.vy = Math.sin(p.facing) * speed; } function bearingTo(from, to) { return Math.atan2(tdelta(from.y, to.y, ARENA_H), tdelta(from.x, to.x, ARENA_W)); } // Ray-vs-circle: distance along the ray (from x,y toward angle) where it first // hits the circle, or Infinity. Toroidal-aware over the short delta. function rayCircle(x, y, angle, cx, cy, radius) { const dx = tdelta(x, cx, ARENA_W); const dy = tdelta(y, cy, ARENA_H); const ux = Math.cos(angle); const uy = Math.sin(angle); const proj = dx * ux + dy * uy; if (proj < 0) return Infinity; const perp2 = dx * dx + dy * dy - proj * proj; const r2 = radius * radius; if (perp2 > r2) return Infinity; return proj - Math.sqrt(r2 - perp2); } // Everything a beam/point-defense zap can hit that belongs to the enemy. function beamTargets(state, ship) { const targets = []; const enemy = enemyOf(state, ship); if (enemy.alive && !enemy.fx.phasing) targets.push({ obj: enemy, ship: true }); for (const p of state.projectiles) { if (p.alive && p.owner !== ship.side && p.collidesShots) targets.push({ obj: p }); } for (const ast of state.asteroids) { if (ast.alive) targets.push({ obj: ast, asteroid: true }); } return targets; } function applyBeamHit(state, ship, hit, damage) { if (hit.ship) { damageShip(state, hit.obj, damage, 'beam'); } else if (hit.asteroid) { destroyAsteroid(state, hit.obj, ship.side); } else { hit.obj.hp -= damage; if (hit.obj.hp <= 0) hit.obj.alive = false; emit(state, { t: 'shotHit', x: hit.obj.x, y: hit.obj.y, kind: 'zapped' }); } } // Instant hit-scan along `angle` up to `range`; damages the first thing hit. // Returns the hit (or null) and emits the beam event. function hitscan(state, ship, angle, range, damage, color) { const targets = beamTargets(state, ship); let best = null; let bestDist = range; for (const t of targets) { const r = t.obj.radius ?? t.obj.def?.radius ?? 24; const d = rayCircle(ship.x, ship.y, angle, t.obj.x, t.obj.y, r); if (d < bestDist) { bestDist = d; best = { ...t, dist: d }; } } emit(state, { t: 'beam', side: ship.side, x1: ship.x, y1: ship.y, x2: wrap(ship.x + Math.cos(angle) * bestDist, ARENA_W), y2: wrap(ship.y + Math.sin(angle) * bestDist, ARENA_H), color: color ?? 'laser', hit: !!best, }); if (best) applyBeamHit(state, ship, best, damage); return best; } // Spawned combat unit (fighter / marine / DOGI / zapsat family): a projectile // with hp and a per-frame brain. function spawnUnit(state, ship, opts) { return spawnProjectile(state, ship, { kind: 'unit', collidesShips: false, collidesShots: true, gravity: false, ...opts, }); } function unitsOf(state, side, type) { return state.projectiles.filter((p) => p.alive && p.owner === side && p.type === type); } function launchHomingMissile(state, ship, def, x, y, angle) { // UQM tracking: every (trackWait+1) frames the missile turns one full 1/16 // facing toward the intercept course. const trackWait = def.trackWait ?? 3; return spawnProjectile(state, ship, { type: def.projectileType ?? 'missile', x, y, angle, speed: def.speed, damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames, radius: def.radius ?? 28, data: def.data ?? {}, behavior(st, p) { if (st.frame % (trackWait + 1) !== 0) return; const enemy = st.ships[1 - p.owner]; if (!enemy.alive || enemy.fx.cloaked || enemy.fx.phasing) return; steerProjectile(p, interceptAngle(p.x, p.y, enemy, def.speed), FACING_STEP, def.speed); }, }); } // --------------------------------------------------------------------------- // PRIMARY weapon types. export const PRIMARIES = { // Straight-line shot(s). Params: damage, speed, lifeFrames, hp, radius?, // count?, spreadDeg? (fan around facing), anglesDeg? (absolute per-shot // offsets, e.g. Pkunk [0, 120, -120]), jitterDeg? (random per shot), // offsets? ([[fwd, side], ...] display px), addShipVelocity?, recoil? // (units/frame kick opposite the shot), muzzleDist? (display px), // turretArcDeg? (auto-aim at the enemy within this cone — Orz turret). projectile: { fire(state, ship, def) { const count = def.count ?? (def.anglesDeg ? def.anglesDeg.length : 1); const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]]; const spread = ((def.spreadDeg ?? 0) * Math.PI) / 180; for (let i = 0; i < count; i += 1) { const [fwd, side] = offsets[i % offsets.length]; const m = muzzle(ship, fwd * 4, side * 4); let ang = m.angle; if (def.anglesDeg) ang += (def.anglesDeg[i] * Math.PI) / 180; else if (count > 1 && spread > 0) ang += spread * (i / (count - 1) - 0.5); if (def.jitterDeg) ang += ((state.rng() - 0.5) * 2 * def.jitterDeg * Math.PI) / 180; if (def.turretArcDeg) { const enemy = enemyOf(state, ship); if (enemy.alive && !enemy.fx.cloaked) { const want = interceptAngle(m.x, m.y, enemy, def.speed); const off = angleDelta(m.angle, want); const arc = (def.turretArcDeg * Math.PI) / 180; if (Math.abs(off) <= arc) ang = want; } } spawnProjectile(state, ship, { type: def.projectileType ?? 'shot', x: m.x, y: m.y, angle: ang, vx: Math.cos(ang) * def.speed + (def.addShipVelocity ? ship.vx : 0), vy: Math.sin(ang) * def.speed + (def.addShipVelocity ? ship.vy : 0), damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames, radius: def.radius ?? 24, }); } if (def.recoil) { const f = facingToRad(quantizeFacing(ship.facing)); ship.vx -= Math.cos(f) * def.recoil; ship.vy -= Math.sin(f) * def.recoil; } }, }, // Seeking shot. Extra params: trackWait (frames between course corrections). homing: { fire(state, ship, def) { const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]]; for (const [fwd, side] of offsets) { const m = muzzle(ship, fwd * 4, side * 4); launchHomingMissile(state, ship, def, m.x, m.y, m.angle); } }, }, // Instant hit-scan. Params: range (display px), damage, autoAim? (nearest // target in range regardless of facing), color?. beam: { fire(state, ship, def) { const range = def.range * 4; if (def.autoAim) { const enemy = enemyOf(state, ship); let best = null; let bestD2 = range * range; const candidates = []; if (enemy.alive && !enemy.fx.phasing) candidates.push(enemy); for (const p of state.projectiles) { if (p.alive && p.owner !== ship.side && p.collidesShots) candidates.push(p); } for (const obj of candidates) { const d2 = tdist2(ship.x, ship.y, obj.x, obj.y); if (d2 < bestD2) { bestD2 = d2; best = obj; } } if (!best) { const f = facingToRad(quantizeFacing(ship.facing)); hitscan(state, ship, f, range, def.damage, def.color); return; } hitscan(state, ship, bearingTo(ship, best), range, def.damage, def.color); return; } hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range, def.damage, def.color); }, }, // Umgah antimatter cone: a stationary damage zone held in front of the ship, // respawned every frame while firing (CONE_LIFE 1 in UQM). Destroys shots. // Params: damage, radius, muzzleDist (display px). cone: { fire(state, ship, def) { const m = muzzle(ship, (def.muzzleDist ?? 32) * 4, 0); spawnProjectile(state, ship, { type: 'cone', x: m.x, y: m.y, angle: m.angle, vx: ship.vx, vy: ship.vy, damage: def.damage ?? 1, hp: def.hp ?? 100, life: def.lifeFrames ?? 2, radius: def.radius ?? 120, }); }, }, // Melnorme pump-up blaster: first press starts a charge; holding pumps it a // level every levelFrames (spending energyCost again); releasing fires. // Params: speed, lifeFrames, damagePerLevel, maxLevel, levelFrames. chargeShot: { canFire(state, ship) { return !ship.fx.pump; }, fire(state, ship, def) { ship.fx.pump = { level: 0, frames: 0 }; }, tick(state, ship, def) { const pump = ship.fx.pump; if (!pump) return; if (ship.input.fire) { pump.frames += 1; if (pump.frames >= (def.levelFrames ?? 72) && pump.level < (def.maxLevel ?? 4) && ship.energy >= def.energyCost) { ship.energy -= def.energyCost; pump.level += 1; pump.frames = 0; emit(state, { t: 'pump', side: ship.side, level: pump.level }); } return; } // Released: let it fly. const m = muzzle(ship, (def.muzzleDist ?? 24) * 4, 0); spawnProjectile(state, ship, { type: 'pumpshot', x: m.x, y: m.y, angle: m.angle, speed: def.speed, damage: (def.damagePerLevel ?? 2) * (pump.level + 1), hp: pump.level + 1, life: def.lifeFrames ?? 10, radius: 20 + pump.level * 14, }); emit(state, { t: 'fire', side: ship.side, weapon: 'chargeShot', sfx: def.sfxClass }); ship.fx.pump = null; }, }, // Chenjesu photon crystal: flies while the button is held; releasing the // button shatters every live shard into a ring of fragments. // Params: speed, lifeFrames, damage, hp, fragment{count,speed,lifeFrames,damage}. crystalShard: { fire(state, ship, def) { const m = muzzle(ship, (def.muzzleDist ?? 12) * 4, 0); spawnProjectile(state, ship, { type: 'shard', x: m.x, y: m.y, angle: m.angle, speed: def.speed, damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames, radius: def.radius ?? 30, }); }, tick(state, ship, def) { if (ship.input.fire) return; const frag = def.fragment ?? {}; for (const p of state.projectiles) { if (!p.alive || p.owner !== ship.side || p.type !== 'shard') continue; p.alive = false; const n = frag.count ?? 8; for (let i = 0; i < n; i += 1) { const a = (i / n) * Math.PI * 2; spawnProjectile(state, ship, { type: 'fragment', x: p.x, y: p.y, angle: a, speed: frag.speed ?? 64, damage: frag.damage ?? 2, hp: 1, life: frag.lifeFrames ?? 10, radius: 16, }); } emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: 'shatter' }); } }, }, // Kohr-Ah spinning blades: launched forward, then steered toward wherever // the Marauder points while the button is held. guidedBlades: { fire(state, ship, def) { const m = muzzle(ship, (def.muzzleDist ?? 9) * 4, 0); spawnProjectile(state, ship, { type: 'blade', x: m.x, y: m.y, angle: m.angle, speed: def.speed, damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames, radius: def.radius ?? 30, behavior(st, p) { const owner = st.ships[p.owner]; if (!owner.alive || !owner.input.fire) return; steerProjectile(p, facingToRad(quantizeFacing(owner.facing)), FACING_STEP / 2, def.speed); }, }); }, }, // Slylandro lightning: a hit-scan bolt of random length. // Params: minRange/maxRange (display px), damage. lightning: { fire(state, ship, def) { const range = (def.minRange ?? 32) + state.rng() * ((def.maxRange ?? 160) - (def.minRange ?? 32)); hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range * 4, def.damage, 'lightning'); }, }, }; // --------------------------------------------------------------------------- // SPECIAL ability types. export const SPECIALS = { // Earthling Cruiser: point-defense laser. Zaps up to params.maxTargets // enemy projectiles in range; hits the enemy ship for the same damage if it // is the only thing in reach. Params: range (display px), damage, maxTargets. pointDefense: { activate(state, ship, def) { const range = (def.params?.range ?? 100) * 4; const damage = def.params?.damage ?? 1; const maxTargets = def.params?.maxTargets ?? 4; const inRange = []; for (const p of state.projectiles) { if (!p.alive || p.owner === ship.side || !p.collidesShots) continue; const d = tdist(ship.x, ship.y, p.x, p.y); if (d <= range) inRange.push({ obj: p, dist: d }); } inRange.sort((a, b) => a.dist - b.dist); let zapped = 0; for (const t of inRange.slice(0, maxTargets)) { applyBeamHit(state, ship, t, damage); emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: t.obj.x, y2: t.obj.y, side: ship.side }); zapped += 1; } if (zapped === 0) { const enemy = enemyOf(state, ship); if (enemy.alive && !enemy.fx.phasing && tdist(ship.x, ship.y, enemy.x, enemy.y) <= range + enemy.def.radius) { damageShip(state, enemy, damage, 'beam'); emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y, side: ship.side }); zapped += 1; } } return zapped > 0 ? true : false; // no cost when nothing in reach }, }, // Shofixti Scout: Glory Device. Self-destruct with radius falloff damage // (UQM: MAX_DESTRUCTION * (range - d) / range + 1 within range). // Params: range (display px), maxDamage. glory: { activate(state, ship, def) { const range = (def.params?.range ?? 180) * 4; const maxDamage = def.params?.maxDamage ?? 18; emit(state, { t: 'glory', x: ship.x, y: ship.y, range }); const enemy = enemyOf(state, ship); if (enemy.alive) { const d = tdist(ship.x, ship.y, enemy.x, enemy.y); if (d < range) { const dmg = Math.floor((maxDamage * (range - d)) / range) + 1; damageShip(state, enemy, dmg, 'glory'); } } for (const p of state.projectiles) { if (p.alive && tdist(ship.x, ship.y, p.x, p.y) < range) p.alive = false; } for (const ast of state.asteroids) { if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range) { destroyAsteroid(state, ast, ship.side); } } killShip(state, ship, 'glory'); return true; }, }, // Ur-Quan Dreadnought: autonomous fighters. Each launch costs 1 crew (plus // energy); fighters fly out, harass the enemy with a short-range laser, then // return to dock (restoring the crew member). Params: speed, lifeFrames, // outFrames, laserRange (display px), laserDamage, weaponWait, hp. fighters: { canFire(state, ship) { return ship.crew > 1; // never launch the last crew member }, activate(state, ship, def) { const p = def.params ?? {}; ship.crew -= 1; const m = muzzle(ship, -ship.def.radius, 0); // launch from the stern const speed = p.speed ?? 32; spawnUnit(state, ship, { type: 'fighter', x: m.x, y: m.y, angle: m.angle + Math.PI, speed, damage: 0, hp: p.hp ?? 1, life: p.lifeFrames ?? 400, radius: p.radius ?? 36, data: { speed, outFrames: p.outFrames ?? 125, laserRange: (p.laserRange ?? 72) * 4, laserDamage: p.laserDamage ?? 1, weaponWait: p.weaponWait ?? 8, cooldown: 0, age: 0, }, behavior: fighterBrain, }); return true; }, }, // Ilwrath Avenger: cloaking device. Toggles on; firing the primary (or // re-triggering the special) drops it. cloak: { activate(state, ship) { ship.fx.cloaked = !ship.fx.cloaked; emit(state, { t: 'cloak', side: ship.side, on: ship.fx.cloaked }); return true; }, tick(state, ship) { if (ship.fx.cloaked && ship.input.fire) { ship.fx.cloaked = false; emit(state, { t: 'cloak', side: ship.side, on: false }); } }, }, // Spathi Eluder: B.U.T.T. — a homing missile launched backwards. Params // mirror the homing primary (speed, damage, lifeFrames, trackWait, hp). rearHoming: { activate(state, ship, def) { const p = def.params ?? {}; const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI; const dist = (p.muzzleDist ?? 20) * 4; launchHomingMissile( state, ship, { speed: p.speed ?? 32, damage: p.damage ?? 2, lifeFrames: p.lifeFrames ?? 30, trackWait: p.trackWait ?? 1, hp: p.hp ?? 1, projectileType: 'butt', }, wrap(ship.x + Math.cos(f) * dist, ARENA_W), wrap(ship.y + Math.sin(f) * dist, ARENA_H), f, ); return true; }, }, // Androsynth Guardian: Blazer form. Toggles into a ramming comet — fast, // constant speed, can't shoot, drains energy (blazer degeneration); reverts // when energy empties. Params: thrust, ramDamage. blazer: { activate(state, ship) { ship.fx.blazer = !ship.fx.blazer; ship.fx.energyRegenOverride = ship.fx.blazer ? -1 : undefined; emit(state, { t: 'blazer', side: ship.side, on: ship.fx.blazer }); return true; }, preprocess(state, ship) { if (!ship.fx.blazer) return; if (ship.energy <= 0) { ship.fx.blazer = false; ship.fx.energyRegenOverride = undefined; emit(state, { t: 'blazer', side: ship.side, on: false }); return; } const p = ship.def.special.params ?? {}; ship.moveOverride = { ...(ship.moveOverride ?? {}), autoThrust: true, setVelocity: true, noFire: true, maxThrust: p.thrust ?? 60, thrustWait: 0, }; }, ramDamage(state, attacker) { return attacker.fx.blazer ? (attacker.def.special.params?.ramDamage ?? 3) : 1; }, }, // Arilou Skiff: hyperspace shunt — random relocation, keeps velocity. teleport: { activate(state, ship) { for (let tries = 0; tries < 12; tries += 1) { const x = state.rng() * ARENA_W; const y = state.rng() * ARENA_H; if (tdist(x, y, state.planet.x, state.planet.y) < state.planet.radius + 500) continue; emit(state, { t: 'teleport', side: ship.side, fromX: ship.x, fromY: ship.y, toX: x, toY: y }); ship.x = x; ship.y = y; ship.px = x; ship.py = y; return true; } return false; }, }, // Chenjesu D.O.G.I.: a spawned drone that chases the enemy and bites off // energy on contact. Params: speed, hp, drain, maxUnits, radius. dogi: { canFire(state, ship, def) { return unitsOf(state, ship.side, 'dogi').length < (ship.def.special.params?.maxUnits ?? 4); }, activate(state, ship, def) { const p = def.params ?? {}; const m = muzzle(ship, -ship.def.radius, 0); const speed = p.speed ?? 32; spawnUnit(state, ship, { type: 'dogi', x: m.x, y: m.y, angle: m.angle + Math.PI, speed, damage: 0, hp: p.hp ?? 3, life: 100000, radius: p.radius ?? 48, data: { speed, drain: p.drain ?? 10, cooldown: 0 }, behavior(st, u) { const enemy = st.ships[1 - u.owner]; if (!enemy.alive) return; if (u.data.cooldown > 0) u.data.cooldown -= 1; steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed); if (u.data.cooldown === 0 && !enemy.fx.phasing && tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) { enemy.energy = Math.max(0, enemy.energy - u.data.drain); u.data.cooldown = 24; // Bounce off after the bite. u.facing += Math.PI; u.vx = Math.cos(u.facing) * u.data.speed; u.vy = Math.sin(u.facing) * u.data.speed; emit(state, { t: 'dogiDrain', x: u.x, y: u.y, side: u.owner }); } }, }); return true; }, }, // Chmmr Avatar: tractor beam — hold to reel the enemy in. // Params: pull (units/frame^2), range (display px). tractor: { activate(state, ship, def) { const enemy = enemyOf(state, ship); if (!enemy.alive) return false; const p = def.params ?? {}; const range = (p.range ?? 1000) * 4; if (tdist(ship.x, ship.y, enemy.x, enemy.y) > range) return false; const b = bearingTo(enemy, ship); const pull = p.pull ?? 6; enemy.vx += Math.cos(b) * pull; enemy.vy += Math.sin(b) * pull; emit(state, { t: 'pdZap', side: ship.side, x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y, }); return true; }, }, // Druuge Mauler: crew furnace — shove a crewman in the engine, refill the // battery completely. furnace: { activate(state, ship) { if (ship.crew <= 1) return false; if (ship.energy >= ship.def.stats.maxEnergy) return false; ship.crew -= 1; ship.energy = ship.def.stats.maxEnergy; emit(state, { t: 'furnace', side: ship.side, x: ship.x, y: ship.y }); return true; }, }, // Kohr-Ah F.R.I.E.D.: a ring of fire that burns off incoming ordnance and // scorches a too-close enemy. Params: range (world), frames, damage, // damageEvery. fireRing: { activate(state, ship, def) { ship.fx.friedFrames = def.params?.frames ?? 32; emit(state, { t: 'fried', side: ship.side, x: ship.x, y: ship.y, range: def.params?.range ?? 520 }); return true; }, tick(state, ship, def) { if (!(ship.fx.friedFrames > 0)) return; ship.fx.friedFrames -= 1; const p = def.params ?? {}; const range = p.range ?? 520; for (const pr of state.projectiles) { if (pr.alive && pr.owner !== ship.side && pr.collidesShots && tdist(ship.x, ship.y, pr.x, pr.y) < range) { pr.alive = false; emit(state, { t: 'shotHit', x: pr.x, y: pr.y, kind: 'fried' }); } } const enemy = enemyOf(state, ship); if (enemy.alive && ship.fx.friedFrames % (p.damageEvery ?? 8) === 0 && tdist(ship.x, ship.y, enemy.x, enemy.y) < range + enemy.def.radius) { damageShip(state, enemy, p.damage ?? 1, 'fried'); } }, }, // Melnorme confusion pulse: a fat, harmless missile that scrambles the // enemy's steering on impact. Params: speed, lifeFrames, confuseFrames. confuse: { activate(state, ship, def) { const p = def.params ?? {}; const m = muzzle(ship, (p.muzzleDist ?? 16) * 4, 0); spawnProjectile(state, ship, { type: 'confuse', x: m.x, y: m.y, angle: m.angle, speed: p.speed ?? 120, damage: 0, hp: p.hp ?? 200, life: p.lifeFrames ?? 20, radius: 30, data: { onHitShip(st, pr, target) { target.fx.confusedFrames = p.confuseFrames ?? 96; emit(st, { t: 'confused', side: target.side }); }, }, }); return true; }, }, // Mmrnmhrm Transform: swap between the ship's JSON `forms` (stat + primary // overrides). Costs the battery, as in UQM. transform: { activate(state, ship) { const forms = ship.def.forms ?? []; if (forms.length === 0) return false; const next = ship.fx.formIndex ? 0 : 1; ship.fx.formIndex = next; const form = next === 0 ? null : forms[next - 1]; ship.fx.formStats = form?.stats ?? null; ship.fx.formPrimary = form?.primary ?? null; ship.fx.pump = null; emit(state, { t: 'transform', side: ship.side, form: next }); return true; }, }, // Mycon Podship: regenerate crew from the whole battery. // Params: crew (regeneration amount). regenerate: { canFire(state, ship) { return ship.crew < ship.def.stats.maxCrew; }, activate(state, ship, def) { gainCrew(state, ship, def.params?.crew ?? 4); emit(state, { t: 'regenerate', side: ship.side, x: ship.x, y: ship.y }); return true; }, }, // Orz space marines: crew sent to chew through the enemy hull. // Params: speed, hp, maxUnits, killEvery (frames), maxKills. marines: { canFire(state, ship, def) { return ship.crew > 1 && unitsOf(state, ship.side, 'marine').length < (ship.def.special.params?.maxUnits ?? 8); }, activate(state, ship, def) { const p = def.params ?? {}; ship.crew -= 1; const m = muzzle(ship, -ship.def.radius, 0); const speed = p.speed ?? 24; spawnUnit(state, ship, { type: 'marine', x: m.x, y: m.y, angle: m.angle + Math.PI, speed, damage: 0, hp: p.hp ?? 3, life: 100000, radius: p.radius ?? 30, data: { speed, attached: false, timer: 0, kills: 0 }, behavior(st, u) { const enemy = st.ships[1 - u.owner]; if (!enemy.alive) { u.alive = false; return; } if (u.data.attached) { u.x = enemy.x; u.y = enemy.y; u.px = enemy.px; u.py = enemy.py; u.vx = enemy.vx; u.vy = enemy.vy; u.collidesShots = false; // inside the hull now u.data.timer += 1; if (u.data.timer >= (ship.def.special.params?.killEvery ?? 36)) { u.data.timer = 0; damageShip(st, enemy, 1, 'marine'); u.data.kills += 1; if (u.data.kills >= (ship.def.special.params?.maxKills ?? 4)) u.alive = false; } return; } steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed); if (!enemy.fx.phasing && tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) { u.data.attached = true; emit(st, { t: 'marineBoard', side: u.owner, x: u.x, y: u.y }); } }, }); return true; }, }, // Pkunk Fury: cathartic insults restore a little energy. // Params: energy. taunt: { canFire(state, ship) { return ship.energy < ship.def.stats.maxEnergy; }, activate(state, ship, def) { gainEnergy(state, ship, def.params?.energy ?? 2); emit(state, { t: 'taunt', side: ship.side }); return true; }, }, // Slylandro Probe: consume a nearby asteroid for energy. // Params: range (display px), energy. harvest: { activate(state, ship, def) { const p = def.params ?? {}; const range = (p.range ?? 78) * 4; for (const ast of state.asteroids) { if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range + ast.radius) { // bySide null: the passive contact-harvest bonus must not also fire. destroyAsteroid(state, ast, null); gainEnergy(state, ship, p.energy ?? 8); emit(state, { t: 'harvest', side: ship.side, x: ast.x, y: ast.y }); return true; } } return false; }, }, // Supox Blade: omni-thrust — while held, the engine burns backwards. omniThrust: { activate(state, ship) { if (!ship.input.thrust) return false; // only costs energy while burning ship.moveOverride = { ...(ship.moveOverride ?? {}), reverseThrust: true }; return true; }, }, // Syreen Song: lure a slice of the enemy crew overboard as floating pods // either ship can scoop up. Params: range (display px), maxCrew, fraction. song: { activate(state, ship, def) { const enemy = enemyOf(state, ship); const p = def.params ?? {}; if (!enemy.alive || enemy.crew <= 1) return false; if (tdist(ship.x, ship.y, enemy.x, enemy.y) > (p.range ?? 208) * 4) return false; const n = Math.min( p.maxCrew ?? 8, Math.max(1, Math.floor(enemy.crew * (p.fraction ?? 0.25))), enemy.crew - 1, ); enemy.crew -= n; emit(state, { t: 'song', side: ship.side, count: n, x: enemy.x, y: enemy.y }); for (let i = 0; i < n; i += 1) { const a = state.rng() * Math.PI * 2; spawnUnit(state, ship, { type: 'crew', x: wrap(enemy.x + Math.cos(a) * (enemy.def.radius + 140), ARENA_W), y: wrap(enemy.y + Math.sin(a) * (enemy.def.radius + 140), ARENA_H), angle: a, speed: 6 + state.rng() * 8, damage: 0, hp: 1, life: 24 * 30, radius: 22, data: { age: 0 }, behavior(st, u) { // A short grace period so the victim can't instantly re-scoop them. u.data.age += 1; if (u.data.age < 24) return; for (const s of st.ships) { if (!s.alive || s.fx.phasing) continue; if (tdist(u.x, u.y, s.x, s.y) < s.def.radius + u.radius + 20) { gainCrew(st, s, 1); u.alive = false; emit(st, { t: 'crewRescued', side: s.side, x: u.x, y: u.y }); return; } } }, }); } return true; }, }, // Thraddash Torch: afterburner — rockets forward leaving burning napalm. // Params: thrust, thrustIncrement, napalm{lifeFrames,damage,radius}. afterburner: { activate(state, ship, def) { const p = def.params ?? {}; ship.moveOverride = { ...(ship.moveOverride ?? {}), autoThrust: true, thrustWait: 0, maxThrust: p.thrust ?? 64, thrustIncrement: p.thrustIncrement ?? 12, }; ship.fx.burnerTick = (ship.fx.burnerTick ?? 0) + 1; if (ship.fx.burnerTick % 2 === 0) { const n = p.napalm ?? {}; const m = muzzle(ship, -ship.def.radius - 20, 0); spawnProjectile(state, ship, { type: 'napalm', x: m.x, y: m.y, angle: m.angle, vx: 0, vy: 0, damage: n.damage ?? 2, hp: 1, life: n.lifeFrames ?? 48, radius: n.radius ?? 60, collidesShots: false, }); } return true; }, }, // Umgah Drone: retro-propulsion — instant backward hop. // Params: dist (world units per hop). retroHop: { activate(state, ship, def) { const f = facingToRad(quantizeFacing(ship.facing)); const d = def.params?.dist ?? 160; ship.x = wrap(ship.x - Math.cos(f) * d, ARENA_W); ship.y = wrap(ship.y - Math.sin(f) * d, ARENA_H); emit(state, { t: 'retro', side: ship.side, x: ship.x, y: ship.y }); return true; }, }, // Utwig Jugger: absorption shield — while raised, damage feeds the battery. // Params: frames (shield life per press). absorbShield: { activate(state, ship, def) { ship.fx.absorbFrames = def.params?.frames ?? 12; emit(state, { t: 'shield', side: ship.side, on: true }); return true; }, tick(state, ship) { if (ship.fx.absorbFrames > 0) ship.fx.absorbFrames -= 1; }, absorbDamage(state, ship, dmg) { if (!(ship.fx.absorbFrames > 0)) return dmg; gainEnergy(state, ship, dmg); emit(state, { t: 'absorbed', side: ship.side, amount: dmg }); return 0; }, }, // VUX limpets: slow homing suckers that gum up the enemy's helm (each one // permanently worsens turning/thrust). Params: speed, lifeFrames, trackWait. limpets: { activate(state, ship, def) { const p = def.params ?? {}; const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI; launchHomingMissile( state, ship, { speed: p.speed ?? 25, damage: 0, lifeFrames: p.lifeFrames ?? 80, trackWait: p.trackWait ?? 0, hp: 1, projectileType: 'limpet', data: { onHitShip(st, pr, target) { target.fx.limpets = (target.fx.limpets ?? 0) + 1; emit(st, { t: 'limpet', side: target.side, count: target.fx.limpets }); }, }, }, wrap(ship.x + Math.cos(f) * 40, ARENA_W), wrap(ship.y + Math.sin(f) * 40, ARENA_H), f, ); return true; }, }, // Yehat Terminator: force shield — total immunity while charged. // Params: frames (shield life per press). forceShield: { activate(state, ship, def) { ship.fx.shieldFrames = def.params?.frames ?? 10; emit(state, { t: 'shield', side: ship.side, on: true }); return true; }, tick(state, ship) { if (ship.fx.shieldFrames > 0) ship.fx.shieldFrames -= 1; }, absorbDamage(state, ship, dmg) { if (!(ship.fx.shieldFrames > 0)) return dmg; emit(state, { t: 'absorbed', side: ship.side, amount: dmg }); return 0; }, }, // Zoq-Fot-Pik tongue: a very short-range, very heavy lick. // Params: range (display px), damage. tongue: { activate(state, ship, def) { const p = def.params ?? {}; const hit = hitscan( state, ship, facingToRad(quantizeFacing(ship.facing)), (p.range ?? 35) * 4 + ship.def.radius, p.damage ?? 12, 'tongue', ); return hit ? true : false; // free lick when it misses }, }, }; function fighterBrain(state, p) { const d = p.data; d.age += 1; if (d.cooldown > 0) d.cooldown -= 1; const mother = state.ships[p.owner]; const enemy = state.ships[1 - p.owner]; const returning = d.age > d.outFrames || !enemy.alive || p.life < d.outFrames; const target = returning ? mother : enemy; if (!target || !target.alive) return; steerProjectile(p, bearingTo(p, target), FACING_STEP / 2, d.speed); if (returning) { if (mother.alive && tdist(p.x, p.y, mother.x, mother.y) < mother.def.radius + 80) { p.alive = false; gainCrew(state, mother, 1); emit(state, { t: 'fighterDock', side: p.owner, x: p.x, y: p.y }); } return; } if (!enemy.fx.cloaked && !enemy.fx.phasing && d.cooldown === 0 && tdist(p.x, p.y, enemy.x, enemy.y) < d.laserRange + enemy.def.radius) { d.cooldown = d.weaponWait + 1; damageShip(state, enemy, d.laserDamage, 'beam'); emit(state, { t: 'pdZap', x1: p.x, y1: p.y, x2: enemy.x, y2: enemy.y, side: p.owner }); } } // --------------------------------------------------------------------------- // PASSIVE abilities (always-on ship traits). export const PASSIVES = { // Pkunk Fury: fifty-fifty phoenix resurrection with full crew and battery. resurrect: { onDeath(state, ship) { if (state.rng() >= 0.5) return false; ship.crew = ship.def.stats.maxCrew; ship.energy = ship.def.stats.maxEnergy; return true; }, }, // Arilou Skiff: inertialess drive — stops dead without thrust, ignores // gravity. inertialess: { init(state, ship) { ship.fx.noGravity = true; }, preprocess(state, ship) { if (!ship.input.thrust) { ship.vx = 0; ship.vy = 0; } }, }, // Slylandro Probe: gravity-immune constant-velocity drive — always moving // at full speed along its facing. probeDrive: { init(state, ship) { ship.fx.noGravity = true; }, preprocess(state, ship) { const f = facingToRad(quantizeFacing(ship.facing)); const v = shipStats(ship).maxThrust; ship.vx = Math.cos(f) * v; ship.vy = Math.sin(f) * v; ship.moveOverride = { ...(ship.moveOverride ?? {}), noThrust: true }; }, onAsteroidKill(state, ship) { gainEnergy(state, ship, ship.def.special?.params?.energy ?? 8); }, }, // Chmmr Avatar ZapSats: three orbiting satellites that zap whatever strays // close (enemy shots first, then the enemy ship). zapsats: { init(state, ship) { const cfg = ship.def.zapsats ?? {}; const n = cfg.count ?? 3; for (let i = 0; i < n; i += 1) { spawnUnit(state, ship, { type: 'zapsat', x: ship.x, y: ship.y, angle: 0, speed: 0, damage: 0, hp: cfg.hp ?? 10, life: 100000, radius: 26, data: { orbit: (i / n) * Math.PI * 2, orbitR: cfg.orbitR ?? ship.def.radius + 190, zapRange: (cfg.zapRange ?? 64) * 4, zapWait: cfg.zapWait ?? 2, cooldown: 0, }, behavior(st, u) { const mother = st.ships[u.owner]; if (!mother.alive) { u.alive = false; return; } u.data.orbit += 0.045; const nx = wrap(mother.x + Math.cos(u.data.orbit) * u.data.orbitR, ARENA_W); const ny = wrap(mother.y + Math.sin(u.data.orbit) * u.data.orbitR, ARENA_H); u.px = u.x; u.py = u.y; u.x = nx; u.y = ny; u.vx = 0; u.vy = 0; if (u.data.cooldown > 0) { u.data.cooldown -= 1; return; } for (const pr of st.projectiles) { if (pr.alive && pr.owner !== u.owner && pr.collidesShots && pr.kind !== 'unit' && tdist(u.x, u.y, pr.x, pr.y) < u.data.zapRange) { pr.hp -= 1; if (pr.hp <= 0) pr.alive = false; u.data.cooldown = u.data.zapWait + 1; emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: pr.x, y2: pr.y, side: u.owner }); return; } } const enemy = st.ships[1 - u.owner]; if (enemy.alive && !enemy.fx.phasing && tdist(u.x, u.y, enemy.x, enemy.y) < u.data.zapRange + enemy.def.radius) { damageShip(st, enemy, 1, 'beam'); u.data.cooldown = u.data.zapWait + 1; emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: enemy.x, y2: enemy.y, side: u.owner }); } }, }); } }, }, // VUX Intruder: warps in right on top of its prey at the start of a battle. warpIn: { preprocess(state, ship) { if (ship.fx.warped || state.frame > 2) { ship.fx.warped = true; return; } ship.fx.warped = true; const enemy = enemyOf(state, ship); const a = state.rng() * Math.PI * 2; const d = 900; ship.x = wrap(enemy.x + Math.cos(a) * d, ARENA_W); ship.y = wrap(enemy.y + Math.sin(a) * d, ARENA_H); ship.px = ship.x; ship.py = ship.y; ship.facing = bearingTo(ship, enemy); emit(state, { t: 'teleport', side: ship.side, toX: ship.x, toY: ship.y }); }, }, };