Add maze mini-boss and rebalance progression

This commit is contained in:
2026-08-20 15:19:53 +02:00
parent e6a370f952
commit 1f311d3208
10 changed files with 807 additions and 40 deletions
+302 -28
View File
@@ -15,6 +15,9 @@ import { SpiderQueen } from './SpiderQueen';
import { FrostGiant } from './FrostGiant';
import { Necromancer } from './Necromancer';
import { Dragon } from './Dragon';
import { Maze } from './Maze';
import { MazeGuard } from './MazeGuard';
import { Gnome } from './Gnome';
import { Crab } from './Crab';
import { Turret } from './Turret';
import { Shaman } from './Shaman';
@@ -95,6 +98,30 @@ export class Game {
private static readonly SPAWN_VIEW_MIN = 10;
private static readonly SPAWN_VIEW_MAX = 24;
// Arena-Theme: eine Stufe pro besiegten Boss
private arena!: Arena;
private arenaStage = 0;
private themeFogTarget = new THREE.Color(0x0d1117);
private themeLightTarget = new THREE.Color(0xffeebb);
private static readonly THEME_LIGHTS = [
0xffeebb, 0xffddaa, 0x99ccff, 0xffbb99, 0xbbffcc, 0xbbddff, 0xddbbff, 0xffcc88,
];
// Labyrinth-Zwischenboss (Welle 5, 15, 25, ...): ersetzt die Welle komplett
maze: Maze | null = null;
private mazeActive = false;
private mazeTimer = 0;
private mazeTotal = 1;
private mazeLevel = 0;
private mazeGoalsLeft = 0;
private mazeGnome: Gnome | null = null;
private mazeGuards: MazeGuard[] = [];
private uiMazeArrow!: HTMLElement;
private static readonly MAZE_WAVES = [5, 15, 25, 35, 45, 55, 65];
private static readonly MAZE_BASE_TIME = 75;
private static readonly MAZE_TIME_STEP = 5;
private static readonly MAZE_MIN_TIME = 50;
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
[10, { name: 'Golem', spawn: () => new Golem() }],
@@ -240,6 +267,7 @@ export class Game {
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
this.uiMazeArrow = document.getElementById('maze-arrow')!;
this.uiGameOver = document.getElementById('game-over')!;
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
this.uiGameOverStats = document.getElementById('game-over-stats')!;
@@ -455,8 +483,8 @@ export class Game {
}
private setupArena() {
const arena = new Arena();
this.scene.add(arena);
this.arena = new Arena();
this.scene.add(this.arena);
}
private setupPlayer() {
@@ -598,6 +626,15 @@ export class Game {
btn.addEventListener('click', () => this.startBossFight(wave));
this.uiBossModeButtons.appendChild(btn);
}
for (const wave of Game.MAZE_WAVES) {
const btn = document.createElement('button');
btn.className = 'menu-button';
btn.textContent = `Labyrinth (Welle ${wave})`;
btn.style.fontSize = '14px';
btn.style.padding = '8px 20px';
btn.addEventListener('click', () => this.startMazeFight(wave));
this.uiBossModeButtons.appendChild(btn);
}
}
private showBossMode() {
@@ -633,8 +670,18 @@ export class Game {
this.beginRun();
}
private grantRandomSkills(wave: number) {
const picks = Math.round(wave * 0.7);
startMazeFight(wave: number) {
if (this.state === 'playing' || this.state === 'levelup') return;
this.seed = Math.floor(Math.random() * 1000000);
this.resetWorld();
this.spawns = wave;
this.grantRandomSkills(wave, 0.25);
this.startMazeWave();
this.beginRun();
}
private grantRandomSkills(wave: number, factor = 0.4) {
const picks = Math.round(wave * factor);
for (let i = 0; i < picks; i++) {
const offers = this.skillSystem.rollOffers();
if (offers.length === 0) break;
@@ -646,6 +693,157 @@ export class Game {
this.syncSkillVisibility();
}
// --- Labyrinth-Zwischenboss ---
private isMazeWave(wave: number): boolean {
return Game.MAZE_WAVES.includes(wave);
}
private startMazeWave() {
this.disposeMaze();
this.maze = new Maze(this.scene);
this.mazeActive = true;
this.mazeLevel = Math.max(0, Math.floor((this.spawns - 5) / 10));
// Spieler zur Startecke
const start = this.maze.startPosition();
this.player.body.position.set(start.x, 0.5, start.z);
this.player.velocity.set(0, 0, 0);
// Ab Level 2 (Welle 25) muss der Gnom 2x gefunden werden
this.mazeGoalsLeft = this.mazeLevel >= 2 ? 2 : 1;
this.mazeTimer = Math.max(
Game.MAZE_MIN_TIME,
Game.MAZE_BASE_TIME - Game.MAZE_TIME_STEP * this.mazeLevel
);
this.mazeTotal = this.mazeTimer;
this.spawnMazeGnome();
this.spawnMazeGuards();
this.updateMazeArrow();
playSound('spawn', 0.8);
}
private spawnMazeGnome() {
if (!this.maze) return;
const gnome = new Gnome(150 + 100 * this.mazeLevel, 60 + 30 * this.mazeLevel);
const goal = this.maze.goalPosition(this.mazeGoalsLeft - 1);
this.mazeGnome = gnome;
this.spawnEnemyInstance(gnome, goal.x, goal.z, 0.1);
}
private spawnMazeGuards() {
if (!this.maze) return;
const count = this.mazeLevel >= 2 ? 4 : this.mazeLevel >= 1 ? 2 : 0;
if (count <= 0) return;
const spots = this.maze.guardPositions(count);
for (const pos of spots) {
const guard = new MazeGuard();
this.mazeGuards.push(guard);
this.spawnEnemyInstance(guard, pos.x, pos.z, 1.0);
}
}
private updateMaze(dt: number) {
if (!this.mazeActive || !this.maze) return;
this.mazeTimer -= dt;
this.updateMazeArrow();
if (this.mazeTimer <= 0) {
// Fehlschlag: neues Labyrinth, zurück zur Startecke
playSound('explosion', 0.5);
this.spawnDamageText('ZEIT ABGELAUFEN!', this.player.body.position, '#ff6644');
this.triggerCameraShake(0.3, 0.3);
this.startMazeWave();
return;
}
// Gnom besiegt?
if (this.mazeGnome && this.mazeGnome.dead) {
this.mazeGoalsLeft--;
this.mazeGnome = null;
if (this.mazeGoalsLeft > 0) {
this.spawnMazeGnome();
} else {
this.endMazeWave(true);
}
}
}
private endMazeWave(success: boolean) {
this.mazeActive = false;
this.disposeMaze();
if (success) {
playSound('explosion', 0.7);
this.triggerCameraShake(0.3, 0.3);
}
this.spawnTimer = 0;
}
private disposeMaze() {
if (this.maze) {
this.maze.dispose();
this.maze = null;
}
this.mazeActive = false;
// Lebenden Gnom (Fehlschlag/Neustart) entfernen; tote bleiben als Leichen
if (this.mazeGnome && !this.mazeGnome.dead) {
this.mazeGnome.dispose();
this.scene.remove(this.mazeGnome.body);
const idx = this.enemies.indexOf(this.mazeGnome);
if (idx >= 0) this.enemies.splice(idx, 1);
}
this.mazeGnome = null;
for (const guard of this.mazeGuards) {
if (!guard.dead) {
guard.dispose();
this.scene.remove(guard.body);
const idx = this.enemies.indexOf(guard);
if (idx >= 0) this.enemies.splice(idx, 1);
}
}
this.mazeGuards = [];
this.hideMazeArrow();
}
// Pfeil am Bildschirmrand zeigt zum aktuellen Ziel-Gnom
private updateMazeArrow() {
const el = this.uiMazeArrow;
if (!this.mazeActive || !this.mazeGnome || !this.maze) {
this.hideMazeArrow();
return;
}
const goal = this.mazeGnome.body.position;
const v = goal.clone().project(this.camera);
const onScreen = v.x > -1 && v.x < 1 && v.y > -1 && v.y < 1 && v.z < 1;
if (onScreen) {
this.hideMazeArrow();
return;
}
const clx = THREE.MathUtils.clamp(v.x, -0.8, 0.8);
const cly = THREE.MathUtils.clamp(v.y, -0.8, 0.8);
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
// NDC-y zeigt nach oben, Bildschirm-y nach unten -> y flippen
const ax = clx * cx;
const ay = -cly * cy;
const gx = v.x * cx;
const gy = -v.y * cy;
el.style.left = `${cx + ax}px`;
el.style.top = `${cy + ay}px`;
el.style.transform = `translate(-50%, -50%) rotate(${Math.atan2(gy - ay, gx - ax)}rad)`;
el.style.opacity = '1';
el.style.visibility = 'visible';
el.style.pointerEvents = 'none';
}
private hideMazeArrow() {
const el = this.uiMazeArrow;
el.style.opacity = '0';
el.style.visibility = 'hidden';
el.style.pointerEvents = 'none';
}
private blurFocus() {
const el = document.activeElement;
if (el instanceof HTMLElement) el.blur();
@@ -779,6 +977,18 @@ export class Game {
this.killed = 0;
this.bossFightActive = false;
this.offscreenTimers.clear();
// Arena-Theme zurücksetzen
this.arenaStage = 0;
this.themeFogTarget.setHex(0x0d1117);
this.themeLightTarget.setHex(0xffeebb);
this.arena.applyTheme(0);
(this.scene.fog as THREE.Fog).color.copy(this.themeFogTarget);
(this.scene.background as THREE.Color).copy(this.themeFogTarget);
this.playerLight.color.copy(this.themeLightTarget);
// Labyrinth aufräumen
this.disposeMaze();
this.shakeTime = 0;
this.pendingPicks = 0;
this.hideLevelUp();
@@ -900,10 +1110,11 @@ export class Game {
this.syncSkillVisibility();
this.blurFocus();
if (this.pendingPicks > 0) {
this.pendingPicks--;
this.openLevelUp();
} else if (this.skillSystem.hasPendingLevelUp()) {
// Genau 1 Skill-Punkt pro Level: Pick verbrauchen und nur weiter aufmachen,
// wenn noch Picks übrig sind (Multi-Level durch XP-Überschuss) oder
// Rest-XP bereits die nächste Stufe erreicht hat.
this.pendingPicks = Math.max(0, this.pendingPicks - 1);
if (this.pendingPicks > 0 || this.skillSystem.hasPendingLevelUp()) {
this.openLevelUp();
} else {
this.hideLevelUp();
@@ -987,24 +1198,41 @@ export class Game {
private enemyWeights(wave: number): Map<string, number> {
const w = new Map<string, number>();
if (wave >= 7) {
w.set('spider', 20).set('ghost', 12).set('frog', 12).set('slime', 8)
.set('bat', 8).set('turret', 8).set('crab', 8).set('bee', 5)
.set('archer', 6).set('bomber', 6).set('shaman', 4).set('wisp', 4)
.set('turtle', 4).set('spore', 5);
} else if (wave >= 6) {
w.set('spider', 28).set('ghost', 16).set('frog', 12).set('slime', 8)
.set('bat', 8).set('turret', 8)
.set('archer', 7).set('bomber', 7).set('wisp', 4).set('spore', 5);
} else if (wave >= 5) {
w.set('spider', 35).set('ghost', 16).set('frog', 12).set('slime', 8)
.set('bat', 8)
.set('archer', 7).set('bomber', 7).set('spore', 5);
} else if (wave >= 4) {
w.set('spider', 45).set('ghost', 20).set('frog', 16)
.set('archer', 6).set('bomber', 6);
// Neue Gegner erscheinen NUR nach den Boss-Wellen (10/20/30/40/50/60/70).
// Vor dem ersten Boss (1-10) gibt es nur Spinnen und Frösche.
if (wave >= 71) {
w.set('spider', 15).set('frog', 10).set('ghost', 10).set('slime', 8)
.set('bat', 7).set('archer', 6).set('crab', 7).set('turret', 7)
.set('bomber', 6).set('bee', 5).set('brute', 4).set('shaman', 5)
.set('spore', 5).set('wisp', 4).set('turtle', 4);
} else if (wave >= 61) {
w.set('spider', 16).set('frog', 11).set('ghost', 11).set('slime', 8)
.set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8)
.set('bomber', 7).set('bee', 5).set('brute', 4).set('shaman', 5)
.set('spore', 5).set('wisp', 5);
} else if (wave >= 51) {
w.set('spider', 17).set('frog', 12).set('ghost', 12).set('slime', 8)
.set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8)
.set('bomber', 7).set('bee', 5).set('brute', 4).set('shaman', 5)
.set('spore', 5);
} else if (wave >= 41) {
w.set('spider', 18).set('frog', 13).set('ghost', 13).set('slime', 8)
.set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8)
.set('bomber', 8).set('bee', 5).set('brute', 5).set('shaman', 5)
.set('spore', 5);
} else if (wave >= 31) {
w.set('spider', 20).set('frog', 14).set('ghost', 14).set('slime', 8)
.set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8)
.set('bomber', 8).set('bee', 5).set('brute', 5);
} else if (wave >= 21) {
w.set('spider', 22).set('frog', 15).set('ghost', 15).set('slime', 8)
.set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8)
.set('bomber', 8);
} else if (wave >= 11) {
w.set('spider', 28).set('frog', 18).set('ghost', 18)
.set('bat', 9);
} else if (wave >= 3) {
w.set('spider', 75).set('ghost', 25);
w.set('spider', 60).set('frog', 40);
} else {
w.set('spider', 100);
}
@@ -1036,6 +1264,16 @@ export class Game {
}
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
// Gegner werden erst NACH den Boss-Wellen stärker: pro besiegten Boss
// (jede 10. Welle) +20% HP, davor Basis-HP. Bosse bleiben unskaliert.
if (!enemy.isBoss) {
const bossGates = Math.floor(Math.max(0, this.spawns - 1) / 10);
if (bossGates > 0) {
const scale = 1 + 0.2 * bossGates;
enemy.health = Math.round(enemy.health * scale);
enemy.maxHealth = enemy.health;
}
}
enemy.body.position.set(x, y, z);
this.scene.add(enemy.body);
this.enemies.push(enemy);
@@ -1150,6 +1388,11 @@ export class Game {
p.body.position.z += p.velocity.z * dt;
this.clampToArena(p.body.position);
// Labyrinth-Wände blockieren den Spieler
if (this.mazeActive && this.maze) {
this.maze.collidePlayer(p.body.position, 0.7);
}
// Look at ground point
p.body.lookAt(
this.groundPoint.x,
@@ -1166,6 +1409,7 @@ export class Game {
// Stationäre Gegner (Turret, Sporen-Pilz, Krake, Spinnenkönigin), die zu
// lange außerhalb des Bildschirms sind, werden wieder in Sicht teleportiert.
private updateOffscreenReposition(dt: number) {
if (this.mazeActive) return;
for (const enemy of this.enemies) {
if (enemy.dead) {
this.offscreenTimers.delete(enemy);
@@ -1328,7 +1572,10 @@ export class Game {
this.uiGasCD.style.width = `${gasPct * 100}%`;
const wavePct = Math.min(1, this.spawnTimer / SPAWN_TIME);
if (this.bossFightActive) {
if (this.mazeActive) {
this.uiWaveBarFill.style.width = `${Math.min(1, this.mazeTimer / this.mazeTotal) * 100}%`;
this.uiWaveBarLabel.textContent = `Labyrinth: ${Math.ceil(this.mazeTimer)}s`;
} else if (this.bossFightActive) {
this.uiWaveBarFill.style.width = '100%';
this.uiWaveBarLabel.textContent = 'BOSS!';
} else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) {
@@ -1346,6 +1593,8 @@ export class Game {
this.uiLowHpVignette.classList.toggle(
'active', healthPct < 0.3 && healthPct > 0
);
this.updateMazeArrow();
}
triggerInkVignette() {
@@ -1454,6 +1703,13 @@ export class Game {
this.updateFireballs(dt);
this.updateHitzones();
// Arena-Theme weich einfärben (Boden, Wände, Nebel, Licht)
this.arena.update(dt);
const themeT = Math.min(1, dt * 1.5);
(this.scene.fog as THREE.Fog).color.lerp(this.themeFogTarget, themeT);
(this.scene.background as THREE.Color).lerp(this.themeFogTarget, themeT);
this.playerLight.color.lerp(this.themeLightTarget, themeT);
// Update slash effects
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
if (!this.slashEffects[i].update(dt)) {
@@ -1482,13 +1738,25 @@ export class Game {
// Spawn enemies in waves
if (this.state === 'playing') {
const arenaCleared = this.enemies.every(e => e.dead);
if (this.bossFightActive) {
if (this.mazeActive) {
// Labyrinth-Zwischenboss: ersetzt die Welle komplett
this.updateMaze(dt);
} else {
const arenaCleared = this.enemies.every(e => e.dead);
if (this.bossFightActive) {
// Boss besiegt -> naechste Welle startet
if (arenaCleared) {
this.bossFightActive = false;
this.spawns++;
this.spawnTimer = 0;
// Arena verändert sich nach jedem besiegten Boss
if (this.arenaStage < 7) {
this.arenaStage++;
this.themeFogTarget.copy(this.arena.applyTheme(this.arenaStage));
this.themeLightTarget.setHex(Game.THEME_LIGHTS[this.arenaStage]);
this.triggerCameraShake(0.4, 0.4);
playSound('explosion', 0.6);
}
}
} else if (this.spawnTimer >= SPAWN_TIME) {
if (this.isBossWave(this.spawns + 1)) {
@@ -1499,6 +1767,11 @@ export class Game {
this.bossFightActive = true;
this.spawnTimer = 0;
}
} else if (this.isMazeWave(this.spawns + 1)) {
// Labyrinth-Zwischenboss startet (ersetzt die Welle)
this.spawns++;
this.startMazeWave();
this.spawnTimer = 0;
} else {
this.spawns++;
for (let i = 0; i < this.spawns; i++) {
@@ -1510,6 +1783,7 @@ export class Game {
this.spawnTimer += dt;
}
}
}
// Check close encounters (enemy touching player)
if (this.state === 'playing') {