/** * Punkte, Serien und Multiplikator (ohne Phaser). * * Regeln: Jeder Abschuss innerhalb von `streakWindowMs` nach dem letzten setzt die * Serie fort. Alle `stepKills` Abschüsse steigt der Multiplikator um `stepValue` * bis `maxMultiplier`. Ein Treffer am Präsidenten beendet die Serie – das belohnt * sauberes Spielen statt blindem Draufhalten. * @module logic/scoring */ import { SCORING } from '../config.js'; /** * Multiplikator für eine Serienlänge. * @param {number} streak Anzahl Abschüsse in Folge (inkl. des aktuellen). * @param {typeof SCORING} [cfg] * @returns {number} */ export function multiplierForStreak(streak, cfg = SCORING) { if (streak <= 0) return 1; return Math.min(cfg.maxMultiplier, 1 + Math.floor(streak / cfg.stepKills) * cfg.stepValue); } /** * Bonus für eine überstandene Welle (ohne Multiplikator). * @param {number} waveNumber * @param {typeof SCORING} [cfg] * @returns {number} */ export function waveBonus(waveNumber, cfg = SCORING) { return waveNumber * cfg.waveBonusPerWave; } /** * Führt Punktestand, Serie und Statistik einer Spielrunde. */ export class ScoreKeeper { /** @param {typeof SCORING} [cfg] */ constructor(cfg = SCORING) { this.cfg = cfg; this.score = 0; this.kills = 0; this.streak = 0; this.bestStreak = 0; this.lastKillAt = -Infinity; } /** * Läuft die Serie zum Zeitpunkt `now` noch? * @param {number} now Spielzeit in ms. * @returns {boolean} */ isStreakActive(now) { return this.streak > 0 && now - this.lastKillAt <= this.cfg.streakWindowMs; } /** * Aktueller Multiplikator (1, wenn die Serie abgelaufen ist). * @param {number} now * @returns {number} */ multiplierAt(now) { return this.isStreakActive(now) ? multiplierForStreak(this.streak, this.cfg) : 1; } /** * Verbleibender Anteil des Serienfensters (1 = gerade getroffen, 0 = abgelaufen). * @param {number} now * @returns {number} */ streakTimeLeft(now) { if (!this.isStreakActive(now)) return 0; return 1 - (now - this.lastKillAt) / this.cfg.streakWindowMs; } /** * Verbucht einen Abschuss. * @param {number} basePoints Punkte des Gegnertyps. * @param {number} now Spielzeit in ms. * @returns {{awarded: number, multiplier: number, streak: number}} */ registerKill(basePoints, now) { if (!this.isStreakActive(now)) { this.streak = 0; } this.streak += 1; this.kills += 1; this.bestStreak = Math.max(this.bestStreak, this.streak); this.lastKillAt = now; const multiplier = multiplierForStreak(this.streak, this.cfg); const awarded = Math.round(basePoints * multiplier); this.score += awarded; return { awarded, multiplier, streak: this.streak }; } /** Beendet die laufende Serie (z. B. wenn der Präsident getroffen wird). */ breakStreak() { this.streak = 0; } /** * Fügt Bonuspunkte ohne Multiplikator hinzu. * @param {number} points * @returns {number} Tatsächlich gutgeschriebene Punkte. */ addBonus(points) { const awarded = Math.max(0, Math.round(points)); this.score += awarded; return awarded; } /** * Momentaufnahme für HUD und Game-Over-Bildschirm. * @param {number} now */ snapshot(now) { const active = this.isStreakActive(now); return { score: this.score, kills: this.kills, streak: active ? this.streak : 0, multiplier: this.multiplierAt(now), streakTimeLeft: this.streakTimeLeft(now), bestStreak: this.bestStreak, }; } }