/** * HUD über der Spielszene: Leben, Munition, Welle, Punkte, Serie, Rolle, * Orbital-Befehle mit Abklingzeiten, Einblendungen und Fadenkreuz. * * Läuft als eigene Szene, damit das HUD nicht mit der Kamera mitscrollt und nicht * vom Bildschirmwackeln betroffen ist. Texte werden nur bei Änderungen neu gesetzt * (setText rendert intern eine Textur neu und ist vergleichsweise teuer). * @module scenes/HudScene */ import { COLORS, DIRECTION_SYMBOLS, FX, ORBITAL, PLAYER, WEAPONS } from '../config.js'; import { ORBITAL_TEXT, PRESIDENT, WAVE } from '../content/texts.js'; import { formatCooldown, formatMultiplier, formatScore } from '../logic/format.js'; import { TEXTURE_KEYS } from '../systems/Placeholders.js'; import { textStyle, UI, uiStyle } from '../ui/theme.js'; const MARGIN = 24; const HP_BAR = Object.freeze({ width: 280, height: 18 }); const DODGE_BAR = Object.freeze({ width: 120, height: 6 }); const RELOAD_BAR = Object.freeze({ width: 220, height: 6 }); const STREAK_BAR = Object.freeze({ width: 140, height: 5 }); const ORBITAL_PANEL = Object.freeze({ width: 350, rowHeight: 34, arrowsX: 150 }); const SLOT_GAP = 22; /** Ab diesem Anteil Leben wird der Balken rot und der Bildschirmrand pulsiert. */ const LOW_HP_RATIO = 0.3; const LOW_HP_BORDER = 16; const LOW_HP_PULSE_MS = 140; /** Randpfeile für Gegner außerhalb des Bildes. */ const INDICATOR = Object.freeze({ edge: 22, size: 9, eliteSize: 14, max: 30 }); const FEEDBACK_MS = 2200; export class HudScene extends Phaser.Scene { constructor() { super('Hud'); } create() { this.gameScene = this.scene.get('Game'); const { width, height } = this.scale; /** Letzter gesetzter Text je Textobjekt (nicht `this.cache` – das ist Phasers Cache). */ this.textCache = new Map(); this.colorCache = new Map(); this.g = this.add.graphics(); // Oben links: Leben und Rolle this.add.text(MARGIN, 14, PRESIDENT.toUpperCase(), uiStyle({ fontSize: '15px', color: UI.colors.gold })); this.hpText = this.add.text(MARGIN + HP_BAR.width + 12, 34, '', uiStyle({ fontSize: '17px' })); this.add.text(MARGIN, 64, 'ROLLE', uiStyle({ fontSize: '13px', color: UI.colors.muted })); // Oben Mitte: Welle this.waveTitle = this.add.text(width / 2, 12, '', textStyle({ fontSize: '28px', color: UI.colors.gold })).setOrigin(0.5, 0); this.waveInfo = this.add.text(width / 2, 48, '', uiStyle({ fontSize: '18px' })).setOrigin(0.5, 0); // Oben rechts: Punkte und Multiplikator this.scoreText = this.add.text(width - MARGIN, 10, '', textStyle({ fontSize: '32px' })).setOrigin(1, 0); this.multText = this.add.text(width - MARGIN, 50, '', uiStyle({ fontSize: '20px', color: UI.colors.gold })).setOrigin(1, 0); // Unten links: Waffe // Waffennamen stehen fest – einmal setzen und nach tatsächlicher Breite anordnen. let slotX = MARGIN; this.slotTexts = PLAYER.weaponSlots.map((id, i) => { const slot = this.add.text(slotX, height - 132, `${i + 1} ${WEAPONS[id].shortName}`, uiStyle({ fontSize: '15px' })); slotX += slot.width + SLOT_GAP; return slot; }); this.weaponName = this.add.text(MARGIN, height - 106, '', uiStyle({ fontSize: '18px', color: UI.colors.gold })); this.ammoText = this.add.text(MARGIN, height - 80, '', uiStyle({ fontSize: '34px' })); // Unten rechts: Orbital-Befehle const panelX = width - MARGIN - ORBITAL_PANEL.width; const panelTop = height - MARGIN - ORBITAL.commands.length * ORBITAL_PANEL.rowHeight - 34; this.orbitalPanel = { x: panelX, y: panelTop }; this.orbitalTitle = this.add.text(panelX + 10, panelTop + 7, ORBITAL_TEXT.panelTitle, uiStyle({ fontSize: '15px', color: UI.colors.gold })); this.orbitalRows = ORBITAL.commands.map((command, i) => { const y = panelTop + 34 + i * ORBITAL_PANEL.rowHeight; return { name: this.add.text(panelX + 10, y + 1, command.shortName, uiStyle({ fontSize: '15px' })), arrows: command.sequence.map((dir, j) => // Pfeile aus einer Symbolschrift, damit sie überall gleich breit und gut erkennbar sind. this.add.text(panelX + ORBITAL_PANEL.arrowsX + j * 24, y - 3, DIRECTION_SYMBOLS[dir], textStyle({ fontSize: '19px', fontFamily: UI.symbolFont })), ), cooldown: this.add.text(panelX + ORBITAL_PANEL.width - 10, y, '', uiStyle({ fontSize: '16px', color: UI.colors.muted })).setOrigin(1, 0), y, }; }); // Einblendungen this.bannerTitle = this.add.text(width / 2, 150, '', textStyle({ fontSize: '46px', color: UI.colors.gold, strokeThickness: 8 })).setOrigin(0.5).setAlpha(0); this.bannerSubtitle = this.add .text(width / 2, 186, '', { ...uiStyle({ fontSize: '22px', align: 'center' }), wordWrap: { width: 900 } }) .setOrigin(0.5, 0) .setAlpha(0); this.feedbackText = this.add.text(width / 2, height - 170, '', uiStyle({ fontSize: '20px' })).setOrigin(0.5).setAlpha(0); this.vignette = this.add.rectangle(0, 0, width, height, COLORS.crimson, 0).setOrigin(0); this.crosshair = this.add.image(0, 0, TEXTURE_KEYS.crosshair); const events = this.gameScene.events; events.on('announce', this.showBanner, this); events.on('feedback', this.showFeedback, this); events.on('player-damaged', this.flashDamage, this); this.events.once('shutdown', () => { events.off('announce', this.showBanner, this); events.off('feedback', this.showFeedback, this); events.off('player-damaged', this.flashDamage, this); }); } /** * Setzt Text nur, wenn er sich geändert hat. * @param {Phaser.GameObjects.Text} target * @param {string} value */ setText(target, value) { if (this.textCache.get(target) !== value) { this.textCache.set(target, value); target.setText(value); } } /** * Setzt die Textfarbe nur bei Änderung (setColor rendert den Text sonst neu). * @param {Phaser.GameObjects.Text} target * @param {string} color */ setColor(target, color) { if (this.colorCache.get(target) !== color) { this.colorCache.set(target, color); target.setColor(color); } } update() { const gameScene = this.gameScene; if (!gameScene?.player || !(gameScene.sys.isActive() || gameScene.sys.isPaused())) return; const s = gameScene.hudState(); const paused = gameScene.sys.isPaused(); const g = this.g; g.clear(); // Leben const hpRatio = s.hp / s.maxHp; this.bar(g, MARGIN, 36, HP_BAR.width, HP_BAR.height, hpRatio, hpRatio <= LOW_HP_RATIO ? COLORS.hpBarLow : COLORS.hpBar); this.setText(this.hpText, `${Math.ceil(s.hp)} / ${s.maxHp}`); this.bar(g, MARGIN + 56, 70, DODGE_BAR.width, DODGE_BAR.height, s.dodge, s.dodge >= 1 ? COLORS.gold : COLORS.muted); // Welle this.setText(this.waveTitle, s.wave.number > 0 ? WAVE.incoming(s.wave.number) : WAVE.preparing); const info = s.wave.state === 'intermission' ? WAVE.nextIn(Math.ceil(s.wave.nextInMs / 1000)) : WAVE.remaining(s.wave.enemies); this.setText(this.waveInfo, info); // Punkte this.setText(this.scoreText, formatScore(s.score.score)); let streakLabel = ''; if (s.score.multiplier > 1) streakLabel = `${formatMultiplier(s.score.multiplier)} · Serie ${s.score.streak}`; else if (s.score.streak >= 2) streakLabel = `Serie ${s.score.streak}`; this.setText(this.multText, streakLabel); if (s.score.streakTimeLeft > 0) { this.bar(g, this.scale.width - MARGIN - STREAK_BAR.width, 80, STREAK_BAR.width, STREAK_BAR.height, s.score.streakTimeLeft, COLORS.gold); } // Waffe s.weapons.forEach((weapon, i) => { const active = i === s.weaponIndex; this.setColor(this.slotTexts[i], active ? UI.colors.gold : weapon.empty ? UI.colors.danger : UI.colors.muted); }); this.setText(this.weaponName, s.weaponName); const reserve = Number.isFinite(s.reserve) ? String(s.reserve) : '∞'; this.setText(this.ammoText, s.reloading ? `NACHLADEN · ${reserve}` : `${s.magazine} / ${s.magazineSize} · ${reserve}`); this.setColor(this.ammoText, s.magazine === 0 && !s.reloading ? UI.colors.danger : UI.colors.cream); if (s.reloading) this.bar(g, MARGIN, this.scale.height - 36, RELOAD_BAR.width, RELOAD_BAR.height, s.reloadProgress, COLORS.gold); this.drawOrbitalPanel(g, s.orbital); this.drawOffscreenIndicators(g, gameScene); if (hpRatio <= LOW_HP_RATIO && !s.over) { // Pulsierender Rand statt Dauerrot: auffällig, verdeckt aber nichts. const pulse = 0.35 + 0.35 * Math.sin(this.time.now / LOW_HP_PULSE_MS); g.lineStyle(LOW_HP_BORDER, COLORS.crimson, pulse); g.strokeRect(LOW_HP_BORDER / 2, LOW_HP_BORDER / 2, this.scale.width - LOW_HP_BORDER, this.scale.height - LOW_HP_BORDER); } // Fadenkreuz folgt der Maus; in der Pause und nach dem Tod ausgeblendet. const pointer = this.input.activePointer; this.crosshair.setPosition(pointer.x, pointer.y); this.crosshair.setVisible(!paused && !s.over); } drawOrbitalPanel(g, orbital) { const { x, y } = this.orbitalPanel; const height = 34 + orbital.commands.length * ORBITAL_PANEL.rowHeight; g.fillStyle(COLORS.navy, 0.72); g.fillRect(x, y, ORBITAL_PANEL.width, height); g.lineStyle(2, orbital.active ? COLORS.gold : COLORS.muted, orbital.active ? 1 : 0.5); g.strokeRect(x, y, ORBITAL_PANEL.width, height); orbital.commands.forEach((command, i) => { const row = this.orbitalRows[i]; // Dünner Ladebalken unter der Zeile if (!command.ready) { g.fillStyle(COLORS.muted, 0.35); g.fillRect(x + 10, row.y + 22, ORBITAL_PANEL.width - 20, 3); g.fillStyle(COLORS.gold, 0.9); g.fillRect(x + 10, row.y + 22, (ORBITAL_PANEL.width - 20) * command.cooldownProgress, 3); } this.setColor(row.name, command.ready ? UI.colors.cream : UI.colors.muted); row.arrows.forEach((arrow, j) => { const done = orbital.active && j < command.progress; this.setColor(arrow, done ? UI.colors.gold : command.ready ? UI.colors.cream : UI.colors.muted); arrow.setScale(done ? 1.2 : 1); }); this.setText(row.cooldown, command.ready ? 'bereit' : `${formatCooldown(command.remainingMs)} s`); this.setColor(row.cooldown, command.ready ? UI.colors.ok : UI.colors.muted); }); } /** * Pfeile am Bildschirmrand für Gegner außerhalb des sichtbaren Bereichs. Die Arena ist * größer als der Bildschirm – ohne Hinweis kämen Angriffe scheinbar aus dem Nichts. * @param {Phaser.GameObjects.Graphics} g * @param {Phaser.Scene & {enemies: Phaser.Physics.Arcade.Group}} gameScene */ drawOffscreenIndicators(g, gameScene) { const view = gameScene.cameras.main.worldView; const cx = this.scale.width / 2; const cy = this.scale.height / 2; let shown = 0; for (const enemy of gameScene.enemies.getChildren()) { if (!enemy.alive || view.contains(enemy.x, enemy.y)) continue; const dx = enemy.x - view.x - cx; const dy = enemy.y - view.y - cy; // Schnittpunkt der Linie Bildmitte→Gegner mit dem (eingerückten) Bildschirmrand. const reach = Math.min((cx - INDICATOR.edge) / Math.abs(dx || 1e-6), (cy - INDICATOR.edge) / Math.abs(dy || 1e-6)); const x = cx + dx * reach; const y = cy + dy * reach; const angle = Math.atan2(dy, dx); const size = enemy.isElite ? INDICATOR.eliteSize : INDICATOR.size; const color = enemy.isElite ? COLORS.gold : enemy.type.faction === 'swarm' ? COLORS.swarm : COLORS.legion; const cos = Math.cos(angle); const sin = Math.sin(angle); g.fillStyle(color, enemy.isElite ? 1 : 0.8); g.fillTriangle( x + cos * size, y + sin * size, x - cos * size * 0.6 - sin * size * 0.7, y - sin * size * 0.6 + cos * size * 0.7, x - cos * size * 0.6 + sin * size * 0.7, y - sin * size * 0.6 - cos * size * 0.7, ); shown += 1; if (shown >= INDICATOR.max) break; } } /** Einfacher Balken mit dunklem Hintergrund. */ bar(g, x, y, width, height, ratio, color) { g.fillStyle(0x000000, 0.55); g.fillRect(x - 2, y - 2, width + 4, height + 4); g.fillStyle(color, 1); g.fillRect(x, y, width * Math.max(0, Math.min(1, ratio)), height); } /** @param {{title: string, subtitle: string, kind: string}} message */ showBanner(message) { this.tweens.killTweensOf([this.bannerTitle, this.bannerSubtitle]); this.bannerTitle.setText(message.title).setColor(message.kind === 'elite' ? UI.colors.danger : UI.colors.gold); this.bannerSubtitle.setText(message.subtitle); this.bannerTitle.setAlpha(0).setScale(1.3); this.bannerSubtitle.setAlpha(0); this.tweens.add({ targets: this.bannerTitle, alpha: 1, scale: 1, duration: 220, ease: 'Back.easeOut' }); this.tweens.add({ targets: this.bannerSubtitle, alpha: 1, duration: 300 }); this.tweens.add({ targets: [this.bannerTitle, this.bannerSubtitle], alpha: 0, delay: FX.announcementMs, duration: 500, }); } /** @param {{text: string, tone: 'ok'|'warn'}} message */ showFeedback(message) { this.tweens.killTweensOf(this.feedbackText); this.feedbackText.setText(message.text).setColor(message.tone === 'warn' ? UI.colors.danger : UI.colors.cyan); this.feedbackText.setAlpha(1); this.tweens.add({ targets: this.feedbackText, alpha: 0, delay: FEEDBACK_MS, duration: 400 }); } flashDamage() { this.tweens.killTweensOf(this.vignette); this.vignette.setFillStyle(COLORS.crimson, 0.28); this.tweens.add({ targets: this.vignette, fillAlpha: 0, duration: 320 }); } }