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
+106
View File
@@ -0,0 +1,106 @@
/**
* Abwurfobjekte der Orbital-Befehle: Verteidigungsgeschütz und Nachschubkapsel.
* @module entities/Deployables
*/
import { GAME } from '../config.js';
import { angleTo, distance } from '../logic/math.js';
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
/** Letzte Sekunden, in denen ein auslaufendes Objekt blinkt. */
const EXPIRY_WARNING_MS = 3000;
/**
* Geschütz »Wachsamer Nachbar«: zielt automatisch auf den nächsten Gegner in Reichweite.
*/
export class Turret extends Phaser.GameObjects.Sprite {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
* @param {{durationMs: number, fireIntervalMs: number, range: number, bulletDamage: number, bulletSpeed: number}} cfg
* @param {number} now
*/
constructor(scene, x, y, cfg, now) {
super(scene, x, y, TEXTURE_KEYS.turret);
scene.add.existing(this);
this.setDepth(GAME.depth.pickups);
this.cfg = cfg;
this.expiresAt = now + cfg.durationMs;
this.nextShotAt = now;
}
/**
* @param {number} now
* @param {Iterable<import('./Enemy.js').Enemy>} enemies
* @param {(x: number, y: number, angle: number) => void} fire
* @returns {boolean} false, wenn die Einsatzzeit abgelaufen ist.
*/
step(now, enemies, fire) {
if (now >= this.expiresAt) return false;
let target = null;
let best = this.cfg.range;
for (const enemy of enemies) {
if (!enemy.alive) continue;
const d = distance(this, enemy);
if (d < best) {
best = d;
target = enemy;
}
}
if (target) {
const angle = angleTo(this, target);
this.setRotation(angle);
if (now >= this.nextShotAt) {
this.nextShotAt = now + this.cfg.fireIntervalMs;
fire(this.x + Math.cos(angle) * 24, this.y + Math.sin(angle) * 24, angle);
}
}
// Kurz vor Ablauf blinken, damit der Spieler nicht überrascht wird.
const left = this.expiresAt - now;
this.setAlpha(left < EXPIRY_WARNING_MS && Math.floor(now / 150) % 2 === 0 ? 0.4 : 1);
return true;
}
}
/**
* Nachschubkapsel »Fürsorgepaket«: Beim Berühren gibt es Leben und Munition.
*/
export class SupplyPod extends Phaser.Physics.Arcade.Sprite {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
* @param {{lifetimeMs: number, pickupRadius: number, heal: number}} cfg
* @param {number} now
*/
constructor(scene, x, y, cfg, now) {
super(scene, x, y, TEXTURE_KEYS.supplyPod);
scene.add.existing(this);
scene.physics.add.existing(this);
this.setDepth(GAME.depth.pickups);
this.cfg = cfg;
this.expiresAt = now + cfg.lifetimeMs;
this.collected = false;
}
/** Nach dem Einfügen in die Gruppe: runden Aufnahmebereich setzen. */
initBody() {
const r = this.cfg.pickupRadius;
this.body.setCircle(r, this.width / 2 - r, this.height / 2 - r);
// Liegt fest am Boden: kein Wegschieben durch Physik.
this.body.setImmovable(true);
this.body.moves = false;
}
/**
* @param {number} now
* @returns {boolean} false, wenn die Kapsel abgelaufen ist.
*/
tick(now) {
const left = this.expiresAt - now;
if (left <= 0) return false;
this.setAlpha(left < EXPIRY_WARNING_MS && Math.floor(now / 150) % 2 === 0 ? 0.4 : 1);
return true;
}
}
+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;
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Präsident Maximilian Valor – die Spielfigur.
*
* Die Figur ist frei erfunden: Raumrüstung mit geschlossenem Helm (kein Gesicht,
* keine Frisur) und Umhang in den Farben der fiktiven Vereinigten Erde.
* @module entities/Player
*/
import { GAME, PLAYER, WEAPONS } from '../config.js';
import { fromAngle, scale } from '../logic/math.js';
import { isNearlyZero } from '../logic/steering.js';
import { WeaponState } from '../logic/weapon.js';
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
export class Player extends Phaser.Physics.Arcade.Sprite {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
*/
constructor(scene, x, y) {
super(scene, x, y, TEXTURE_KEYS.player);
scene.add.existing(this);
scene.physics.add.existing(this);
this.setDepth(GAME.depth.player);
this.setCollideWorldBounds(true);
const r = PLAYER.radius;
this.body.setCircle(r, this.width / 2 - r, this.height / 2 - r);
this.maxHp = PLAYER.maxHp;
this.hp = PLAYER.maxHp;
this.alive = true;
this.weapons = PLAYER.weaponSlots.map((id) => new WeaponState(WEAPONS[id]));
this.weaponIndex = 0;
this.aimAngle = 0;
this.dodgeUntil = 0;
this.dodgeReadyAt = 0;
this.dodgeDirection = { x: 1, y: 0 };
this.hitInvulnerableUntil = 0;
}
/** @returns {WeaponState} */
get weapon() {
return this.weapons[this.weaponIndex];
}
/** @param {number} now @returns {boolean} */
isDodging(now) {
return now < this.dodgeUntil;
}
/**
* Während der Rolle und kurz nach einem Treffer nimmt der Präsident keinen Schaden.
* @param {number} now
* @returns {boolean}
*/
isInvulnerable(now) {
return !this.alive || this.isDodging(now) || now < this.hitInvulnerableUntil;
}
/**
* @param {number} now
* @returns {number} Bereitschaft der Rolle, 0..1 (1 = bereit).
*/
dodgeReadiness(now) {
if (now >= this.dodgeReadyAt) return 1;
return 1 - (this.dodgeReadyAt - now) / PLAYER.dodge.cooldownMs;
}
/**
* Startet eine Ausweichrolle in Bewegungsrichtung (ohne Bewegung: in Blickrichtung).
* @param {number} now
* @param {{x: number, y: number}} moveDirection Normalisiert.
* @returns {boolean} true, wenn die Rolle ausgelöst wurde.
*/
tryDodge(now, moveDirection) {
if (!this.alive || now < this.dodgeReadyAt) return false;
this.dodgeDirection = isNearlyZero(moveDirection) ? fromAngle(this.aimAngle) : moveDirection;
this.dodgeUntil = now + PLAYER.dodge.durationMs;
this.dodgeReadyAt = now + PLAYER.dodge.cooldownMs;
return true;
}
/**
* Bewegung für diesen Frame.
* @param {number} now
* @param {{x: number, y: number}} moveDirection Normalisiert.
* @param {boolean} blocked true, solange ein Orbital-Befehl eingegeben wird.
*/
move(now, moveDirection, blocked) {
if (!this.alive) {
this.setVelocity(0, 0);
return;
}
if (this.isDodging(now)) {
const v = scale(this.dodgeDirection, PLAYER.dodge.speed);
this.setVelocity(v.x, v.y);
this.setAlpha(0.55);
return;
}
this.setAlpha(1);
const v = blocked ? { x: 0, y: 0 } : scale(moveDirection, PLAYER.speed);
this.setVelocity(v.x, v.y);
}
/**
* Richtet die Figur auf einen Punkt in Weltkoordinaten aus.
* @param {number} x
* @param {number} y
*/
aimAt(x, y) {
this.aimAngle = Math.atan2(y - this.y, x - this.x);
this.setRotation(this.aimAngle);
}
/** @returns {{x: number, y: number}} Position der Waffenmündung. */
muzzlePosition() {
const reach = PLAYER.radius + 8;
return {
x: this.x + Math.cos(this.aimAngle) * reach,
y: this.y + Math.sin(this.aimAngle) * reach,
};
}
/**
* @param {number} index
* @returns {boolean} true, wenn gewechselt wurde.
*/
switchWeapon(index) {
if (index === this.weaponIndex || !this.weapons[index]) return false;
// Nachladen bricht beim Wechsel ab – sonst könnte man durch Wechseln „heimlich“ laden.
this.weapon.cancelReload();
this.weaponIndex = index;
return true;
}
/**
* @param {1|-1} step
* @returns {boolean}
*/
cycleWeapon(step) {
const count = this.weapons.length;
return this.switchWeapon((this.weaponIndex + step + count) % count);
}
/**
* Fügt Schaden zu, sofern der Präsident nicht gerade unverwundbar ist.
* @param {number} amount
* @param {number} now
* @returns {number} Tatsächlich erlittener Schaden.
*/
takeDamage(amount, now) {
if (this.isInvulnerable(now) || amount <= 0) return 0;
const dealt = Math.min(this.hp, amount);
this.hp -= dealt;
this.hitInvulnerableUntil = now + PLAYER.hitInvulnerabilityMs;
if (this.hp <= 0) {
this.alive = false;
this.setVelocity(0, 0);
}
return dealt;
}
/** @param {number} amount @returns {number} Tatsächlich geheilte Punkte. */
heal(amount) {
const before = this.hp;
this.hp = Math.min(this.maxHp, this.hp + amount);
return this.hp - before;
}
/** Füllt die Reserve aller Waffen auf (Nachschubkapsel). */
refillAmmo() {
for (const weapon of this.weapons) weapon.refill();
}
/** @param {number} now @returns {boolean} true, wenn die aktive Waffe fertig nachgeladen hat. */
updateWeapons(now) {
return this.weapon.update(now);
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Wiederverwendbare Projektile (Object Pooling).
*
* Bei Dauerfeuer entstehen Hunderte Projektile pro Minute. Statt sie jedes Mal neu
* zu erzeugen und zu zerstören (Garbage-Collector-Ruckler), werden deaktivierte
* Projektile wiederverwendet.
* @module entities/ProjectilePool
*/
export class ProjectilePool {
/**
* @param {Phaser.Scene} scene
* @param {string} textureKey
* @param {number} maxSize Obergrenze gleichzeitig fliegender Projektile.
* @param {number} depth
*/
constructor(scene, textureKey, maxSize, depth) {
this.scene = scene;
this.textureKey = textureKey;
this.depth = depth;
this.group = scene.physics.add.group({
classType: Phaser.Physics.Arcade.Image,
maxSize,
allowGravity: false,
});
}
/**
* Feuert ein Projektil ab.
* @param {number} x
* @param {number} y
* @param {number} angle Bogenmaß.
* @param {number} speed px/s
* @param {number} damage
* @param {number} range Maximale Flugstrecke in px.
* @param {string} [textureKey] Abweichende Textur (z. B. Geschütz).
* @returns {Phaser.Physics.Arcade.Image|null} null, wenn der Pool erschöpft ist.
*/
fire(x, y, angle, speed, damage, range, textureKey) {
const projectile = this.group.get(x, y, textureKey ?? this.textureKey);
if (!projectile) return null;
projectile.enableBody(true, x, y, true, true);
projectile.setTexture(textureKey ?? this.textureKey);
projectile.setDepth(this.depth);
projectile.setRotation(angle);
this.scene.physics.velocityFromRotation(angle, speed, projectile.body.velocity);
projectile.damage = damage;
projectile.spawnX = x;
projectile.spawnY = y;
projectile.maxTravelSq = range * range;
return projectile;
}
/**
* Deaktiviert Projektile, die ihre Reichweite überschritten oder die Welt verlassen haben.
* @param {Phaser.Geom.Rectangle} bounds
*/
update(bounds) {
for (const projectile of this.group.getChildren()) {
if (!projectile.active) continue;
const dx = projectile.x - projectile.spawnX;
const dy = projectile.y - projectile.spawnY;
if (dx * dx + dy * dy > projectile.maxTravelSq || !bounds.contains(projectile.x, projectile.y)) {
this.kill(projectile);
}
}
}
/** @param {Phaser.Physics.Arcade.Image} projectile */
kill(projectile) {
projectile.disableBody(true, true);
}
/** @returns {number} Aktive Projektile. */
get activeCount() {
return this.group.countActive(true);
}
}