372 lines
15 KiB
JavaScript
372 lines
15 KiB
JavaScript
// 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;
|
|
}
|