Files
2026-09-24 17:57:54 +02:00

665 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Die eigentliche Spielszene: Arena, Präsident, Gegner, Projektile, Wellen und
* Orbital-Befehle. Das HUD läuft als eigene Szene darüber (HudScene) und liest
* seinen Zustand über `hudState()`.
*
* Zeitmessung: Alle Spielmechaniken verwenden `this.gameTime` (eigene Uhr, die nur
* läuft, während die Szene aktiv ist). So laufen Abklingzeiten in der Pause nicht
* weiter, und die gemeldete Spieldauer enthält keine Pausen.
* @module scenes/GameScene
*/
import { COLORS, ENEMY_TYPES, FX, GAME, PLAYER, SCORING } from '../config.js';
import { ORBITAL_TEXT, PLAYER_TEXT, PROPAGANDA, WAVE } from '../content/texts.js';
import { SupplyPod, Turret } from '../entities/Deployables.js';
import { Enemy } from '../entities/Enemy.js';
import { Player } from '../entities/Player.js';
import { ProjectilePool } from '../entities/ProjectilePool.js';
import { clamp, directionFromKeys, distance, inCircle } from '../logic/math.js';
import { createRng, randFloat, timeSeed } from '../logic/rng.js';
import { ScoreKeeper, waveBonus } from '../logic/scoring.js';
import { WaveDirector } from '../logic/waveDirector.js';
import { spawnPointOnEdge } from '../logic/waves.js';
import { formatMultiplier, formatSequence } from '../logic/format.js';
import { buildArena } from '../systems/Arena.js';
import { Effects } from '../systems/Effects.js';
import { GroundMarker } from '../systems/GroundMarker.js';
import { OrbitalSystem } from '../systems/OrbitalSystem.js';
import { oncePerEvent } from '../systems/keyboard.js';
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
/** Längere Frames (z. B. nach Tab-Wechsel) zählen nicht voll als Spielzeit. */
const MAX_FRAME_MS = 100;
/** Streuung der Mitglieder einer Spawn-Gruppe um ihren Spawnpunkt. */
const SPAWN_SPREAD = 70;
/** Kein Spawn näher als das am Präsidenten – sonst gäbe es unfaire Treffer aus dem Nichts. */
const MIN_SPAWN_DISTANCE = 420;
/** Ein Klick kurz vor Ende der Feuerpause wird nachgeholt (fühlt sich reaktionsschneller an). */
const CLICK_BUFFER_MS = 180;
const PLAYER_BULLET_POOL = 240;
const ENEMY_BULLET_POOL = 280;
const ENEMY_BULLET_RANGE = 950;
/** Reichweitenzuschlag für Geschützprojektile über den Zielradius hinaus. */
const TURRET_BULLET_EXTRA_RANGE = 80;
const OPPOSITE_EDGE = Object.freeze({ north: 'south', south: 'north', east: 'west', west: 'east' });
const FACTION_COLORS = Object.freeze({ swarm: COLORS.swarm, legion: COLORS.legion });
const ORBITAL_COLORS = Object.freeze({ strike: COLORS.strike, supply: COLORS.supply, turret: COLORS.turret });
export class GameScene extends Phaser.Scene {
constructor() {
super('Game');
}
create() {
this.services = this.registry.get('services');
this.rng = createRng(timeSeed());
this.gameTime = 0;
this.isOver = false;
this.runToken = null;
this.lowAmmoHinted = false;
this.propagandaDeck = [];
this.firePressedAt = -Infinity;
this.services.api.startRun().then((token) => {
this.runToken = token;
});
this.createArena();
this.player = new Player(this, GAME.world.width / 2, GAME.world.height / 2);
this.cameras.main.setBounds(0, 0, GAME.world.width, GAME.world.height);
this.cameras.main.startFollow(this.player, true, GAME.cameraLerp, GAME.cameraLerp);
this.enemies = this.physics.add.group();
this.pods = this.physics.add.group();
/** @type {Turret[]} */
this.turrets = [];
/** @type {{marker: GroundMarker, cfg: object}[]} */
this.mortars = [];
this.playerBullets = new ProjectilePool(this, TEXTURE_KEYS.bulletPlayer, PLAYER_BULLET_POOL, GAME.depth.projectiles);
this.enemyBullets = new ProjectilePool(this, TEXTURE_KEYS.bulletEnemy, ENEMY_BULLET_POOL, GAME.depth.projectiles);
this.effects = new Effects(this);
this.score = new ScoreKeeper();
this.healthBars = this.add.graphics().setDepth(GAME.depth.effects);
this.enemyContext = {
player: this.player,
neighbors: [],
rng: this.rng,
fireBullet: (x, y, angle, speed, damage) => {
this.enemyBullets.fire(x, y, angle, speed, damage, ENEMY_BULLET_RANGE);
this.services.audio.play('enemy-shot');
},
spawnChildren: (mother, count) => this.spawnBrood(mother, count),
scheduleMortar: (x, y, cfg) => this.scheduleMortar(x, y, cfg),
};
this.setupInput();
this.orbital = new OrbitalSystem(this, {
getPlayer: () => this.player,
getAimPoint: () => this.aimPoint(),
onImpact: (command, x, y) => this.onOrbitalImpact(command, x, y),
onFeedback: (kind, command, remainingMs) => this.onOrbitalFeedback(kind, command, remainingMs),
});
this.waves = new WaveDirector({
rng: this.rng,
spawnGroup: (group, plan) => this.spawnGroup(group, plan),
aliveCount: () => this.enemies.countActive(true),
onWaveStart: (plan) => this.onWaveStart(plan),
onWaveCleared: (n) => this.onWaveCleared(n),
});
this.setupCollisions();
this.waves.start(this.gameTime);
this.scene.launch('Hud');
this.input.setDefaultCursor('none');
this.game.events.on('blur', this.autoPause, this);
this.events.once('shutdown', this.cleanup, this);
this.services.audio.startMusic();
}
// ------------------------------------------------------------------ Aufbau
createArena() {
const { width, height } = GAME.world;
this.physics.world.setBounds(0, 0, width, height);
this.worldRect = new Phaser.Geom.Rectangle(0, 0, width, height);
buildArena(this, this.rng);
}
setupInput() {
// Bewegung wird pro Frame abgefragt (gehaltene Tasten). Einmalige Aktionen laufen
// dagegen über keydown-Ereignisse: Phasers JustDown verliert Tastendrücke, die
// innerhalb eines Frames gedrückt UND losgelassen werden (schnelles Antippen).
this.keys = this.input.keyboard.addKeys('W,A,S,D,R,SPACE,ONE,TWO,THREE,ESC');
/** @type {Set<string>} Seit dem letzten Frame angeforderte Aktionen. */
this.pendingActions = new Set();
const bindings = { ESC: 'pause', SPACE: 'dodge', R: 'reload', ONE: 'slot-0', TWO: 'slot-1', THREE: 'slot-2' };
for (const [key, action] of Object.entries(bindings)) {
this.input.keyboard.on(
`keydown-${key}`,
oncePerEvent((event) => {
if (!event.repeat) this.pendingActions.add(action);
}),
);
}
this.input.on('pointerdown', (pointer) => {
this.services.audio.unlock();
if (pointer.leftButtonDown()) this.firePressedAt = this.gameTime;
});
this.input.on('wheel', (_pointer, _over, _dx, dy) => {
if (dy !== 0 && this.player.cycleWeapon(dy > 0 ? 1 : -1)) this.services.audio.play('switch');
});
}
setupCollisions() {
this.physics.add.overlap(this.playerBullets.group, this.enemies, (a, b) => {
const [bullet, enemy] = a instanceof Enemy ? [b, a] : [a, b];
this.onBulletHitEnemy(bullet, enemy);
});
this.physics.add.overlap(
this.player,
this.enemyBullets.group,
(a, b) => this.onEnemyBulletHitPlayer(a === this.player ? b : a),
// Während der Rolle fliegen Projektile hindurch, statt verschluckt zu werden.
() => !this.player.isDodging(this.gameTime),
);
this.physics.add.overlap(this.player, this.enemies, (a, b) => this.onEnemyContact(a === this.player ? b : a));
this.physics.add.overlap(this.player, this.pods, (a, b) => this.onPickup(a === this.player ? b : a));
}
// ------------------------------------------------------------------ Hauptschleife
update(_time, delta) {
const dt = Math.min(delta, MAX_FRAME_MS);
this.gameTime += dt;
const now = this.gameTime;
if (this.isOver) {
this.player.move(now, { x: 0, y: 0 }, true);
this.updateEnemies(now);
this.updateProjectiles();
return;
}
if (this.pendingActions.has('pause')) {
this.pendingActions.clear();
this.openPause();
return;
}
this.orbital.update(now);
this.updatePlayer(now);
this.updateEnemies(now);
this.updateDeployables(now);
this.updateMortars(now);
this.updateProjectiles();
this.waves.update(now, dt);
this.drawHealthBars();
}
updatePlayer(now) {
const { keys, player } = this;
const comboActive = this.orbital.isInputActive;
// Während der Befehlseingabe dienen WASD als Richtungstasten, nicht zum Laufen.
const move = comboActive
? { x: 0, y: 0 }
: directionFromKeys({ up: keys.W.isDown, down: keys.S.isDown, left: keys.A.isDown, right: keys.D.isDown });
const actions = this.pendingActions;
if (!comboActive && actions.has('dodge') && player.tryDodge(now, move)) {
this.services.audio.play('dodge');
this.effects.afterimage(player);
this.effects.burst(player.x, player.y, COLORS.muted, 8);
}
player.move(now, move, comboActive);
const aim = this.aimPoint();
player.aimAt(aim.x, aim.y);
player.weapons.forEach((_weapon, index) => {
if (actions.has(`slot-${index}`) && player.switchWeapon(index)) this.services.audio.play('switch');
});
if (actions.has('reload') && player.weapon.startReload(now)) {
this.services.audio.play('reload');
}
actions.clear();
if (player.updateWeapons(now)) this.services.audio.play('reload-done');
this.handleFiring(now);
}
handleFiring(now) {
const { player } = this;
const weapon = player.weapon;
const def = weapon.def;
const held = this.input.activePointer.leftButtonDown();
const clicked = now - this.firePressedAt <= CLICK_BUFFER_MS;
if (!(def.automatic ? held || clicked : clicked)) return;
if (weapon.isEmpty) {
// Waffe leer: automatisch zur Dienstpistole mit unbegrenzter Munition wechseln.
const fallback = player.weapons.findIndex((w) => w.hasInfiniteReserve);
if (fallback >= 0 && player.switchWeapon(fallback)) this.feedback(PLAYER_TEXT.outOfAmmo, 'warn');
this.firePressedAt = -Infinity;
return;
}
if (weapon.magazine === 0 && weapon.startReload(now)) this.services.audio.play('reload');
const offsets = weapon.fire(now, this.rng);
if (!offsets) return;
this.firePressedAt = -Infinity;
const muzzle = player.muzzlePosition();
for (const offset of offsets) {
this.playerBullets.fire(muzzle.x, muzzle.y, player.aimAngle + offset, def.bulletSpeed, def.damage, def.rangePx);
}
this.effects.muzzle(muzzle.x, muzzle.y, player.aimAngle);
this.services.audio.play(`shot-${def.id}`);
if (weapon.isReloading && weapon.reserve > 0) this.services.audio.play('reload');
if (!weapon.hasInfiniteReserve && weapon.reserve === 0 && !this.lowAmmoHinted) {
this.lowAmmoHinted = true;
const supply = this.orbital.commands.find((c) => c.id === 'supply');
this.feedback(PLAYER_TEXT.lowAmmo(supply ? formatSequence(supply.sequence) : ''), 'warn');
}
}
updateEnemies(now) {
const list = this.enemies.getChildren().slice();
const ctx = this.enemyContext;
ctx.neighbors = list;
for (const enemy of list) {
if (enemy.alive) enemy.step(now, ctx);
}
}
updateDeployables(now) {
const alive = this.enemies.getChildren().filter((e) => e.alive);
for (let i = this.turrets.length - 1; i >= 0; i -= 1) {
const turret = this.turrets[i];
const running = turret.step(now, alive, (x, y, angle) => {
this.playerBullets.fire(
x,
y,
angle,
turret.cfg.bulletSpeed,
turret.cfg.bulletDamage,
turret.cfg.range + TURRET_BULLET_EXTRA_RANGE,
TEXTURE_KEYS.bulletTurret,
);
this.effects.muzzle(x, y, angle, COLORS.turretBullet);
this.services.audio.play('turret-shot');
});
if (!running) {
this.effects.burst(turret.x, turret.y, COLORS.turret, 14);
turret.destroy();
this.turrets.splice(i, 1);
}
}
for (const pod of this.pods.getChildren().slice()) {
if (!pod.tick(now)) pod.destroy();
}
}
updateMortars(now) {
for (let i = this.mortars.length - 1; i >= 0; i -= 1) {
const { marker, cfg } = this.mortars[i];
if (!marker.isDue(now)) {
marker.update(now);
continue;
}
marker.destroy();
this.mortars.splice(i, 1);
this.effects.explosion(marker.x, marker.y, cfg.radius, COLORS.mortar);
this.services.audio.play('explosion-small');
if (inCircle(this.player, marker, cfg.radius, PLAYER.radius)) this.damagePlayer(cfg.damage);
}
}
updateProjectiles() {
this.playerBullets.update(this.worldRect);
this.enemyBullets.update(this.worldRect);
}
drawHealthBars() {
const g = this.healthBars;
g.clear();
for (const enemy of this.enemies.getChildren()) {
if (!enemy.alive || (enemy.hp >= enemy.maxHp && !enemy.isElite)) continue;
const width = enemy.isElite ? 72 : 30;
const height = enemy.isElite ? 7 : 4;
const x = enemy.x - width / 2;
const y = enemy.y - enemy.type.radius - 12;
g.fillStyle(0x000000, 0.6);
g.fillRect(x - 1, y - 1, width + 2, height + 2);
g.fillStyle(enemy.isElite ? COLORS.gold : COLORS.hpBarLow, 1);
g.fillRect(x, y, (width * enemy.hp) / enemy.maxHp, height);
}
}
/** @returns {{x: number, y: number}} Mausposition in Weltkoordinaten. */
aimPoint() {
const point = this.input.activePointer.positionToCamera(this.cameras.main);
return { x: point.x, y: point.y };
}
// ------------------------------------------------------------------ Gegner
spawnGroup(group, plan) {
let point = spawnPointOnEdge(group.edge, GAME.world, GAME.spawnMargin, this.rng);
if (distance(point, this.player) < MIN_SPAWN_DISTANCE) {
point = spawnPointOnEdge(OPPOSITE_EDGE[group.edge], GAME.world, GAME.spawnMargin, this.rng);
}
const margin = GAME.spawnMargin;
for (let i = 0; i < group.count; i += 1) {
const x = clamp(point.x + randFloat(this.rng, -SPAWN_SPREAD, SPAWN_SPREAD), margin, GAME.world.width - margin);
const y = clamp(point.y + randFloat(this.rng, -SPAWN_SPREAD, SPAWN_SPREAD), margin, GAME.world.height - margin);
this.spawnEnemy(group.type, x, y, { hpMultiplier: plan.hpMultiplier, speedMultiplier: plan.speedMultiplier });
}
const type = ENEMY_TYPES[group.type];
if (type.tier === 'elite') {
this.announce(WAVE.elite[type.id] ?? type.name, '', 'elite');
this.services.audio.play('alarm');
}
}
/**
* @param {string} typeId
* @param {number} x
* @param {number} y
* @param {{hpMultiplier?: number, speedMultiplier?: number, parent?: Enemy}} [options]
* @returns {Enemy}
*/
spawnEnemy(typeId, x, y, options = {}) {
const enemy = new Enemy(this, x, y, typeId, { now: this.gameTime, rng: this.rng, ...options });
this.enemies.add(enemy);
enemy.initBody();
enemy.setAlpha(0);
this.tweens.add({ targets: enemy, alpha: 1, duration: 250 });
return enemy;
}
spawnBrood(mother, count) {
const childType = mother.type.brood.childType;
for (let i = 0; i < count; i += 1) {
const angle = this.rng() * Math.PI * 2;
const reach = mother.type.radius + ENEMY_TYPES[childType].radius;
const child = this.spawnEnemy(childType, mother.x + Math.cos(angle) * reach, mother.y + Math.sin(angle) * reach, {
hpMultiplier: mother.multipliers.hp,
speedMultiplier: mother.multipliers.speed,
parent: mother,
});
child.setAlpha(1);
mother.childCount += 1;
}
this.effects.burst(mother.x, mother.y, COLORS.swarm, 10);
this.services.audio.play('brood');
}
scheduleMortar(x, y, cfg) {
const now = this.gameTime;
this.mortars.push({ marker: new GroundMarker(this, x, y, cfg.radius, COLORS.mortar, now, now + cfg.delayMs), cfg });
this.services.audio.play('mortar');
}
/**
* @param {Enemy} enemy
* @param {number} amount
* @param {number} hitX
* @param {number} hitY
*/
damageEnemy(enemy, amount, hitX, hitY) {
if (!enemy.alive) return;
const killed = enemy.takeDamage(amount);
this.effects.flash(enemy);
this.effects.burst(hitX, hitY, FACTION_COLORS[enemy.type.faction], 4);
this.services.audio.play(enemy.type.faction === 'legion' ? 'hit-metal' : 'hit-flesh');
if (killed) this.killEnemy(enemy);
}
/** @param {Enemy} enemy */
killEnemy(enemy) {
const now = this.gameTime;
const previousMultiplier = this.score.multiplierAt(now);
const result = this.score.registerKill(enemy.type.points, now);
const label = result.multiplier > 1 ? `+${result.awarded} ${formatMultiplier(result.multiplier)}` : `+${result.awarded}`;
this.effects.floatingText(enemy.x, enemy.y - enemy.type.radius, label);
if (result.multiplier > previousMultiplier) {
// Neue Multiplikatorstufe deutlich am Präsidenten melden – Belohnung soll man sehen.
this.effects.floatingText(this.player.x, this.player.y - 48, `SERIE ${formatMultiplier(result.multiplier)}`, '#ffe28a', 26);
}
this.effects.burst(enemy.x, enemy.y, FACTION_COLORS[enemy.type.faction], enemy.isElite ? 40 : 12);
if (enemy.isElite) this.effects.explosion(enemy.x, enemy.y, enemy.type.radius * 3, COLORS.gold);
if (enemy.parentEnemy) enemy.parentEnemy.childCount = Math.max(0, enemy.parentEnemy.childCount - 1);
this.services.audio.play(enemy.type.faction === 'legion' ? 'death-legion' : 'death-swarm');
// Körper sofort deaktivieren (zählt nicht mehr als lebend, keine Kollisionen),
// die Grafik aber kurz ausblenden statt sie hart verschwinden zu lassen.
enemy.disableBody(true, false);
this.tweens.add({
targets: enemy,
alpha: 0,
scale: 0.4,
duration: 220,
onComplete: () => enemy.destroy(),
});
}
// ------------------------------------------------------------------ Kollisionen
onBulletHitEnemy(bullet, enemy) {
if (!bullet.active || !enemy.alive) return;
this.playerBullets.kill(bullet);
this.damageEnemy(enemy, bullet.damage, bullet.x, bullet.y);
}
onEnemyBulletHitPlayer(bullet) {
if (!bullet.active || this.isOver) return;
this.enemyBullets.kill(bullet);
this.damagePlayer(bullet.damage);
}
/** @param {Enemy} enemy */
onEnemyContact(enemy) {
const now = this.gameTime;
if (!enemy.alive || this.isOver || this.player.isDodging(now)) return;
if (now - enemy.lastContactAt < enemy.type.contactCooldownMs) return;
enemy.lastContactAt = now;
this.damagePlayer(enemy.type.contactDamage);
}
/** @param {SupplyPod} pod */
onPickup(pod) {
if (pod.collected || this.isOver) return;
pod.collected = true;
this.player.heal(pod.cfg.heal);
this.player.refillAmmo();
this.lowAmmoHinted = false;
this.feedback(ORBITAL_TEXT.supplyPickup(pod.cfg.heal), 'ok');
this.effects.burst(pod.x, pod.y, COLORS.supply, 20);
this.services.audio.play('pickup');
pod.destroy();
}
/**
* @param {number} amount
* @returns {number} Tatsächlich erlittener Schaden.
*/
damagePlayer(amount) {
const dealt = this.player.takeDamage(amount, this.gameTime);
if (dealt <= 0) return 0;
this.effects.flash(this.player);
this.effects.shake('hit');
this.services.audio.play('player-hit');
if (SCORING.damageBreaksStreak) this.score.breakStreak();
this.events.emit('player-damaged', dealt);
if (!this.player.alive) this.onPlayerDeath();
return dealt;
}
// ------------------------------------------------------------------ Orbital-Befehle
onOrbitalFeedback(kind, command, remainingMs) {
const audio = this.services.audio;
if (kind === 'step') audio.play('combo-step');
else if (kind === 'fail') {
audio.play('combo-fail');
this.feedback(ORBITAL_TEXT.invalid, 'warn');
} else if (kind === 'notReady') {
audio.play('combo-fail');
this.feedback(ORBITAL_TEXT.notReady(command.shortName, Math.ceil(remainingMs / 1000)), 'warn');
} else if (kind === 'deploy') {
audio.play('orbital-call');
this.feedback(ORBITAL_TEXT.requested(command.shortName), 'ok');
}
}
onOrbitalImpact(command, x, y) {
const center = { x, y };
this.effects.explosion(x, y, command.radius, ORBITAL_COLORS[command.id] ?? COLORS.gold);
this.services.audio.play(command.id === 'strike' ? 'explosion-big' : 'explosion-small');
for (const enemy of this.enemies.getChildren().slice()) {
if (enemy.alive && inCircle(enemy, center, command.radius, enemy.type.radius)) {
this.damageEnemy(enemy, command.damage, enemy.x, enemy.y);
}
}
if (command.playerDamage > 0 && inCircle(this.player, center, command.radius, PLAYER.radius)) {
if (this.damagePlayer(command.playerDamage) > 0 && command.id === 'strike') {
this.feedback(ORBITAL_TEXT.selfHit, 'warn');
}
}
if (command.id === 'supply' && !this.isOver) {
const pod = new SupplyPod(this, x, y, command, this.gameTime);
this.pods.add(pod);
pod.initBody();
}
if (command.id === 'turret' && !this.isOver) {
this.turrets.push(new Turret(this, x, y, command, this.gameTime));
this.feedback(ORBITAL_TEXT.turretOnline, 'ok');
}
}
// ------------------------------------------------------------------ Wellen
onWaveStart(plan) {
const factionKey = plan.factions.length > 1 ? 'mixed' : plan.factions[0];
this.announce(WAVE.incoming(plan.number), WAVE.factionLine[factionKey], 'wave');
this.services.audio.play('wave-start');
}
onWaveCleared(waveNumber) {
const bonus = this.score.addBonus(waveBonus(waveNumber));
const healed = this.player.heal(PLAYER.waveClearHeal);
const lines = [WAVE.bonus(bonus)];
if (healed > 0) lines.push(WAVE.heal(healed));
lines.push(`„${this.nextPropaganda()}“`);
this.announce(WAVE.cleared(waveNumber), lines.join('\n'), 'cleared');
this.services.audio.play('wave-cleared');
}
/** Propaganda ohne direkte Wiederholung: Stapel mischen und nacheinander ziehen. */
nextPropaganda() {
if (this.propagandaDeck.length === 0) {
this.propagandaDeck = PROPAGANDA.slice();
for (let i = this.propagandaDeck.length - 1; i > 0; i -= 1) {
const j = Math.floor(this.rng() * (i + 1));
[this.propagandaDeck[i], this.propagandaDeck[j]] = [this.propagandaDeck[j], this.propagandaDeck[i]];
}
}
return this.propagandaDeck.pop();
}
// ------------------------------------------------------------------ Meldungen
/**
* Große Einblendung im HUD.
* @param {string} title
* @param {string} subtitle
* @param {'wave'|'cleared'|'elite'} kind
*/
announce(title, subtitle, kind) {
this.events.emit('announce', { title, subtitle, kind });
}
/**
* Kurze Statuszeile im HUD.
* @param {string} text
* @param {'ok'|'warn'} tone
*/
feedback(text, tone) {
this.events.emit('feedback', { text, tone });
}
// ------------------------------------------------------------------ Pause und Ende
openPause() {
if (this.isOver || !this.scene.isActive()) return;
this.scene.pause();
this.scene.launch('Pause');
}
autoPause() {
// Fenster verliert den Fokus: lieber pausieren, als den Präsidenten allein zu lassen.
if (this.scene.isActive() && !this.isOver) this.openPause();
}
onPlayerDeath() {
if (this.isOver) return;
this.isOver = true;
this.services.audio.play('player-death');
this.services.audio.stopMusic();
this.effects.explosion(this.player.x, this.player.y, 120, COLORS.crimson);
this.player.setTint(0x555555);
this.tweens.add({ targets: this.player, alpha: 0.3, duration: FX.deathDelayMs });
this.time.delayedCall(FX.deathDelayMs, () => this.finish());
}
finish() {
const snapshot = this.score.snapshot(this.gameTime);
const stats = {
score: snapshot.score,
wave: Math.max(1, this.waves.waveNumber),
kills: snapshot.kills,
bestStreak: snapshot.bestStreak,
durationMs: this.gameTime,
runToken: this.runToken,
};
this.scene.stop('Hud');
this.scene.start('GameOver', stats);
}
cleanup() {
this.orbital?.destroy();
this.game.events.off('blur', this.autoPause, this);
this.input.setDefaultCursor('default');
this.services.audio.stopMusic();
}
// ------------------------------------------------------------------ HUD-Daten
/** Zustand für das HUD (wird jeden Frame von HudScene gelesen). */
hudState() {
const now = this.gameTime;
const { player } = this;
const weapon = player.weapon;
return {
hp: player.hp,
maxHp: player.maxHp,
dodge: player.dodgeReadiness(now),
weaponIndex: player.weaponIndex,
weapons: player.weapons.map((w) => ({ name: w.def.shortName, empty: w.isEmpty })),
weaponName: weapon.def.name,
magazine: weapon.magazine,
magazineSize: weapon.def.magazineSize,
reserve: weapon.reserve,
reloading: weapon.isReloading,
reloadProgress: weapon.reloadProgress(now),
score: this.score.snapshot(now),
wave: {
number: this.waves.waveNumber,
state: this.waves.state,
enemies: this.waves.pendingEnemies + this.enemies.countActive(true),
nextInMs: this.waves.intermissionRemaining(now),
},
orbital: this.orbital.hudState(now),
over: this.isOver,
};
}
}