From 1f311d3208ac91e77c9198f2d6dab8aafeb9fa36 Mon Sep 17 00:00:00 2001 From: nico Date: Thu, 20 Aug 2026 15:19:53 +0200 Subject: [PATCH] Add maze mini-boss and rebalance progression --- AGENTS.md | 4 + web/index.html | 15 ++ web/src/Arena.ts | 62 +++++++ web/src/ChainLightning.ts | 6 +- web/src/Game.ts | 330 ++++++++++++++++++++++++++++++++++---- web/src/Ghost.ts | 5 + web/src/Gnome.ts | 142 ++++++++++++++++ web/src/Maze.ts | 240 +++++++++++++++++++++++++++ web/src/MazeGuard.ts | 22 +++ web/src/Skills.ts | 21 +-- 10 files changed, 807 insertions(+), 40 deletions(-) create mode 100644 web/src/Gnome.ts create mode 100644 web/src/Maze.ts create mode 100644 web/src/MazeGuard.ts diff --git a/AGENTS.md b/AGENTS.md index e304d06..348a833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ npm run dev # Dev-Server mit HMR | `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` | | `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` | | `Golem.ts` / `Krake.ts` / `Minotaurus.ts` / `SpiderQueen.ts` / `FrostGiant.ts` / `Necromancer.ts` / `Dragon.ts` | Bosse (Welle 10 / 20 / 30 / 40 / 50 / 60 / 70), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Spinnenkönigin: stationär, Netze (`WebPatch.ts`: fesselt via `player.rootTime`), Spinngeschwister (Mini-Spiders), Säuresalven. Frostriese: Eispanzer (70% Schadensreduktion, `armorHp`, bricht + Stagger, regeneriert nach Timer), Eisberst-Ring (nacheinander explodierende Marken), Eiszapfen-Regen, Stampf (überspringbar). Erz-Nekromant: schwebend, Teleport mit Glow-Ring-Telegraf + Ankunftssalve, Beschwörungen mit wachsendem Boden-Glow. Drache: fliegt und ist nur im Lande-Zustand verletzbar (`takeDamage`-Override blockt im Flug), Sturzangriff mit wachsendem Schatten-Telegraf, Feuerspeer (Telegraf-Bogen, überspringbar), Feuerbälle im Flug, Enrage mit Doppel-Sturz. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) | +| `Maze.ts` / `MazeGuard.ts` / `Gnome.ts` | Labyrinth-Zwischenboss (Welle 5/15/25/…, `Game.MAZE_WAVES`): randomisierter Prim-Generator (11×11, `collidePlayer`-Kollision, `hasLineOfSight`), Wächter-Geister aktivieren sich erst bei Spielernähe (`MazeGuard.chase`), Gnom als Ziel (`Gnome.ts`, `isBoss`, nur mit Sichtlinie verletzbar, ab Level 2 doppeltes Ziel). Spieler-Kollision nur während `mazeActive`; `#maze-arrow`-Overlay zeigt den Weg; Boss-Modus-Buttons „Labyrinth (Welle X)" | | `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf | | `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret, Shaman, Archer, Bomber, Wisp, Turtle, SporeMushroom) | Prozedurale Gegner-Modelle; je `update(dt, game)`. Schamane heilt Gegner im Radius (Prioritätsziel), Bogenschütze hält Distanz + Pfeil mit Boden-Telegraf, Bomber explodiert bei Nähe (AoE), Irrlicht schießt Homing-Sporen (Slow), Schildkröte blockt vorn komplett + Panzer-Rückzug (unverwundbar), Sporen-Pilz wirft Bogen-Sporen mit DoT-Wolke | | `SoundManager.ts` | Vorab geladene .ogg-Buffer (`playSound(name, volume)`); Sounds: damage, spawn, fireball, explosion, teleport, gameover, sword_hit1, spiderwalking | @@ -50,6 +51,9 @@ npm run dev # Dev-Server mit HMR - **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen"). - **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen. - **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf). +- **Balance**: XP-Kurve `30 + 15·Level` (steiler, Skills maxen langsamer); Level-Ups geben genau **1** Skill-Punkt (Multi-Level nur bei XP-Überschuss, `pendingPicks`); Gegner-HP skaliert erst NACH den Bossen (jede 10. Welle = 1 Boss-Gate → +20% HP, nur Nicht-Bosse, in `spawnEnemyInstance`); neue Gegner erscheinen in `enemyWeights` nur nach Boss-Wellen (vor Golem nur Spinnen/Frösche, dann nach Golem/Krake/Minotaurus/Spinnenkönigin/Frostriese/Nekromant/Drache); Boss-Modus-Grants `wave·0.4` Skills. Arena-Theme: `Arena.applyTheme(stage)` (8 Stufen, Boden/Wände/Nebel/Licht) wird in `Game` nach jedem Boss-Sieg (`arenaStage++`) getriggert, weiches Lerpen in `arena.update(dt)`. +- **Gegner-Spawn im Sichtbereich**: `spawnEnemy`/`spawnBossForWave` spawnen im Ring 10–24 m um den Spieler; stationäre Gegner (`immovable`), die >8 s außerhalb des Bildschirms sind (NDC-Projektion `isOnScreen`), werden wieder in Sicht teleportiert (`updateOffscreenReposition`). +- **Labyrinth-Zwischenboss**: `Game.MAZE_WAVES` (5, 15, 25, …) ersetzt die Welle komplett (`startMazeWave`/`updateMaze`/`endMazeWave`); Zeitlimit (75 s − 5 s/Level, Min 50 s) → bei Ablauf wird das Labyrinth neu generiert und der Spieler zur Startecke gesetzt; Gnome nur mit Sichtlinie verletzbar (`hasLineOfSight`); `mazeActive` pausiert Spawn-Logik und Off-Screen-Reposition; Aufräumen über `disposeMaze()` in `resetWorld`; `#maze-arrow`-Overlay zeigt zum Ziel. - **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung), `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf) und `rootTime` (Netz-Fessel: keine Bewegung, Angriffe bleiben möglich). Alles wird in `Player.reset()` zurückgesetzt. - **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig). - **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag. diff --git a/web/index.html b/web/index.html index adbc76f..31047de 100644 --- a/web/index.html +++ b/web/index.html @@ -120,6 +120,15 @@ text-align: center; line-height: 16px; color: #fff; font-size: 11px; font-family: monospace; } + #maze-arrow { + position: absolute; + width: 40px; height: 40px; + color: #ffcc44; font-size: 30px; line-height: 40px; text-align: center; + text-shadow: 0 0 8px #ff8800, 0 0 2px #000; + font-family: monospace; + pointer-events: none; user-select: none; + opacity: 0; visibility: hidden; + } #game-over { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 100px; color: #fff; font-family: monospace; opacity: 0; @@ -286,6 +295,7 @@
+
BOSS
@@ -400,6 +410,11 @@

