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