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