// Real-time computer pilot for Star Control Super Melee. No Phaser // dependency. Modeled on BlockFighterAI: skill 1-10 interpolates between // anchor knob sets, and the pilot re-decides its inputs only every reactMs, // so low skill reacts like a slow human rather than a lobotomized one. // // Layered decision, first match wins the steering vote: // 1. survival — don't fall into the planet, dodge incoming tracked fire // 2. range — close/hold/open distance per the ship's engageStyle // 3. aim+fire — intercept lead with skill-scaled aim error // 4. special — per-ship tactic script keyed by ai.specialUse in the JSON import { ARENA_W, ARENA_H, TUNE, mulberry32, tdelta, tdist, angleDelta, interceptAngle, quantizeFacing, facingToRad, enemyOf, } from './StarControlLogic.js'; export const SKILL_ANCHORS = [ { skill: 1, reactMs: 550, aimErrDeg: 22, blunder: 0.35, lead: 0.0, gravityIQ: 0.2, dodge: 0.0 }, { skill: 3, reactMs: 400, aimErrDeg: 14, blunder: 0.20, lead: 0.4, gravityIQ: 0.5, dodge: 0.25 }, { skill: 5, reactMs: 280, aimErrDeg: 8, blunder: 0.10, lead: 1.0, gravityIQ: 0.8, dodge: 0.55 }, { skill: 7, reactMs: 190, aimErrDeg: 4, blunder: 0.04, lead: 1.0, gravityIQ: 1.0, dodge: 0.8 }, { skill: 10, reactMs: 110, aimErrDeg: 1, blunder: 0.0, lead: 1.0, gravityIQ: 1.0, dodge: 1.0 }, ]; export function knobsFor(skill) { const s = Math.max(1, Math.min(10, skill)); let lo = SKILL_ANCHORS[0]; let hi = SKILL_ANCHORS[SKILL_ANCHORS.length - 1]; for (const a of SKILL_ANCHORS) { if (a.skill <= s && a.skill >= lo.skill) lo = a; if (a.skill >= s && a.skill < hi.skill) hi = a; } if (lo.skill === hi.skill) return { ...lo }; const t = (s - lo.skill) / (hi.skill - lo.skill); const mix = {}; for (const k of Object.keys(lo)) mix[k] = lo[k] + (hi[k] - lo[k]) * t; mix.skill = s; return mix; } export function createAI({ skill = 5, side = 1, seed = 1 } = {}) { return { side, skill, knobs: knobsFor(skill), rng: mulberry32((seed * 2654435761) >>> 0), clockMs: 0, nextDecisionMs: 0, input: { left: false, right: false, thrust: false, fire: false, special: false }, lastKnown: null, // last visible enemy position (cloak memory) aimBias: 0, // current aim-error sample, re-rolled per decision }; } // --- steering helper: sets left/right toward a desired absolute heading. function steerTo(ai, ship, heading, deadzone) { const d = angleDelta(ship.facing, heading); ai.input.left = d < -deadzone; ai.input.right = d > deadzone; return Math.abs(d); } function primaryReach(def) { const p = def.primary; if (p.type === 'beam') return p.range * 4; if (p.maxRange) return p.maxRange * 4 * 0.8; // hitscan bolts (lightning) if (p.type === 'cone') return (p.muzzleDist ?? 32) * 4 + (p.radius ?? 120); return (p.muzzleDist ?? 12) * 4 + (p.speed ?? 0) * (p.lifeFrames ?? 0); } // Nearest live enemy projectile that is closing on the ship. function incomingThreat(state, ship) { let best = null; let bestD = Infinity; for (const p of state.projectiles) { if (!p.alive || p.owner === ship.side || p.kind === 'unit') continue; const d = tdist(ship.x, ship.y, p.x, p.y); if (d > 1600 || d >= bestD) continue; const closing = tdelta(p.x, ship.x, ARENA_W) * p.vx + tdelta(p.y, ship.y, ARENA_H) * p.vy; if (closing > 0) { best = p; bestD = d; } } return best; } // Predicts whether coasting for `frames` brings the ship dangerously close to // the planet. function planetDanger(state, ship, frames) { let { x, y } = ship; const { vx, vy } = ship; const danger = state.planet.radius + ship.def.radius + 260; for (let f = 0; f < frames; f += 8) { x += vx * 8; y += vy * 8; if (tdist(x, y, state.planet.x, state.planet.y) < danger) return true; } return false; } // --- special-ability tactics, keyed by the ship JSON's ai.specialUse. const SPECIAL_TACTICS = { pointDefenseAuto(ai, state, ship) { const range = (ship.def.special.params?.range ?? 100) * 4 * 1.15; for (const p of state.projectiles) { if (p.alive && p.owner !== ship.side && tdist(ship.x, ship.y, p.x, p.y) < range) return true; } return false; }, gloryFinale(ai, state, ship) { const enemy = enemyOf(state, ship); const range = (ship.def.special.params?.range ?? 180) * 4; const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); // Boom when point-blank, or when nearly dead and still in blast range. return dist < range * 0.45 || (ship.crew <= 2 && dist < range * 0.85); }, swarm(ai, state, ship) { const enemy = enemyOf(state, ship); const out = state.projectiles.filter((p) => p.alive && p.type === 'fighter' && p.owner === ship.side).length; return ship.crew > 8 && out < 4 && ship.energy > ship.def.special.energyCost + 10 && tdist(ship.x, ship.y, enemy.x, enemy.y) < 4200; }, cloakApproach(ai, state, ship) { const enemy = enemyOf(state, ship); const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); if (ship.fx.cloaked) return false; // stay dark until the flame flies return dist > 1400; }, kiteRear(ai, state, ship) { const enemy = enemyOf(state, ship); const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); if (dist > 1600) return false; const bearing = Math.atan2(tdelta(ship.y, enemy.y, ARENA_H), tdelta(ship.x, enemy.x, ARENA_W)); return Math.abs(angleDelta(ship.facing, bearing)) > 1.9; // enemy behind us }, onCooldown(ai, state, ship) { return ship.energy >= ship.def.stats.maxEnergy * 0.85; }, never() { return false; }, blazer(ai, state, ship) { const enemy = enemyOf(state, ship); const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); if (ship.fx.blazer) return ship.energy < 3 || dist > 3200; // toggle back off return dist < 2600 && ship.energy > ship.def.stats.maxEnergy * 0.5; }, teleportEscape(ai, state, ship) { if (incomingThreat(state, ship) && ai.rng() < 0.5) return true; const enemy = enemyOf(state, ship); return ship.crew <= 2 && tdist(ship.x, ship.y, enemy.x, enemy.y) < 900; }, tractor(ai, state, ship) { const enemy = enemyOf(state, ship); const d = tdist(ship.x, ship.y, enemy.x, enemy.y); return d < 3600 && d > 500 && ship.energy > 10; }, furnace(ai, state, ship) { return ship.crew > 3 && ship.energy < (ship.def.primary.energyCost ?? 4) + 2; }, fireRingReactive(ai, state, ship) { if (ship.fx.friedFrames > 0) return false; const range = (ship.def.special.params?.range ?? 520) * 0.9; for (const p of state.projectiles) { if (p.alive && p.owner !== ship.side && p.kind !== 'unit' && tdist(ship.x, ship.y, p.x, p.y) < range) return true; } const enemy = enemyOf(state, ship); return tdist(ship.x, ship.y, enemy.x, enemy.y) < range; }, transformRange(ai, state, ship) { const enemy = enemyOf(state, ship); const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); const inYForm = ship.fx.formIndex === 1; if (!inYForm && dist > 3000) return true; // Y-wing to close the gap if (inYForm && dist < 1300) return true; // X-wing for the knife fight return false; }, regen(ai, state, ship) { return ship.crew <= ship.def.stats.maxCrew - (ship.def.special.params?.crew ?? 4) && ship.energy >= ship.def.stats.maxEnergy; }, marines(ai, state, ship) { const enemy = enemyOf(state, ship); return ship.crew > 4 && tdist(ship.x, ship.y, enemy.x, enemy.y) < 1500; }, taunt(ai, state, ship) { return ship.energy <= ship.def.stats.maxEnergy - (ship.def.special.params?.energy ?? 2); }, harvest(ai, state, ship) { if (ship.energy > ship.def.stats.maxEnergy - 8) return false; const range = (ship.def.special.params?.range ?? 78) * 4 * 1.2; return state.asteroids.some((a) => a.alive && tdist(ship.x, ship.y, a.x, a.y) < range); }, song(ai, state, ship) { const enemy = enemyOf(state, ship); return enemy.crew > 2 && tdist(ship.x, ship.y, enemy.x, enemy.y) < (ship.def.special.params?.range ?? 208) * 4 * 0.9; }, burner(ai, state, ship) { // Drive-by: light the afterburner when charging roughly at the enemy so // the napalm trail crosses their path. const enemy = enemyOf(state, ship); if (ship.energy < 6) return false; const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); const bearing = Math.atan2( tdelta(ship.y, enemy.y, ARENA_H), tdelta(ship.x, enemy.x, ARENA_W), ); return dist > 400 && dist < 2400 && Math.abs(angleDelta(ship.facing, bearing)) < 0.5; }, retro(ai, state, ship) { // Hop backward only when the enemy is on the Drone's tail — it can't // out-turn anyone, but it can out-reverse everyone. const enemy = enemyOf(state, ship); if (ship.energy < 2) return false; const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); const bearing = Math.atan2( tdelta(ship.y, enemy.y, ARENA_H), tdelta(ship.x, enemy.x, ARENA_W), ); return dist < 700 && Math.abs(angleDelta(ship.facing, bearing)) > 1.9; }, shieldReactive(ai, state, ship) { if (ship.fx.shieldFrames > 0 || ship.fx.absorbFrames > 0) return false; const enemy = enemyOf(state, ship); if (tdist(ship.x, ship.y, enemy.x, enemy.y) < 600) return true; const threat = incomingThreat(state, ship); return !!threat && tdist(ship.x, ship.y, threat.x, threat.y) < 700; }, tongueRange(ai, state, ship) { const enemy = enemyOf(state, ship); const reach = (ship.def.special.params?.range ?? 35) * 4 + ship.def.radius + enemy.def.radius; return tdist(ship.x, ship.y, enemy.x, enemy.y) < reach * 1.1; }, }; // Advances the pilot by dtMs and returns the input object to feed setInput. export function updateAI(ai, state, dtMs) { ai.clockMs += dtMs; if (ai.clockMs < ai.nextDecisionMs) return ai.input; ai.nextDecisionMs = ai.clockMs + ai.knobs.reactMs; const ship = state.ships[ai.side]; const enemy = enemyOf(state, ship); const input = ai.input; input.left = input.right = input.thrust = input.fire = input.special = false; if (!ship?.alive || state.over) return input; const rng = ai.rng; const K = ai.knobs; ai.aimBias = ((rng() - 0.5) * 2 * K.aimErrDeg * Math.PI) / 180; // Cloak memory: track the last place we actually saw the enemy. if (!enemy.fx.cloaked) ai.lastKnown = { x: enemy.x, y: enemy.y, vx: enemy.vx, vy: enemy.vy }; const target = enemy.fx.cloaked && ai.lastKnown ? ai.lastKnown : enemy; const dist = tdist(ship.x, ship.y, target.x, target.y); const bearing = Math.atan2( tdelta(ship.y, target.y, ARENA_H), tdelta(ship.x, target.x, ARENA_W), ); const hints = ship.def.ai ?? {}; const style = hints.engageStyle ?? 'chase'; const preferred = hints.preferredRange ?? 1200; // ---- 1. survival if (K.gravityIQ > 0.05 && planetDanger(state, ship, 40 + 60 * K.gravityIQ) && (hints.gravityCaution ?? 1) > rng() * 0.4) { // Thrust perpendicular to the planet bearing to swing wide. const pb = Math.atan2( tdelta(ship.y, state.planet.y, ARENA_H), tdelta(ship.x, state.planet.x, ARENA_W), ); const escape = pb + (angleDelta(pb, Math.atan2(ship.vy, ship.vx)) > 0 ? Math.PI / 2 : -Math.PI / 2); steerTo(ai, ship, escape, ship.def.turnPerFrame * 1.5); input.thrust = true; maybeSpecial(ai, state, ship, hints); return blunder(ai, input); } // Harvest ships (Slylandro Probe): when the battery runs low, graze the // asteroid field instead of pressing a fight it can't shoot. if (hints.specialUse === 'harvest' && ship.energy < ship.def.stats.maxEnergy * 0.3) { let rock = null; let rockD = Infinity; for (const a of state.asteroids) { if (!a.alive) continue; const d = tdist(ship.x, ship.y, a.x, a.y); if (d < rockD) { rockD = d; rock = a; } } if (rock) { steerTo(ai, ship, Math.atan2( tdelta(ship.y, rock.y, ARENA_H), tdelta(ship.x, rock.x, ARENA_W), ), ship.def.turnPerFrame); input.thrust = true; maybeSpecial(ai, state, ship, hints); return blunder(ai, input); } } const threat = K.dodge > 0 ? incomingThreat(state, ship) : null; if (threat && rng() < K.dodge) { const tb = Math.atan2(threat.vy, threat.vx); steerTo(ai, ship, tb + Math.PI / 2, ship.def.turnPerFrame * 1.5); input.thrust = true; maybeSpecial(ai, state, ship, hints); return blunder(ai, input); } // ---- 2 + 3. range control and aim/fire const reach = primaryReach(ship.def); const shotSpeed = ship.def.primary.speed ?? 999; const lead = K.lead >= 1 ? target : { x: target.x, y: target.y, vx: (target.vx ?? 0) * K.lead, vy: (target.vy ?? 0) * K.lead }; const aimAngle = interceptAngle(ship.x, ship.y, lead, shotSpeed) + ai.aimBias; let wantThrust = false; let heading = aimAngle; if (style === 'kite' && dist < preferred * 0.62) { heading = bearing + Math.PI; // run away wantThrust = true; } else if (style === 'ambush' && ship.fx.cloaked) { heading = bearing; // sneak straight in, guns cold wantThrust = true; } else if (dist > preferred) { heading = aimAngle; // close in along the firing solution wantThrust = dist > preferred * 0.8; } else if (style === 'chase') { heading = aimAngle; wantThrust = dist > preferred * 0.45; } const offAim = steerTo(ai, ship, heading, ship.def.turnPerFrame * 0.6); input.thrust = wantThrust; // Fire when the (quantized) nose is on the solution and the shot can reach. const noseErr = Math.abs(angleDelta(facingToRad(quantizeFacing(ship.facing)), aimAngle)); const reserve = hints.specialUse === 'pointDefenseAuto' ? ship.def.special.energyCost : 0; const canAfford = ship.energy >= ship.def.primary.energyCost + reserve; const inRange = dist < reach + enemy.def.radius; const holdForCloak = ship.fx.cloaked && dist > 700; // don't blow cover early if (canAfford && inRange && !holdForCloak && noseErr < 0.38 && offAim < 1.2 && !(enemy.fx.cloaked && dist > 900)) { input.fire = true; } // Charge weapons (Melnorme pump-up): keep pumping while far or unaimed, // release once the shot is charged and the nose is on target. if (ship.fx.pump) { const maxLevel = (ship.fx.formPrimary ?? ship.def.primary).maxLevel ?? 4; const charged = ship.fx.pump.level >= Math.min(2, maxLevel); if (!charged || !inRange || noseErr > 0.3) input.fire = true; if (charged && inRange && noseErr <= 0.3) input.fire = false; } // ---- 4. special maybeSpecial(ai, state, ship, hints); return blunder(ai, input); } function maybeSpecial(ai, state, ship, hints) { const tactic = SPECIAL_TACTICS[hints.specialUse] ?? SPECIAL_TACTICS.onCooldown; if (ship.energy < ship.def.special.energyCost) return; if (tactic(ai, state, ship)) ai.input.special = true; } function blunder(ai, input) { if (ai.rng() < ai.knobs.blunder) { const roll = ai.rng(); if (roll < 0.34) { input.left = !input.left; input.right = false; } else if (roll < 0.67) { input.thrust = !input.thrust; } else { input.fire = false; } } return input; }