Files
2026-09-24 17:57:54 +02:00

166 lines
6.2 KiB
JavaScript

import { describe, expect, it } from 'vitest';
import { ENEMY_TYPES } from '../../app/static/js/config.js';
import {
attackStep,
broodStep,
createAttackState,
createBrain,
flankVelocity,
leaperStep,
swarmVelocity,
updateStrafe,
volleyAngles,
} from '../../app/static/js/logic/enemyAi.js';
import { degToRad, distance, length } from '../../app/static/js/logic/math.js';
import { createRng } from '../../app/static/js/logic/rng.js';
const player = { x: 0, y: 0 };
describe('createBrain', () => {
it('verzögert den ersten Angriff und ist reproduzierbar', () => {
const a = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 });
const b = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 });
expect(a).toEqual(b);
expect(a.attack.nextAttackAt).toBeGreaterThanOrEqual(1400);
expect(a.attack.nextAttackAt).toBeLessThan(2200);
});
});
describe('swarmVelocity', () => {
const type = ENEMY_TYPES.krabbler;
it('steuert aus der Distanz den eigenen Umzingelungsplatz an', () => {
const brain = { slotAngle: Math.PI / 2 }; // Platz unterhalb des Spielers
const v = swarmVelocity(brain, { x: 800, y: 0 }, player, 100, type);
// Ziel ist (0, encircleRadius): nach links UND nach unten.
expect(v.x).toBeLessThan(0);
expect(v.y).toBeGreaterThan(0);
});
it('greift in Reichweite direkt an', () => {
const v = swarmVelocity({ slotAngle: Math.PI / 2 }, { x: 100, y: 0 }, player, 100, type);
expect(v.x).toBeCloseTo(-100);
expect(v.y).toBeCloseTo(0);
});
});
describe('leaperStep', () => {
const { leap } = ENEMY_TYPES.sprungkralle;
it('verfolgt außerhalb der Sprungreichweite', () => {
const brain = createBrain(createRng(1), 0);
const r = leaperStep(brain, { x: 1000, y: 0 }, player, 0, 120, leap);
expect(r.telegraph).toBe(false);
expect(r.velocity.x).toBeCloseTo(-120);
});
it('durchläuft Ansetzen → Sprung → Abklingzeit', () => {
const brain = createBrain(createRng(1), 0);
const self = { x: 200, y: 0 };
const windup = leaperStep(brain, self, player, 0, 120, leap);
expect(windup).toMatchObject({ telegraph: true, leaping: false, velocity: { x: 0, y: 0 } });
// Spieler bewegt sich während des Ansetzens: Die Sprungrichtung bleibt fest.
const moved = { x: 0, y: 500 };
const jump = leaperStep(brain, self, moved, leap.windupMs, 120, leap);
expect(jump.leaping).toBe(true);
expect(jump.velocity.x).toBeCloseTo(-leap.speed);
expect(jump.velocity.y).toBeCloseTo(0);
const landed = leaperStep(brain, self, player, leap.windupMs + leap.durationMs, 120, leap);
expect(landed.leaping).toBe(false);
expect(landed.telegraph).toBe(false);
// Während der Abklingzeit kein neuer Sprung, obwohl in Reichweite.
const cooling = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + 10, 120, leap);
expect(cooling.telegraph).toBe(false);
const again = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + leap.cooldownMs, 120, leap);
expect(again.telegraph).toBe(true);
});
});
describe('flankVelocity', () => {
it('schwenkt seitlich ein statt frontal anzulaufen', () => {
const type = ENEMY_TYPES.blechsoldat;
const self = { x: 600, y: 0 };
const left = flankVelocity({ flankSide: 1 }, self, player, 80, type);
const right = flankVelocity({ flankSide: -1 }, self, player, 80, type);
expect(Math.sign(left.y)).not.toBe(Math.sign(right.y));
expect(length(left)).toBeCloseTo(80);
});
it('hält an der Flankenposition Abstand zum Spieler', () => {
const type = ENEMY_TYPES.blechsoldat;
const angle = degToRad(type.flankAngleDeg);
// Genau an der Flankenposition relativ zu sich selbst gibt es keinen festen Punkt,
// daher: sehr nah am Spieler → muss zurückweichen oder seitlich ausweichen.
const self = { x: Math.cos(angle) * 100, y: Math.sin(angle) * 100 };
const v = flankVelocity({ flankSide: 1 }, self, player, 80, type);
expect(distance({ x: self.x + v.x, y: self.y + v.y }, player)).toBeGreaterThanOrEqual(distance(self, player) - 1);
});
});
describe('updateStrafe', () => {
it('wechselt die Richtung nach Ablauf des Intervalls', () => {
const brain = { strafeDirection: 1, nextStrafeSwitchAt: 100 };
updateStrafe(brain, 50, { min: 1000, max: 1000 }, createRng(1));
expect(brain.strafeDirection).toBe(1);
updateStrafe(brain, 100, { min: 1000, max: 1000 }, createRng(1));
expect(brain.strafeDirection).toBe(-1);
expect(brain.nextStrafeSwitchAt).toBe(1100);
});
});
describe('attackStep', () => {
const attack = { cooldownMs: 1000, burst: 3, burstIntervalMs: 100, maxDistance: 400 };
it('feuert nicht außerhalb der Reichweite', () => {
const state = createAttackState(0);
expect(attackStep(state, 0, 401, attack)).toBe(false);
});
it('feuert eine Salve mit Abständen und wartet dann die Abklingzeit ab', () => {
const state = createAttackState(0);
const shots = [];
for (let t = 0; t <= 1200; t += 50) {
if (attackStep(state, t, 100, attack)) shots.push(t);
}
expect(shots).toEqual([0, 100, 200, 1000, 1100, 1200]);
});
it('respektiert die Verzögerung des ersten Angriffs', () => {
const state = createAttackState(500);
expect(attackStep(state, 499, 100, attack)).toBe(false);
expect(attackStep(state, 500, 100, attack)).toBe(true);
});
});
describe('volleyAngles', () => {
it('liefert bei einem Projektil genau die Zielrichtung', () => {
expect(volleyAngles(1.2, 1, 40)).toEqual([1.2]);
});
it('verteilt einen Fächer symmetrisch um die Zielrichtung', () => {
const angles = volleyAngles(0, 5, 40);
expect(angles).toHaveLength(5);
expect(angles[0]).toBeCloseTo(-degToRad(20));
expect(angles[2]).toBeCloseTo(0);
expect(angles[4]).toBeCloseTo(degToRad(20));
});
});
describe('broodStep', () => {
const brood = { intervalMs: 1000, spawnCount: 3, maxChildren: 5 };
it('brütet im Takt und respektiert die Obergrenze', () => {
const brain = { nextBroodAt: 0 };
expect(broodStep(brain, 0, 0, brood)).toBe(3);
expect(broodStep(brain, 500, 3, brood)).toBe(0);
expect(broodStep(brain, 1000, 3, brood)).toBe(2);
expect(broodStep(brain, 2000, 5, brood)).toBe(0);
expect(broodStep(brain, 3000, 9, brood)).toBe(0);
});
});