slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
/**
* 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;
}
}