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