/** * 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)); }