Changelog

+
20.08.Labyrinth-Zwischenboss (Welle 5, 15, 25, …): generiertes Labyrinth, Gnom als Ziel, Zeitlimit, Wächter-Geister ab Level 2, Gnom 2x finden ab Welle 25, Pfeil zeigt den Weg, Boss-Modus-Buttons
+
20.08.Level-Up-Bug behoben: genau 1 Skill-Punkt pro Level (vorher 2); Gegner werden erst nach den Bossen stärker, neue Monster erscheinen erst nach jedem Boss
+
20.08.Balance: steilere XP-Kurve, weniger Boss-Modus-Skills, Feuerball abgeschwächt (Kettenblitz/Falle/Gaswolke gepuffert), Gegner-HP skaliert mit der Welle
+
20.08.Arena verändert sich nach jedem Boss-Sieg (Boden, Wände, Nebel und Licht färben sich um)
+
20.08.Neue Gegner werden nach den Bossen nach und nach freigeschaltet statt ab Welle 4-7
20.08.Sechs neue Gegner: Schamane (heilt), Bogenschütze (Telegraf-Pfeile), Bomber (Explosion), Irrlicht (Slow-Sporen), Schildkröte (Frontpanzer + Rückzug) und Sporen-Pilz (Bogen-Sporen mit DoT-Wolke)
20.08.Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer
20.08.Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen
diff --git a/web/src/Arena.ts b/web/src/Arena.ts index ecac49f..eee708e 100644 --- a/web/src/Arena.ts +++ b/web/src/Arena.ts @@ -1,6 +1,35 @@ import * as THREE from 'three'; +interface Theme { + floorLight: number; + floorDark: number; + wall: number; + fog: number; +} + +// 8 Stufen: Start + eine pro besiegten Boss (Golem, Krake, Minotaurus, +// Spinnenkönigin, Frostriese, Erz-Nekromant, Drache) +const THEMES: Theme[] = [ + { floorLight: 0x668866, floorDark: 0x557755, wall: 0x776677, fog: 0x0d1117 }, // Start (Grün) + { floorLight: 0x8a7a55, floorDark: 0x7a6a48, wall: 0x776055, fog: 0x141210 }, // Golem (Erdig) + { floorLight: 0x3a4a66, floorDark: 0x2f3d55, wall: 0x44506a, fog: 0x0a1220 }, // Krake (Tinte) + { floorLight: 0x8a4a3a, floorDark: 0x6e3a2c, wall: 0x7a4438, fog: 0x180d0b }, // Minotaurus (Asche) + { floorLight: 0x4a7a4a, floorDark: 0x3a643a, wall: 0x4a6a4a, fog: 0x0a160d }, // Spinnenkönigin (Gift) + { floorLight: 0x88bbcc, floorDark: 0x6a9ab0, wall: 0x7aa0b5, fog: 0x0d1a24 }, // Frostriese (Eis) + { floorLight: 0x6a4a8a, floorDark: 0x543a70, wall: 0x5a3f74, fog: 0x140a20 }, // Erz-Nekromant (Lila) + { floorLight: 0xaa6a33, floorDark: 0x8a5428, wall: 0x8f5c34, fog: 0x1e0f06 }, // Drache (Glut) +]; + +const LERP_SPEED = 2; + export class Arena extends THREE.Group { + private lightMats: THREE.MeshStandardMaterial[] = []; + private darkMats: THREE.MeshStandardMaterial[] = []; + private wallMats: THREE.MeshToonMaterial[] = []; + private targets: + | { floorLight: THREE.Color; floorDark: THREE.Color; wall: THREE.Color } + | null = null; + constructor() { super(); @@ -23,6 +52,7 @@ export class Arena extends THREE.Group { tile.position.set(x, -0.15, z); tile.receiveShadow = true; this.add(tile); + (isDark ? this.darkMats : this.lightMats).push(tileMat); } } @@ -38,6 +68,12 @@ export class Arena extends THREE.Group { this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat); this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat); } + for (const wall of this.children) { + const mesh = wall as THREE.Mesh; + if (mesh.material instanceof THREE.MeshToonMaterial) { + this.wallMats.push(mesh.material); + } + } } private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) { @@ -47,4 +83,30 @@ export class Arena extends THREE.Group { wall.receiveShadow = true; this.add(wall); } + + // Setzt die Ziel-Farben für eine Theme-Stufe und gibt die Nebel-Farbe zurück + applyTheme(stage: number): THREE.Color { + const t = THEMES[Math.min(Math.max(stage, 0), THEMES.length - 1)]; + this.targets = { + floorLight: new THREE.Color(t.floorLight), + floorDark: new THREE.Color(t.floorDark), + wall: new THREE.Color(t.wall), + }; + return new THREE.Color(t.fog); + } + + // Lerpt die aktuellen Farben weich in Richtung Ziel-Theme + update(dt: number) { + if (!this.targets) return; + const t = Math.min(1, dt * LERP_SPEED); + for (const mat of this.lightMats) { + mat.color.lerp(this.targets.floorLight, t); + } + for (const mat of this.darkMats) { + mat.color.lerp(this.targets.floorDark, t); + } + for (const mat of this.wallMats) { + mat.color.lerp(this.targets.wall, t); + } + } } diff --git a/web/src/ChainLightning.ts b/web/src/ChainLightning.ts index 588dc27..1a6c2a3 100644 --- a/web/src/ChainLightning.ts +++ b/web/src/ChainLightning.ts @@ -1,10 +1,9 @@ import * as THREE from 'three'; import { Weapon } from './Weapon'; import { Player } from './Player'; -import { lightningDamage, lightningJumps, lightningCooldown } from './Skills'; +import { lightningDamage, lightningJumps, lightningJumpRange, lightningCooldown } from './Skills'; const CHAIN_FIRST_RANGE = 14; -const CHAIN_JUMP_RANGE = 8; const FLASH_TIME = 0.35; export class ChainLightning extends Weapon { @@ -25,6 +24,7 @@ export class ChainLightning extends Weapon { const game = player.game; const damage = lightningDamage(level); const maxJumps = lightningJumps(level); + const jumpRange = lightningJumpRange(level); // Chain through enemies, always the closest un-hit one const start = player.body.position.clone(); @@ -35,7 +35,7 @@ export class ChainLightning extends Weapon { for (let i = 0; i < maxJumps; i++) { let best: import('./Enemy').Enemy | null = null; - let bestDist = i === 0 ? CHAIN_FIRST_RANGE : CHAIN_JUMP_RANGE; + let bestDist = i === 0 ? CHAIN_FIRST_RANGE : jumpRange; for (const enemy of game.enemies) { if (enemy.dead || hit.has(enemy)) continue; const dist = enemy.body.position.distanceTo(from); diff --git a/web/src/Game.ts b/web/src/Game.ts index 80ac078..b4d5405 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -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 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 { const w = new Map(); - 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') { diff --git a/web/src/Ghost.ts b/web/src/Ghost.ts index 2e2f2d2..f50d032 100644 --- a/web/src/Ghost.ts +++ b/web/src/Ghost.ts @@ -198,6 +198,11 @@ export class Ghost extends Enemy { } // Chase the player (stays hittable and dangerous even while invisible) + this.chase(dt, game); + } + + // Getrennt für Subklassen (z. B. MazeGuard: erst aktiv, wenn der Spieler nah ist) + protected chase(dt: number, game: Game) { const toPlayer = new THREE.Vector3() .subVectors(game.player.body.position, this.body.position); toPlayer.y = 0; diff --git a/web/src/Gnome.ts b/web/src/Gnome.ts new file mode 100644 index 0000000..e0b5f4a --- /dev/null +++ b/web/src/Gnome.ts @@ -0,0 +1,142 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { playSound } from './SoundManager'; + +const HIT_FLASH_TIME = 0.14; + +// Gnom: das Ziel im Labyrinth. Stationär, nur mit Sichtlinie (durch die +// Wände hindurch) verletzbar -> man muss wirklich zu ihm hingehen. +export class Gnome extends Enemy { + private model: THREE.Group; + private skinMat!: THREE.MeshToonMaterial; + private hatMat!: THREE.MeshToonMaterial; + private beardMat!: THREE.MeshToonMaterial; + private time = Math.random() * Math.PI * 2; + private hitFlash = 0; + + constructor(hp: number, xp: number) { + super(); + this.health = hp; + this.maxHealth = hp; + this.isBoss = true; + this.displayName = 'Gnom'; + this.speed = 0; + this.xp = xp; + this.contactDps = 0; + this.contactRadius = 0; + this.guaranteedDrop = true; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + this.skinMat = new THREE.MeshToonMaterial({ color: 0xe8c8a0 }); + this.hatMat = new THREE.MeshToonMaterial({ color: 0x44aa44 }); + this.beardMat = new THREE.MeshToonMaterial({ color: 0xf8f0e0 }); + + // Körper + const body = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), this.skinMat); + body.scale.set(1, 1.15, 1); + body.position.y = 0.5; + body.castShadow = true; + this.registerFadeMesh(body); + this.model.add(body); + + // Mantel + const coat = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.45, 8, 1, true), this.hatMat); + coat.position.y = 0.28; + coat.rotation.x = Math.PI; + this.registerFadeMesh(coat); + this.model.add(coat); + + // Kopf + const head = new THREE.Mesh(new THREE.SphereGeometry(0.24, 12, 10), this.skinMat); + head.position.y = 0.95; + head.castShadow = true; + this.registerFadeMesh(head); + this.model.add(head); + + // Spitzer Hut + const hat = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.55, 8), this.hatMat); + hat.position.set(0, 1.3, 0.05); + hat.rotation.x = 0.12; + this.registerFadeMesh(hat); + this.model.add(hat); + + // Bart (+Z) + const beard = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), this.beardMat); + beard.scale.set(1, 1.2, 0.7); + beard.position.set(0, 0.72, 0.2); + this.registerFadeMesh(beard); + this.model.add(beard); + + // Augen (+Z) + const eyeMat = new THREE.MeshBasicMaterial({ color: 0x223322 }); + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.04, 6, 5), eyeMat); + eye.position.set(side * 0.09, 1.0, 0.22); + this.model.add(eye); + } + } + + playAnim() { + // Prozedurale Optik + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 1.0, shrink: 0.8 }); + } + + update(dt: number, game: Game) { + this.time += dt; + + // Hit-Feedback + if (this.hitFlash > 0) { + this.hitFlash -= dt; + this.skinMat.color.set(0xffffff); + this.hatMat.color.set(0xffffff); + } else { + this.skinMat.color.set(0xe8c8a0); + this.hatMat.color.set(0x44aa44); + } + + // Wippen + ab und zu aufhüpfen + this.body.position.y = 0.1 + Math.abs(Math.sin(this.time * 2.2)) * 0.08; + this.model.rotation.y = Math.sin(this.time * 1.5) * 0.15; + + // Zum Spieler drehen + const toPlayer = new THREE.Vector3() + .subVectors(game.player.body.position, this.body.position); + toPlayer.y = 0; + if (toPlayer.length() > 0.1) { + this.body.lookAt( + this.body.position.x + toPlayer.x, + this.body.position.y, + this.body.position.z + toPlayer.z + ); + } + } + + override takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + if (this.dead) return; + // Nur mit Sichtlinie verletzbar (kein Feuerball durch die Wand) + const src = sourcePos ?? game.player.body.position; + if (game.maze && !game.maze.hasLineOfSight(src, this.body.position)) { + game.spawnDamageText('WAND!', this.body.position, '#8899aa'); + return; + } + super.takeDamage(damage, game, withKnockback, sourcePos); + this.hitFlash = HIT_FLASH_TIME; + playSound('damage', 0.6); + } + + protected onDeath(_game: Game) { + this.beginFadeDeath(); + } +} diff --git a/web/src/Maze.ts b/web/src/Maze.ts new file mode 100644 index 0000000..bd5bc76 --- /dev/null +++ b/web/src/Maze.ts @@ -0,0 +1,240 @@ +import * as THREE from 'three'; + +const GRID = 11; +const CELL = 92 / GRID; +const HALF = 46; +const WALL_THICK = 1; +const WALL_HEIGHT = 2.5; + +interface WallBox { + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +// Labyrinth-Zwischenboss: randomisierter Prim auf einem 11x11-Grid. +// Erzeugt ein "perfektes" Labyrinth (jede Zelle erreichbar, genau ein Pfad +// zwischen zwei beliebigen Zellen) mit kurzen, verzweigten Lösungswegen. +export class Maze { + private scene: THREE.Scene; + private wallMeshes: THREE.Mesh[] = []; + private wallBoxes: WallBox[] = []; + private openH: boolean[][] = []; // Passage (i,j)-(i+1,j) + private openV: boolean[][] = []; // Passage (i,j)-(i,j+1) + + constructor(scene: THREE.Scene) { + this.scene = scene; + this.generate(); + this.buildWalls(); + } + + // Start-Ecke = Zelle (0,0), Ziel = Zelle (10,10) + startPosition(): THREE.Vector3 { + return this.cellCenter(0, 0); + } + + // goalIndex 0 = Ziel-Ecke (max,max), 1 = Neben-Ecke (min,max) für 2x-Finden + goalPosition(goalIndex: number): THREE.Vector3 { + if (goalIndex === 1) return this.cellCenter(0, GRID - 1); + return this.cellCenter(GRID - 1, GRID - 1); + } + + private cellCenter(i: number, j: number): THREE.Vector3 { + return new THREE.Vector3(-HALF + (i + 0.5) * CELL, 0, -HALF + (j + 0.5) * CELL); + } + + private generate() { + for (let i = 0; i < GRID; i++) { + this.openH.push(new Array(GRID - 1).fill(false)); + this.openV.push(new Array(GRID - 1).fill(false)); + } + + // Randomized Prim: startet bei Zelle (0,0), wählt zufällige Frontier-Kante + const visited: boolean[][] = Array.from({ length: GRID }, () => new Array(GRID).fill(false)); + const frontier: { i: number; j: number; dir: 'h' | 'v' }[] = []; + const addFrontier = (i: number, j: number) => { + if (i < GRID - 1 && !visited[i + 1][j]) frontier.push({ i, j, dir: 'h' }); + if (i > 0 && !visited[i - 1][j]) frontier.push({ i: i - 1, j, dir: 'h' }); + if (j < GRID - 1 && !visited[i][j + 1]) frontier.push({ i, j, dir: 'v' }); + if (j > 0 && !visited[i][j - 1]) frontier.push({ i, j: j - 1, dir: 'v' }); + }; + + visited[0][0] = true; + addFrontier(0, 0); + while (frontier.length > 0) { + const idx = Math.floor(Math.random() * frontier.length); + const { i, j, dir } = frontier[idx]; + frontier.splice(idx, 1); + const ci = dir === 'h' ? i + 1 : i; + const cj = dir === 'h' ? j : j + 1; + if (visited[ci][cj]) continue; + visited[ci][cj] = true; + if (dir === 'h') this.openH[i][j] = true; + else this.openV[i][j] = true; + addFrontier(ci, cj); + } + } + + private buildWalls() { + const wallMat = new THREE.MeshToonMaterial({ color: 0x776677 }); + + // Außenwände: Labyrinth bis zur Begrenzung abdichten, + // damit man nicht außen herumlaufen kann + const outer = HALF + WALL_THICK / 2; + this.addWall(-outer, -HALF + WALL_THICK / 2, -outer, outer, WALL_THICK, 2 * outer, -HALF, 0, 0, wallMat); + this.addWall(HALF - WALL_THICK / 2, outer, -outer, outer, WALL_THICK, 2 * outer, HALF, 0, 0, wallMat); + this.addWall(-outer, outer, -outer, -HALF + WALL_THICK / 2, 2 * outer, WALL_THICK, 0, 0, -HALF, wallMat); + this.addWall(-outer, outer, HALF - WALL_THICK / 2, outer, 2 * outer, WALL_THICK, 0, 0, HALF, wallMat); + + // Vertikale Gitterlinien (konstantes x): Wand blockiert Bewegung in x + for (let i = 1; i < GRID; i++) { + const bx = -HALF + i * CELL; + for (let j = 0; j < GRID; j++) { + if (this.openH[i - 1][j]) continue; + const zc = -HALF + (j + 0.5) * CELL; + this.addWall(bx - WALL_THICK / 2, bx + WALL_THICK / 2, zc - CELL / 2, zc + CELL / 2, WALL_THICK, CELL, bx, 0, zc, wallMat); + } + } + + // Horizontale Gitterlinien (konstantes z): Wand blockiert Bewegung in z + for (let j = 1; j < GRID; j++) { + const bz = -HALF + j * CELL; + for (let i = 0; i < GRID; i++) { + if (this.openV[i][j - 1]) continue; + const xc = -HALF + (i + 0.5) * CELL; + this.addWall(xc - CELL / 2, xc + CELL / 2, bz - WALL_THICK / 2, bz + WALL_THICK / 2, CELL, WALL_THICK, xc, 0, bz, wallMat); + } + } + } + + private addWall( + minX: number, maxX: number, minZ: number, maxZ: number, + sizeX: number, sizeZ: number, + px: number, py: number, pz: number, + mat: THREE.Material + ) { + const geom = new THREE.BoxGeometry(sizeX, WALL_HEIGHT, sizeZ); + const mesh = new THREE.Mesh(geom, mat); + mesh.position.set(px, py + WALL_HEIGHT / 2, pz); + mesh.castShadow = true; + mesh.receiveShadow = true; + this.scene.add(mesh); + this.wallMeshes.push(mesh); + this.wallBoxes.push({ minX, maxX, minZ, maxZ }); + } + + // Circle-vs-AABB-Kollision: Spieler aus Wänden schieben (3 Durchgänge) + collidePlayer(pos: THREE.Vector3, radius: number) { + for (let pass = 0; pass < 3; pass++) { + let moved = false; + for (const w of this.wallBoxes) { + const cx = Math.max(w.minX, Math.min(pos.x, w.maxX)); + const cz = Math.max(w.minZ, Math.min(pos.z, w.maxZ)); + const dx = pos.x - cx; + const dz = pos.z - cz; + const d2 = dx * dx + dz * dz; + if (d2 < radius * radius) { + if (d2 > 1e-6) { + const d = Math.sqrt(d2); + pos.x += (dx / d) * (radius - d); + pos.z += (dz / d) * (radius - d); + } else { + // Mittelpunkt in der Wand: kleinste Überlappung wählen + const ox = Math.min(pos.x - w.minX, w.maxX - pos.x); + const oz = Math.min(pos.z - w.minZ, w.maxZ - pos.z); + if (ox < oz) { + pos.x += (pos.x < (w.minX + w.maxX) / 2 ? -1 : 1) * (radius + ox); + } else { + pos.z += (pos.z < (w.minZ + w.maxZ) / 2 ? -1 : 1) * (radius + oz); + } + } + moved = true; + } + } + if (!moved) break; + } + } + + // Sichtlinie: Segment alle ~0.8 m abtasten, Wand dazwischen = kein Sichtkontakt + hasLineOfSight(a: THREE.Vector3, b: THREE.Vector3): boolean { + const dx = b.x - a.x; + const dz = b.z - a.z; + const dist = Math.hypot(dx, dz); + const steps = Math.max(1, Math.ceil(dist / 0.8)); + for (let s = 1; s < steps; s++) { + const t = s / steps; + const px = a.x + dx * t; + const pz = a.z + dz * t; + for (const w of this.wallBoxes) { + if (px > w.minX - 0.3 && px < w.maxX + 0.3 && + pz > w.minZ - 0.3 && pz < w.maxZ + 0.3) { + return false; + } + } + } + return true; + } + + // Wächter-Positionen: gleichmäßig verteilte Zellen entlang des Lösungswegs + guardPositions(count: number): THREE.Vector3[] { + if (count <= 0) return []; + + // BFS von (0,0) zu (10,10) + const prev: ([number, number] | null)[][] = Array.from( + { length: GRID }, + () => new Array<[number, number] | null>(GRID).fill(null) + ); + const queue: [number, number][] = [[0, 0]]; + prev[0][0] = [0, 0]; + while (queue.length > 0) { + const [i, j] = queue.shift()!; + if (i === GRID - 1 && j === GRID - 1) break; + const neigh: [number, number][] = []; + if (i < GRID - 1 && this.openH[i][j]) neigh.push([i + 1, j]); + if (i > 0 && this.openH[i - 1][j]) neigh.push([i - 1, j]); + if (j < GRID - 1 && this.openV[i][j]) neigh.push([i, j + 1]); + if (j > 0 && this.openV[i][j - 1]) neigh.push([i, j - 1]); + for (const [ni, nj] of neigh) { + if (prev[ni][nj]) continue; + prev[ni][nj] = [i, j]; + queue.push([ni, nj]); + } + } + + // Pfad rekonstruieren + const path: [number, number][] = []; + let cur: [number, number] | null = [GRID - 1, GRID - 1]; + while (cur) { + path.push(cur); + const p: [number, number] | null = prev[cur[0]][cur[1]]; + if (!p || (p[0] === cur[0] && p[1] === cur[1])) break; + cur = p; + } + path.reverse(); + if (path.length < 3) return []; + + // count Zellen gleichmäßig auf dem Weg verteilen (ohne Start/Ziel) + const res: THREE.Vector3[] = []; + const used = new Set(); + for (let k = 1; k <= count; k++) { + const idx = Math.min(path.length - 2, Math.max(1, Math.round((k * path.length) / (count + 1)))); + const [i, j] = path[idx]; + const key = `${i},${j}`; + if (used.has(key)) continue; + used.add(key); + res.push(this.cellCenter(i, j)); + } + return res; + } + + dispose() { + for (const mesh of this.wallMeshes) { + this.scene.remove(mesh); + mesh.geometry.dispose(); + (mesh.material as THREE.Material).dispose(); + } + this.wallMeshes = []; + this.wallBoxes = []; + } +} diff --git a/web/src/MazeGuard.ts b/web/src/MazeGuard.ts new file mode 100644 index 0000000..de33340 --- /dev/null +++ b/web/src/MazeGuard.ts @@ -0,0 +1,22 @@ +import type { Game } from './Game'; +import { Ghost } from './Ghost'; + +const ACTIVATE_RADIUS = 8; + +// Labyrinth-Wächter: steht still am Wegpunkt und greift erst an, +// wenn der Spieler in die Nähe kommt. +export class MazeGuard extends Ghost { + private activated = false; + + protected chase(dt: number, game: Game) { + if (!this.activated) { + const dx = game.player.body.position.x - this.body.position.x; + const dz = game.player.body.position.z - this.body.position.z; + if (Math.hypot(dx, dz) < ACTIVATE_RADIUS) { + this.activated = true; + } + return; + } + super.chase(dt, game); + } +} diff --git a/web/src/Skills.ts b/web/src/Skills.ts index 6fd5b44..a0c8737 100644 --- a/web/src/Skills.ts +++ b/web/src/Skills.ts @@ -22,7 +22,7 @@ export const SKILLS: SkillDef[] = [ type: 'active', maxLevel: 5, describe: (l) => l === 1 - ? 'Schießt einen Feuerball (100 DMG, 5s CD)' + ? 'Schießt einen Feuerball (85 DMG, 5.5s CD)' : `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`, }, { @@ -37,7 +37,7 @@ export const SKILLS: SkillDef[] = [ name: 'Kettenblitz', type: 'active', maxLevel: 5, - describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD)`, + describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD, ${lightningJumpRange(l)} m Sprungweite)`, }, { id: 'swordRange', @@ -132,23 +132,26 @@ export function getSkill(id: string): SkillDef { } export function fireballDamage(level: number): number { - return [100, 150, 200, 250, 300][Math.min(level, 5) - 1] ?? 100; + return [85, 125, 165, 205, 245][Math.min(level, 5) - 1] ?? 85; } export function fireballCooldown(level: number): number { - return [5, 4.5, 4, 3.5, 3][Math.min(level, 5) - 1] ?? 5; + return [5.5, 5, 4.5, 4, 3.5][Math.min(level, 5) - 1] ?? 5.5; } export function fireballRadius(level: number): number { - return [8, 8, 10, 10, 12][Math.min(level, 5) - 1] ?? 8; + return [7, 7, 9, 9, 11][Math.min(level, 5) - 1] ?? 7; } export function teleportCooldown(level: number): number { return [50, 35, 20][Math.min(level, 3) - 1] ?? 50; } export function lightningDamage(level: number): number { - return [40, 60, 80, 100, 120][Math.min(level, 5) - 1] ?? 40; + return [55, 75, 95, 115, 135][Math.min(level, 5) - 1] ?? 55; } export function lightningJumps(level: number): number { return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2; } +export function lightningJumpRange(level: number): number { + return [9, 10, 11, 12, 13][Math.min(level, 5) - 1] ?? 9; +} export function lightningCooldown(level: number): number { return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8; } @@ -174,7 +177,7 @@ export function boomerangCount(level: number): number { return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1; } export function trapDamage(level: number): number { - return [25, 35, 45, 55, 70][Math.min(level, 5) - 1] ?? 25; + return [35, 45, 55, 65, 80][Math.min(level, 5) - 1] ?? 35; } export function trapHold(level: number): number { return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5; @@ -186,7 +189,7 @@ export function trapCooldown(level: number): number { return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8; } export function gasDamage(level: number): number { - return [8, 12, 16, 20, 25][Math.min(level, 5) - 1] ?? 8; + return [12, 16, 20, 24, 30][Math.min(level, 5) - 1] ?? 12; } export function gasRadius(level: number): number { return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5; @@ -226,7 +229,7 @@ export class SkillSystem { } xpNeeded(): number { - return 20 + this.level * 10; + return 30 + this.level * 15; } xpProgress(): number {