+
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 {