slop game oder so
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
Reference in New Issue
Block a user