fertig-classic-games/src/games/starcontrol/StarControlAI.js

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