Files
slop-game-opus5.5/app/static/js/logic/enemyAi.js
T
2026-09-24 17:57:54 +02:00

226 lines
8.0 KiB
JavaScript

/**
* Entscheidungslogik der Gegner (ohne Phaser): Bewegung pro Verhalten,
* Sprungangriff, Schuss-Salven, Brut. Die Entities rufen diese Funktionen jeden
* Frame auf und setzen das Ergebnis in Physik und Grafik um.
*
* Zustände liegen in kleinen, veränderlichen „Brain“-Objekten pro Gegner. Das ist
* bewusst kein unveränderlicher Stil: Bei 60 Gegnern und 60 FPS sollen pro Frame
* keine Objektberge für den Garbage Collector entstehen.
* @module logic/enemyAi
*/
import { degToRad, distance, normalize, scale, sub } from './math.js';
import { encirclePoint, flankPoint, keepDistance, seek } from './steering.js';
/** @typedef {import('./math.js').Vec2} Vec2 */
/**
* @typedef {Object} Brain
* @property {number} slotAngle Anflugwinkel im Schwarm (Bogenmaß).
* @property {1|-1} strafeDirection Richtung beim seitlichen Ausweichen.
* @property {number} nextStrafeSwitchAt
* @property {1|-1} flankSide Seite, von der flankiert wird.
* @property {'chase'|'windup'|'leap'} leapState
* @property {number} leapStateUntil
* @property {number} nextLeapAt
* @property {Vec2} leapDirection
* @property {AttackState} attack
* @property {AttackState} mortar
* @property {number} nextBroodAt
*/
/**
* @typedef {Object} AttackState
* @property {number} nextAttackAt
* @property {number} burstRemaining
* @property {number} nextShotAt
*/
/**
* Neuer Angriffszustand. `firstAttackAt` verzögert den ersten Schuss, damit frisch
* gespawnte Gegner nicht sofort und alle gleichzeitig feuern.
* @param {number} firstAttackAt
* @returns {AttackState}
*/
export function createAttackState(firstAttackAt = 0) {
return { nextAttackAt: firstAttackAt, burstRemaining: 0, nextShotAt: 0 };
}
/**
* Neues Brain mit zufälligen, aber reproduzierbaren Eigenheiten.
* @param {import('./rng.js').Rng} rng
* @param {number} now
* @param {{firstAttackDelayMs?: number}} [options]
* @returns {Brain}
*/
export function createBrain(rng, now, options = {}) {
const firstAttackAt = now + (options.firstAttackDelayMs ?? 0) * (0.5 + rng());
return {
slotAngle: rng() * Math.PI * 2,
strafeDirection: rng() < 0.5 ? 1 : -1,
nextStrafeSwitchAt: now,
flankSide: rng() < 0.5 ? 1 : -1,
leapState: 'chase',
leapStateUntil: 0,
nextLeapAt: now,
leapDirection: { x: 0, y: 0 },
attack: createAttackState(firstAttackAt),
mortar: createAttackState(firstAttackAt + (options.firstAttackDelayMs ?? 0)),
nextBroodAt: now + (options.firstAttackDelayMs ?? 0),
};
}
/**
* Wechselt gelegentlich die Ausweichrichtung, damit Fernkämpfer nicht vorhersehbar kreisen.
* @param {Brain} brain
* @param {number} now
* @param {{min: number, max: number}} switchMs
* @param {import('./rng.js').Rng} rng
*/
export function updateStrafe(brain, now, switchMs, rng) {
if (now >= brain.nextStrafeSwitchAt) {
brain.strafeDirection = brain.strafeDirection === 1 ? -1 : 1;
brain.nextStrafeSwitchAt = now + switchMs.min + rng() * (switchMs.max - switchMs.min);
}
}
/**
* Schwarm: aus der Distanz den eigenen Platz im Umzingelungskreis ansteuern,
* in Reichweite direkt angreifen. So kommt ein Rudel von mehreren Seiten.
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} speed
* @param {{engageRange: number, encircleRadius: number}} type
* @returns {Vec2}
*/
export function swarmVelocity(brain, self, player, speed, type) {
if (distance(self, player) <= type.engageRange) {
return seek(self, player, speed);
}
return seek(self, encirclePoint(player, brain.slotAngle, type.encircleRadius), speed);
}
/**
* Sprungkralle: verfolgen → ansetzen (stillstehen, Vorwarnung) → springen → verfolgen.
* Die Sprungrichtung wird beim Ansetzen festgelegt: Wer die Vorwarnung sieht, kann
* ausweichen. Das ist die „Fairness“ des Angriffs.
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} now
* @param {number} speed
* @param {{triggerRange: number, windupMs: number, speed: number, durationMs: number, cooldownMs: number}} leap
* @returns {{velocity: Vec2, telegraph: boolean, leaping: boolean}}
*/
export function leaperStep(brain, self, player, now, speed, leap) {
if (brain.leapState === 'windup') {
if (now < brain.leapStateUntil) {
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
}
brain.leapState = 'leap';
brain.leapStateUntil = now + leap.durationMs;
}
if (brain.leapState === 'leap') {
if (now < brain.leapStateUntil) {
return { velocity: scale(brain.leapDirection, leap.speed), telegraph: false, leaping: true };
}
brain.leapState = 'chase';
brain.nextLeapAt = now + leap.cooldownMs;
}
// chase
if (distance(self, player) <= leap.triggerRange && now >= brain.nextLeapAt) {
brain.leapState = 'windup';
brain.leapStateUntil = now + leap.windupMs;
brain.leapDirection = normalize(sub(player, self));
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
}
return { velocity: seek(self, player, speed), telegraph: false, leaping: false };
}
/**
* Fernkämpfer auf Abstand (Nieter-Drohne, Walzmeister).
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} speed
* @param {{min: number, max: number}} range
* @returns {Vec2}
*/
export function kiteVelocity(brain, self, player, speed, range) {
return keepDistance(self, player, range, speed, brain.strafeDirection);
}
/**
* Flankierer (Blechsoldat): steuert eine Position seitlich des Spielers an und
* hält dort Abstand. Nah an der Flankenposition geht er ins Abstandhalten über.
* @param {Brain} brain
* @param {Vec2} self
* @param {Vec2} player
* @param {number} speed
* @param {{flankAngleDeg: number, flankDistance: number, range: {min: number, max: number}}} type
* @returns {Vec2}
*/
export function flankVelocity(brain, self, player, speed, type) {
const target = flankPoint(player, self, degToRad(type.flankAngleDeg) * brain.flankSide, type.flankDistance);
// Ist die Flanke (fast) erreicht, wie ein Fernkämpfer weiterkämpfen.
if (distance(self, target) < type.range.min * 0.5) {
return keepDistance(self, player, type.range, speed, brain.flankSide);
}
return seek(self, target, speed);
}
/**
* Taktgeber für Schüsse und Salven.
* @param {AttackState} state
* @param {number} now
* @param {number} distanceToPlayer
* @param {{cooldownMs: number, burst?: number, burstIntervalMs?: number, maxDistance: number}} attack
* @returns {boolean} true, wenn in diesem Frame geschossen werden soll.
*/
export function attackStep(state, now, distanceToPlayer, attack) {
if (state.burstRemaining > 0) {
if (now >= state.nextShotAt) {
state.burstRemaining -= 1;
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
return true;
}
return false;
}
if (now >= state.nextAttackAt && distanceToPlayer <= attack.maxDistance) {
state.burstRemaining = (attack.burst ?? 1) - 1;
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
state.nextAttackAt = now + attack.cooldownMs;
return true;
}
return false;
}
/**
* Winkel aller Projektile einer Salve, gleichmäßig über `spreadDeg` verteilt.
* @param {number} baseAngle Bogenmaß (Richtung zum Ziel).
* @param {number} count
* @param {number} spreadDeg Gesamtbreite des Fächers in Grad.
* @returns {number[]}
*/
export function volleyAngles(baseAngle, count, spreadDeg) {
if (count <= 1) return [baseAngle];
const spread = degToRad(spreadDeg);
const step = spread / (count - 1);
return Array.from({ length: count }, (_, i) => baseAngle - spread / 2 + i * step);
}
/**
* Brutmutter: Wie viele Krabbler jetzt schlüpfen sollen.
* @param {Brain} brain
* @param {number} now
* @param {number} aliveChildren Aktuell lebende Nachkommen dieser Brutmutter.
* @param {{intervalMs: number, spawnCount: number, maxChildren: number}} brood
* @returns {number}
*/
export function broodStep(brain, now, aliveChildren, brood) {
if (now < brain.nextBroodAt) return 0;
brain.nextBroodAt = now + brood.intervalMs;
return Math.max(0, Math.min(brood.spawnCount, brood.maxChildren - aliveChildren));
}