450 lines
12 KiB
JavaScript
450 lines
12 KiB
JavaScript
/**
|
||
* 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,
|
||
});
|