/** * 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} 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, }; } }