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

251 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Gegner beider Fraktionen. Das Verhalten kommt aus `config.ENEMY_TYPES[type].behavior`,
* die Entscheidungen trifft die Phaser-freie Logik in `logic/enemyAi.js`. Diese
* Klasse setzt sie nur in Physik, Drehung und Grafik um.
* @module entities/Enemy
*/
import { AI, ENEMY_TYPES, GAME, PLAYER } from '../config.js';
import {
attackStep,
broodStep,
createBrain,
flankVelocity,
kiteVelocity,
leaperStep,
swarmVelocity,
updateStrafe,
volleyAngles,
} from '../logic/enemyAi.js';
import { angleTo, degToRad, distance, scale } from '../logic/math.js';
import { combine, seek, separation, steerTowards } from '../logic/steering.js';
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
/** Frisch gespawnte Gegner schießen nicht sofort (Fairness). */
const FIRST_ATTACK_DELAY_MS = 1100;
/** Unter dieser Geschwindigkeit bleibt die Blickrichtung stehen (verhindert Zittern). */
const MIN_TURN_SPEED = 12;
/** Farbe der Vorwarnung vor dem Sprungangriff. */
const TELEGRAPH_TINT = 0xff5040;
/** Verhalten, die im Nahkampf angreifen. */
const MELEE_BEHAVIORS = new Set(['swarm', 'leaper', 'brood']);
/** Stärke der Leerlauf-Animationen (Anteil der Größe). */
const ANIMATION = Object.freeze({ periodMs: 90, swarmWobble: 0.09, broodPulse: 0.04, hoverBob: 0.04 });
/**
* @typedef {Object} EnemyContext
* @property {{x: number, y: number}} player
* @property {Phaser.GameObjects.GameObject[]} neighbors
* @property {() => number} rng
* @property {(x: number, y: number, angle: number, speed: number, damage: number) => void} fireBullet
* @property {(enemy: Enemy, count: number) => void} spawnChildren
* @property {(x: number, y: number, mortar: object) => void} scheduleMortar
*/
let nextEnemyId = 1;
export class Enemy extends Phaser.Physics.Arcade.Sprite {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
* @param {string} typeId Schlüssel aus ENEMY_TYPES.
* @param {{now: number, rng: () => number, hpMultiplier?: number, speedMultiplier?: number, parent?: Enemy|null}} options
*/
constructor(scene, x, y, typeId, options) {
super(scene, x, y, TEXTURE_KEYS.enemy(typeId));
this.id = nextEnemyId;
nextEnemyId += 1;
this.type = ENEMY_TYPES[typeId];
/** Wellen-Multiplikatoren; ausgebrütete Krabbler erben sie von der Brutmutter. */
this.multipliers = { hp: options.hpMultiplier ?? 1, speed: options.speedMultiplier ?? 1 };
this.maxHp = Math.round(this.type.hp * this.multipliers.hp);
this.hp = this.maxHp;
this.speed = this.type.speed * this.multipliers.speed;
this.brain = createBrain(options.rng, options.now, { firstAttackDelayMs: FIRST_ATTACK_DELAY_MS });
this.alive = true;
this.lastContactAt = -Infinity;
this.leaping = false;
this.telegraphing = false;
/** @type {Enemy|null} Brutmutter, von der dieser Krabbler stammt. */
this.parentEnemy = options.parent ?? null;
this.childCount = 0;
scene.add.existing(this);
scene.physics.add.existing(this);
this.setDepth(GAME.depth.enemies);
}
/** Physik-Körper einrichten – erst nach dem Einfügen in die Physik-Gruppe aufrufen. */
initBody() {
const r = this.type.radius;
this.body.setCircle(r, this.width / 2 - r, this.height / 2 - r);
this.setCollideWorldBounds(true);
}
/** @returns {boolean} */
get isElite() {
return this.type.tier === 'elite';
}
/**
* Ein KI-Schritt: Wunschgeschwindigkeit bestimmen, Angriffe auslösen.
* @param {number} now Spielzeit in ms.
* @param {EnemyContext} ctx
*/
step(now, ctx) {
if (!this.alive) return;
const { player } = ctx;
const type = this.type;
let desired;
let responsiveness = AI.steeringResponsiveness;
let faceTarget = false;
switch (type.behavior) {
case 'swarm':
desired = swarmVelocity(this.brain, this, player, this.speed, type);
break;
case 'leaper': {
const result = leaperStep(this.brain, this, player, now, this.speed, type.leap);
desired = result.velocity;
this.leaping = result.leaping;
if (result.leaping || result.telegraph) responsiveness = 1;
this.setTelegraph(result.telegraph);
break;
}
case 'brood': {
desired = seek(this, player, this.speed);
const hatch = broodStep(this.brain, now, this.childCount, type.brood);
if (hatch > 0) ctx.spawnChildren(this, hatch);
break;
}
case 'kite':
updateStrafe(this.brain, now, AI.strafeSwitchMs, ctx.rng);
desired = kiteVelocity(this.brain, this, player, this.speed, type.range);
faceTarget = true;
this.tryAttack(now, ctx);
break;
case 'flank':
desired = flankVelocity(this.brain, this, player, this.speed, type);
faceTarget = true;
this.tryAttack(now, ctx);
break;
case 'siege':
updateStrafe(this.brain, now, AI.strafeSwitchMs, ctx.rng);
desired = kiteVelocity(this.brain, this, player, this.speed, type.range);
faceTarget = true;
this.tryAttack(now, ctx);
this.tryMortar(now, ctx);
break;
default:
desired = seek(this, player, this.speed);
}
// Nahkämpfer bleiben am Präsidenten „kleben“, statt sich auf seinen Mittelpunkt zu
// drängen – sonst stapeln sie sich unlesbar übereinander.
if (MELEE_BEHAVIORS.has(type.behavior) && !this.leaping) {
if (distance(this, player) < PLAYER.radius + type.radius) desired = { x: 0, y: 0 };
}
// Abstand zu Nachbarn – außer im Sprung, der soll geradlinig bleiben.
if (!this.leaping) {
const push = separation(this, ctx.neighbors, AI.separationRadius + type.radius);
desired = combine(
[
{ vector: desired, weight: 1 },
{ vector: scale(push, this.speed), weight: AI.separationWeight },
],
this.speed,
);
}
const velocity = steerTowards(this.body.velocity, desired, responsiveness);
this.setVelocity(velocity.x, velocity.y);
if (faceTarget) {
this.setRotation(angleTo(this, player));
} else if (Math.hypot(velocity.x, velocity.y) > MIN_TURN_SPEED) {
this.setRotation(Math.atan2(velocity.y, velocity.x));
}
this.animate(now);
}
/**
* Kleine Lebenszeichen ohne Spritesheet: Insekten wackeln beim Laufen, die Brutmutter
* pulsiert, Drohnen schweben. Die ID verschiebt die Phase, damit nicht alle im Takt wippen.
* @param {number} now
*/
animate(now) {
if (this.telegraphing) return;
const phase = now / ANIMATION.periodMs + this.id;
if (this.type.behavior === 'brood') {
this.setScale(1 + ANIMATION.broodPulse * Math.sin(phase * 0.35));
} else if (this.type.faction === 'swarm') {
const moving = Math.hypot(this.body.velocity.x, this.body.velocity.y) > MIN_TURN_SPEED;
this.setScale(1, moving ? 1 + ANIMATION.swarmWobble * Math.sin(phase * 2.2) : 1);
} else if (this.type.behavior === 'kite') {
this.setScale(1 + ANIMATION.hoverBob * Math.sin(phase));
}
}
/**
* Schießt gemäß Takt des Gegnertyps.
* @param {number} now
* @param {EnemyContext} ctx
*/
tryAttack(now, ctx) {
const attack = this.type.attack;
if (!attackStep(this.brain.attack, now, distance(this, ctx.player), attack)) return;
let base = angleTo(this, ctx.player);
// Einzelschüsse streuen leicht, Fächersalven sind bereits breit.
if (attack.count === 1 && attack.spreadDeg > 0) {
base += (ctx.rng() - 0.5) * degToRad(attack.spreadDeg);
}
const reach = this.type.radius + 6;
for (const angle of volleyAngles(base, attack.count, attack.spreadDeg)) {
ctx.fireBullet(this.x + Math.cos(angle) * reach, this.y + Math.sin(angle) * reach, angle, attack.speed, attack.damage);
}
}
/**
* Walzmeister: Mörser auf die aktuelle Spielerposition (mit Bodenmarkierung).
* @param {number} now
* @param {EnemyContext} ctx
*/
tryMortar(now, ctx) {
const mortar = this.type.mortar;
if (!mortar) return;
if (attackStep(this.brain.mortar, now, distance(this, ctx.player), mortar)) {
ctx.scheduleMortar(ctx.player.x, ctx.player.y, mortar);
}
}
/** @param {boolean} active */
setTelegraph(active) {
if (active === this.telegraphing) return;
this.telegraphing = active;
if (active) {
this.setTint(TELEGRAPH_TINT);
this.setScale(1.15);
} else {
this.clearTint();
this.setScale(1);
}
}
/**
* @param {number} amount
* @returns {boolean} true, wenn der Gegner dadurch stirbt.
*/
takeDamage(amount) {
if (!this.alive) return false;
this.hp -= amount;
if (this.hp <= 0) {
this.hp = 0;
this.alive = false;
return true;
}
return false;
}
}