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
+4
View File
@@ -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 1024 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.
+15
View File
@@ -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 @@
<div id="damage-vignette"></div>
<div id="low-hp-vignette"></div>
<div id="ink-vignette"></div>
<div id="maze-arrow">&#9650;</div>
<div id="boss-bar">
<div id="boss-bar-label">BOSS</div>
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
@@ -400,6 +410,11 @@
<div id="changelog-panel">
<h2>Changelog</h2>
<div id="changelog-list">
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>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</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>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</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Balance: steilere XP-Kurve, weniger Boss-Modus-Skills, Feuerball abgeschwächt (Kettenblitz/Falle/Gaswolke gepuffert), Gegner-HP skaliert mit der Welle</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Arena verändert sich nach jedem Boss-Sieg (Boden, Wände, Nebel und Licht färben sich um)</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Neue Gegner werden nach den Bossen nach und nach freigeschaltet statt ab Welle 4-7</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>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)</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen</span></div>
+62
View File
@@ -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);
}
}
}
+3 -3
View File
@@ -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);
+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') {
+5
View File
@@ -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;
+142
View File
@@ -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();
}
}
+240
View File
@@ -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<string>();
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 = [];
}
}
+22
View File
@@ -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);
}
}
+12 -9
View File
@@ -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 {