665 lines
25 KiB
JavaScript
665 lines
25 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
}
|
||
}
|