Files
slop-game-opus5.5/app/static/js/scenes/HudScene.js
T
2026-09-24 17:57:54 +02:00

293 lines
13 KiB
JavaScript
Raw 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.
/**
* 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 });
}
}