slop game oder so
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user