/** * Zentrale Spielkonfiguration: alle Balancing-Werte an einem Ort. * * Keine Magic Numbers im restlichen Code – wer das Spiel ausbalancieren will, * ändert nur diese Datei. Einheiten: Pixel, Pixel pro Sekunde, Millisekunden. * Die Werte sind in docs/GAME_DESIGN.md erläutert. * * Achtung: Die Punkte-Obergrenze im Backend (app/config.py, GAME_RULE_DEFAULTS) * ist auf diese Werte abgestimmt. Wer Punkte stark erhöht, muss sie dort anpassen. */ /** Darstellung und Spielwelt. */ export const GAME = Object.freeze({ width: 1280, height: 720, backgroundColor: '#11151f', /** Die Arena ist größer als der Bildschirm; die Kamera folgt dem Präsidenten. */ world: Object.freeze({ width: 2400, height: 1600 }), /** Gegner erscheinen so weit außerhalb des sichtbaren Randes der Arena. */ spawnMargin: 40, cameraLerp: 0.12, /** Tiefe (Zeichenreihenfolge) der Ebenen. */ depth: Object.freeze({ ground: 0, markers: 5, pickups: 8, enemies: 10, player: 20, projectiles: 25, effects: 30, hud: 100, }), }); /** Präsident Maximilian Valor. */ export const PLAYER = Object.freeze({ maxHp: 100, speed: 240, radius: 18, /** Kurze Unverwundbarkeit nach einem Treffer, damit ein Schwarm nicht in einem Frame tötet. */ hitInvulnerabilityMs: 500, /** Heilung nach jeder überstandenen Welle – belohnt Fortschritt ohne Nachschub. */ waveClearHeal: 15, dodge: Object.freeze({ speed: 640, durationMs: 320, cooldownMs: 1500, }), /** Waffen in Slot-Reihenfolge (Tasten 1–3). */ weaponSlots: Object.freeze(['carbine', 'shotgun', 'pistol']), }); /** * Waffen. `reserveMax: null` bedeutet unbegrenzte Reserve (Dienstpistole als * Rückfalloption, damit der Spieler ohne Munition nie wehrlos ist). */ export const WEAPONS = Object.freeze({ carbine: Object.freeze({ id: 'carbine', name: 'Karabiner »Zuversicht«', shortName: 'Karabiner', magazineSize: 30, reserveStart: 180, reserveMax: 270, fireIntervalMs: 110, reloadMs: 1500, damage: 14, pellets: 1, spreadDeg: 3, bulletSpeed: 900, rangePx: 820, automatic: true, }), shotgun: Object.freeze({ id: 'shotgun', name: 'Schrotflinte »Überzeugungskraft«', shortName: 'Schrotflinte', magazineSize: 6, reserveStart: 24, reserveMax: 48, fireIntervalMs: 650, reloadMs: 1900, damage: 11, pellets: 8, spreadDeg: 24, bulletSpeed: 820, rangePx: 420, automatic: false, }), pistol: Object.freeze({ id: 'pistol', name: 'Dienstpistole »Anstand«', shortName: 'Pistole', magazineSize: 12, reserveStart: null, reserveMax: null, fireIntervalMs: 260, reloadMs: 1100, damage: 16, pellets: 1, spreadDeg: 2, bulletSpeed: 860, rangePx: 700, automatic: false, }), }); /** Faktionen (für Farben, Texte und Wellenzusammensetzung). */ export const FACTIONS = Object.freeze({ swarm: Object.freeze({ id: 'swarm', name: 'Die Schwärmer' }), legion: Object.freeze({ id: 'legion', name: 'Die Blechlegion' }), }); /** * Gegnertypen. `cost` ist der Preis im Wellenbudget, `behavior` wählt die KI. * Verhalten: * - swarm: umzingeln (jeder Krabbler hat einen eigenen Anflugwinkel), nah dran direkt angreifen * - leaper: verfolgen, dann mit Vorwarnung einen Sprungangriff ausführen * - brood: langsam verfolgen und dabei Krabbler ausbrüten * - kite: Abstand halten, seitlich ausweichen, aus der Distanz schießen * - flank: seitlich versetzt zum Spieler positionieren (Flanke), Salven schießen * - siege: großer Abstand, Fächersalven und Mörser mit Bodenmarkierung */ export const ENEMY_TYPES = Object.freeze({ krabbler: Object.freeze({ id: 'krabbler', name: 'Krabbler', faction: 'swarm', tier: 'small', behavior: 'swarm', hp: 20, speed: 175, radius: 12, contactDamage: 6, contactCooldownMs: 700, points: 10, cost: 1, groupSize: Object.freeze({ min: 4, max: 8 }), /** Abstand, ab dem der Krabbler den Umzingelungspunkt verlässt und direkt angreift. */ engageRange: 170, encircleRadius: 130, }), sprungkralle: Object.freeze({ id: 'sprungkralle', name: 'Sprungkralle', faction: 'swarm', tier: 'medium', behavior: 'leaper', hp: 60, speed: 125, radius: 17, contactDamage: 14, contactCooldownMs: 800, points: 30, cost: 3, groupSize: Object.freeze({ min: 1, max: 3 }), leap: Object.freeze({ triggerRange: 260, windupMs: 450, speed: 560, durationMs: 380, cooldownMs: 2400, }), }), brutmutter: Object.freeze({ id: 'brutmutter', name: 'Brutmutter', faction: 'swarm', tier: 'elite', behavior: 'brood', hp: 450, speed: 58, radius: 34, contactDamage: 28, contactCooldownMs: 1000, points: 250, cost: 15, groupSize: Object.freeze({ min: 1, max: 1 }), brood: Object.freeze({ intervalMs: 5000, spawnCount: 3, /** Obergrenze lebender Nachkommen, damit eine ignorierte Brutmutter nicht eskaliert. */ maxChildren: 6, childType: 'krabbler', }), }), nieter: Object.freeze({ id: 'nieter', name: 'Nieter-Drohne', faction: 'legion', tier: 'small', behavior: 'kite', hp: 30, speed: 95, radius: 13, contactDamage: 5, contactCooldownMs: 800, points: 20, cost: 2, groupSize: Object.freeze({ min: 2, max: 3 }), range: Object.freeze({ min: 220, max: 340 }), attack: Object.freeze({ cooldownMs: 1500, burst: 1, burstIntervalMs: 0, count: 1, spreadDeg: 0, damage: 7, speed: 330, /** Nur feuern, wenn der Spieler näher als das ist (etwas mehr als range.max). */ maxDistance: 480, }), }), blechsoldat: Object.freeze({ id: 'blechsoldat', name: 'Blechsoldat', faction: 'legion', tier: 'medium', behavior: 'flank', hp: 90, speed: 78, radius: 18, contactDamage: 10, contactCooldownMs: 900, points: 50, cost: 4, groupSize: Object.freeze({ min: 1, max: 3 }), range: Object.freeze({ min: 200, max: 380 }), /** Winkelversatz der Flankenposition relativ zur Linie Spieler→Gegner. */ flankAngleDeg: 70, flankDistance: 290, attack: Object.freeze({ cooldownMs: 2300, burst: 3, burstIntervalMs: 140, count: 1, spreadDeg: 4, damage: 8, speed: 400, maxDistance: 520, }), }), walzmeister: Object.freeze({ id: 'walzmeister', name: 'Walzmeister', faction: 'legion', tier: 'elite', behavior: 'siege', hp: 700, speed: 44, radius: 36, contactDamage: 25, contactCooldownMs: 1000, points: 400, cost: 18, groupSize: Object.freeze({ min: 1, max: 1 }), range: Object.freeze({ min: 260, max: 460 }), attack: Object.freeze({ cooldownMs: 2700, burst: 1, burstIntervalMs: 0, count: 7, spreadDeg: 50, damage: 10, speed: 290, maxDistance: 640, }), mortar: Object.freeze({ cooldownMs: 5200, delayMs: 1400, radius: 90, damage: 26, maxDistance: 700, }), }), }); /** Allgemeine Steuerungswerte der Gegner-KI. */ export const AI = Object.freeze({ /** Abstand, unter dem sich Gegner gegenseitig wegschieben (verhindert Klumpen). */ separationRadius: 34, separationWeight: 1.4, /** Richtungswechsel beim seitlichen Ausweichen (Strafen) in diesem Zeitfenster. */ strafeSwitchMs: Object.freeze({ min: 1400, max: 3200 }), /** Wie stark die Bewegung pro Frame zur Wunschrichtung nachgeführt wird (0..1). */ steeringResponsiveness: 0.18, }); /** Wellen-Generator (siehe logic/waves.js). */ export const WAVES = Object.freeze({ /** Budget(n) = firstBudget + growth * (n - 1) ^ exponent */ firstBudget: 8, budgetGrowth: 5, budgetExponent: 1.15, /** Wellen 1–2 nur Schwärmer, 3–4 nur Blechlegion, danach gemischt. */ legionFromWave: 3, mixedFromWave: 5, /** Jede n-te Welle bringt Elite-Gegner. */ eliteEvery: 5, maxElitesPerWave: 4, /** Anteil des Restbudgets, der in mittlere Gegner fließt – steigt mit der Welle. */ mediumShareBase: 0.1, mediumSharePerWave: 0.04, mediumShareMax: 0.5, /** Ab dieser Welle werden Gegner zäher und schneller. */ scalingFromWave: 6, hpScalePerWave: 0.07, speedScalePerWave: 0.012, speedScaleMax: 1.3, /** Zeitlicher Abstand zwischen zwei Spawn-Gruppen. */ groupDelayMs: Object.freeze({ min: 1300, max: 3000 }), /** Spätere Wellen spawnen schneller, aber nie schneller als dieser Faktor. */ groupDelayMinFactor: 0.45, groupDelayFactorPerWave: 0.035, /** Solange so viele Gegner leben, wartet der Spawner. */ maxAliveEnemies: 60, intermissionMs: 8000, firstWaveDelayMs: 1500, }); /** Punkte und Serien-Multiplikator (siehe logic/scoring.js). */ export const SCORING = Object.freeze({ /** Abschüsse innerhalb dieses Fensters setzen die Serie fort. */ streakWindowMs: 3000, /** Alle `stepKills` Abschüsse steigt der Multiplikator um `stepValue`. */ stepKills: 5, stepValue: 0.5, maxMultiplier: 4, /** Treffer am Präsidenten beenden die Serie. */ damageBreaksStreak: true, waveBonusPerWave: 100, }); /** * Orbital-Befehle: Taste `holdKey` gedrückt halten, dann die Richtungsfolge mit * Pfeiltasten oder WASD eingeben. Ziel ist die Mausposition (auf maxTargetRange begrenzt). * Keine Folge darf Anfang einer anderen sein (wird in den Tests geprüft). Die Codes sind * eigene Erfindungen; jeder beginnt mit einer anderen Richtung, damit man sich nach * dem ersten Tastendruck nicht mehr vertippen kann. */ export const ORBITAL = Object.freeze({ holdKey: 'Q', maxTargetRange: 560, commands: Object.freeze([ Object.freeze({ id: 'strike', name: 'Orbitalschlag »Morgenröte«', shortName: 'Orbitalschlag', sequence: Object.freeze(['up', 'right', 'down', 'down']), cooldownMs: 30000, delayMs: 2500, radius: 150, damage: 260, /** Auch der Präsident steht nicht über der Physik. */ playerDamage: 40, }), Object.freeze({ id: 'supply', name: 'Nachschubkapsel »Fürsorgepaket«', shortName: 'Nachschub', sequence: Object.freeze(['left', 'left', 'down', 'right']), cooldownMs: 45000, delayMs: 3000, radius: 70, damage: 120, playerDamage: 20, heal: 50, pickupRadius: 46, lifetimeMs: 30000, }), Object.freeze({ id: 'turret', name: 'Geschütz »Wachsamer Nachbar«', shortName: 'Geschütz', sequence: Object.freeze(['right', 'left', 'right', 'up', 'up']), cooldownMs: 60000, delayMs: 3000, radius: 60, damage: 80, playerDamage: 15, durationMs: 25000, fireIntervalMs: 150, range: 480, bulletDamage: 12, bulletSpeed: 950, }), ]), }); /** Darstellung von Kombinationspfeilen im HUD. */ export const DIRECTION_SYMBOLS = Object.freeze({ up: '↑', down: '↓', left: '←', right: '→' }); /** Farben (Platzhaltergrafiken, HUD, Effekte). */ export const COLORS = Object.freeze({ navy: 0x0d1321, crimson: 0xc1121f, gold: 0xf2c14e, cream: 0xf4ecd8, muted: 0xa9b1c4, ground: 0x2a2f24, groundAlt: 0x31372a, player: 0xf4ecd8, playerCape: 0xc1121f, swarm: 0x8fbf3a, swarmDark: 0x4f6d1e, legion: 0x9aa4b5, legionDark: 0x535c6b, playerBullet: 0xffe28a, enemyBullet: 0xff5a3c, turretBullet: 0x9be7ff, strike: 0xff7b00, supply: 0x4fd1c5, turret: 0x9be7ff, mortar: 0xff3d3d, hpBar: 0x5ad469, hpBarLow: 0xe0453a, }); /** Standardlautstärken (0..1) und Ton-Parameter. */ export const AUDIO = Object.freeze({ defaultVolumes: Object.freeze({ master: 0.8, music: 0.5, sfx: 0.8 }), /** Mindestabstand zwischen gleichen Effekten, damit Dauerfeuer nicht übersteuert. */ sfxMinIntervalMs: 45, }); /** Schlüssel für localStorage (versioniert, damit Formatänderungen alte Daten nicht brechen). */ export const STORAGE_KEYS = Object.freeze({ disclaimerAccepted: 'gloryDefense.disclaimerAccepted.v1', volumes: 'gloryDefense.volumes.v1', lastName: 'gloryDefense.lastName.v1', }); /** Grenzen, die mit dem Backend übereinstimmen müssen (app/validation.py). */ export const HIGHSCORE = Object.freeze({ nameMaxLength: 16, requestTimeoutMs: 8000, }); /** Effekte und Rückmeldung. */ export const FX = Object.freeze({ screenShake: Object.freeze({ hit: Object.freeze({ durationMs: 120, intensity: 0.004 }), explosion: Object.freeze({ durationMs: 350, intensity: 0.012 }), }), hitFlashMs: 80, damageNumberMs: 600, announcementMs: 3800, deathDelayMs: 1600, });