Add maze mini-boss and rebalance progression
This commit is contained in:
@@ -35,6 +35,7 @@ npm run dev # Dev-Server mit HMR
|
|||||||
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
|
| `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()` |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `*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 |
|
| `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").
|
- **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.
|
- **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).
|
- **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.
|
- **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).
|
- **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.
|
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
|
||||||
|
|||||||
@@ -120,6 +120,15 @@
|
|||||||
text-align: center; line-height: 16px; color: #fff; font-size: 11px;
|
text-align: center; line-height: 16px; color: #fff; font-size: 11px;
|
||||||
font-family: monospace;
|
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 {
|
#game-over {
|
||||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
|
font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
|
||||||
@@ -286,6 +295,7 @@
|
|||||||
<div id="damage-vignette"></div>
|
<div id="damage-vignette"></div>
|
||||||
<div id="low-hp-vignette"></div>
|
<div id="low-hp-vignette"></div>
|
||||||
<div id="ink-vignette"></div>
|
<div id="ink-vignette"></div>
|
||||||
|
<div id="maze-arrow">▲</div>
|
||||||
<div id="boss-bar">
|
<div id="boss-bar">
|
||||||
<div id="boss-bar-label">BOSS</div>
|
<div id="boss-bar-label">BOSS</div>
|
||||||
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
|
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
|
||||||
@@ -400,6 +410,11 @@
|
|||||||
<div id="changelog-panel">
|
<div id="changelog-panel">
|
||||||
<h2>Changelog</h2>
|
<h2>Changelog</h2>
|
||||||
<div id="changelog-list">
|
<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>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>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>
|
<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>
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
import * as THREE from 'three';
|
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 {
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -23,6 +52,7 @@ export class Arena extends THREE.Group {
|
|||||||
tile.position.set(x, -0.15, z);
|
tile.position.set(x, -0.15, z);
|
||||||
tile.receiveShadow = true;
|
tile.receiveShadow = true;
|
||||||
this.add(tile);
|
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);
|
||||||
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) {
|
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;
|
wall.receiveShadow = true;
|
||||||
this.add(wall);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { Weapon } from './Weapon';
|
import { Weapon } from './Weapon';
|
||||||
import { Player } from './Player';
|
import { Player } from './Player';
|
||||||
import { lightningDamage, lightningJumps, lightningCooldown } from './Skills';
|
import { lightningDamage, lightningJumps, lightningJumpRange, lightningCooldown } from './Skills';
|
||||||
|
|
||||||
const CHAIN_FIRST_RANGE = 14;
|
const CHAIN_FIRST_RANGE = 14;
|
||||||
const CHAIN_JUMP_RANGE = 8;
|
|
||||||
const FLASH_TIME = 0.35;
|
const FLASH_TIME = 0.35;
|
||||||
|
|
||||||
export class ChainLightning extends Weapon {
|
export class ChainLightning extends Weapon {
|
||||||
@@ -25,6 +24,7 @@ export class ChainLightning extends Weapon {
|
|||||||
const game = player.game;
|
const game = player.game;
|
||||||
const damage = lightningDamage(level);
|
const damage = lightningDamage(level);
|
||||||
const maxJumps = lightningJumps(level);
|
const maxJumps = lightningJumps(level);
|
||||||
|
const jumpRange = lightningJumpRange(level);
|
||||||
|
|
||||||
// Chain through enemies, always the closest un-hit one
|
// Chain through enemies, always the closest un-hit one
|
||||||
const start = player.body.position.clone();
|
const start = player.body.position.clone();
|
||||||
@@ -35,7 +35,7 @@ export class ChainLightning extends Weapon {
|
|||||||
|
|
||||||
for (let i = 0; i < maxJumps; i++) {
|
for (let i = 0; i < maxJumps; i++) {
|
||||||
let best: import('./Enemy').Enemy | null = null;
|
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) {
|
for (const enemy of game.enemies) {
|
||||||
if (enemy.dead || hit.has(enemy)) continue;
|
if (enemy.dead || hit.has(enemy)) continue;
|
||||||
const dist = enemy.body.position.distanceTo(from);
|
const dist = enemy.body.position.distanceTo(from);
|
||||||
|
|||||||
+302
-28
@@ -15,6 +15,9 @@ import { SpiderQueen } from './SpiderQueen';
|
|||||||
import { FrostGiant } from './FrostGiant';
|
import { FrostGiant } from './FrostGiant';
|
||||||
import { Necromancer } from './Necromancer';
|
import { Necromancer } from './Necromancer';
|
||||||
import { Dragon } from './Dragon';
|
import { Dragon } from './Dragon';
|
||||||
|
import { Maze } from './Maze';
|
||||||
|
import { MazeGuard } from './MazeGuard';
|
||||||
|
import { Gnome } from './Gnome';
|
||||||
import { Crab } from './Crab';
|
import { Crab } from './Crab';
|
||||||
import { Turret } from './Turret';
|
import { Turret } from './Turret';
|
||||||
import { Shaman } from './Shaman';
|
import { Shaman } from './Shaman';
|
||||||
@@ -95,6 +98,30 @@ export class Game {
|
|||||||
private static readonly SPAWN_VIEW_MIN = 10;
|
private static readonly SPAWN_VIEW_MIN = 10;
|
||||||
private static readonly SPAWN_VIEW_MAX = 24;
|
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)
|
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
|
||||||
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
||||||
[10, { name: 'Golem', spawn: () => new Golem() }],
|
[10, { name: 'Golem', spawn: () => new Golem() }],
|
||||||
@@ -240,6 +267,7 @@ export class Game {
|
|||||||
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
|
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
|
||||||
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
|
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
|
||||||
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
|
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
|
||||||
|
this.uiMazeArrow = document.getElementById('maze-arrow')!;
|
||||||
this.uiGameOver = document.getElementById('game-over')!;
|
this.uiGameOver = document.getElementById('game-over')!;
|
||||||
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
|
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
|
||||||
this.uiGameOverStats = document.getElementById('game-over-stats')!;
|
this.uiGameOverStats = document.getElementById('game-over-stats')!;
|
||||||
@@ -455,8 +483,8 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupArena() {
|
private setupArena() {
|
||||||
const arena = new Arena();
|
this.arena = new Arena();
|
||||||
this.scene.add(arena);
|
this.scene.add(this.arena);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupPlayer() {
|
private setupPlayer() {
|
||||||
@@ -598,6 +626,15 @@ export class Game {
|
|||||||
btn.addEventListener('click', () => this.startBossFight(wave));
|
btn.addEventListener('click', () => this.startBossFight(wave));
|
||||||
this.uiBossModeButtons.appendChild(btn);
|
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() {
|
private showBossMode() {
|
||||||
@@ -633,8 +670,18 @@ export class Game {
|
|||||||
this.beginRun();
|
this.beginRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
private grantRandomSkills(wave: number) {
|
startMazeFight(wave: number) {
|
||||||
const picks = Math.round(wave * 0.7);
|
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++) {
|
for (let i = 0; i < picks; i++) {
|
||||||
const offers = this.skillSystem.rollOffers();
|
const offers = this.skillSystem.rollOffers();
|
||||||
if (offers.length === 0) break;
|
if (offers.length === 0) break;
|
||||||
@@ -646,6 +693,157 @@ export class Game {
|
|||||||
this.syncSkillVisibility();
|
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() {
|
private blurFocus() {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
if (el instanceof HTMLElement) el.blur();
|
if (el instanceof HTMLElement) el.blur();
|
||||||
@@ -779,6 +977,18 @@ export class Game {
|
|||||||
this.killed = 0;
|
this.killed = 0;
|
||||||
this.bossFightActive = false;
|
this.bossFightActive = false;
|
||||||
this.offscreenTimers.clear();
|
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.shakeTime = 0;
|
||||||
this.pendingPicks = 0;
|
this.pendingPicks = 0;
|
||||||
this.hideLevelUp();
|
this.hideLevelUp();
|
||||||
@@ -900,10 +1110,11 @@ export class Game {
|
|||||||
this.syncSkillVisibility();
|
this.syncSkillVisibility();
|
||||||
this.blurFocus();
|
this.blurFocus();
|
||||||
|
|
||||||
if (this.pendingPicks > 0) {
|
// Genau 1 Skill-Punkt pro Level: Pick verbrauchen und nur weiter aufmachen,
|
||||||
this.pendingPicks--;
|
// wenn noch Picks übrig sind (Multi-Level durch XP-Überschuss) oder
|
||||||
this.openLevelUp();
|
// Rest-XP bereits die nächste Stufe erreicht hat.
|
||||||
} else if (this.skillSystem.hasPendingLevelUp()) {
|
this.pendingPicks = Math.max(0, this.pendingPicks - 1);
|
||||||
|
if (this.pendingPicks > 0 || this.skillSystem.hasPendingLevelUp()) {
|
||||||
this.openLevelUp();
|
this.openLevelUp();
|
||||||
} else {
|
} else {
|
||||||
this.hideLevelUp();
|
this.hideLevelUp();
|
||||||
@@ -987,24 +1198,41 @@ export class Game {
|
|||||||
|
|
||||||
private enemyWeights(wave: number): Map<string, number> {
|
private enemyWeights(wave: number): Map<string, number> {
|
||||||
const w = new Map<string, number>();
|
const w = new Map<string, number>();
|
||||||
if (wave >= 7) {
|
// Neue Gegner erscheinen NUR nach den Boss-Wellen (10/20/30/40/50/60/70).
|
||||||
w.set('spider', 20).set('ghost', 12).set('frog', 12).set('slime', 8)
|
// Vor dem ersten Boss (1-10) gibt es nur Spinnen und Frösche.
|
||||||
.set('bat', 8).set('turret', 8).set('crab', 8).set('bee', 5)
|
if (wave >= 71) {
|
||||||
.set('archer', 6).set('bomber', 6).set('shaman', 4).set('wisp', 4)
|
w.set('spider', 15).set('frog', 10).set('ghost', 10).set('slime', 8)
|
||||||
.set('turtle', 4).set('spore', 5);
|
.set('bat', 7).set('archer', 6).set('crab', 7).set('turret', 7)
|
||||||
} else if (wave >= 6) {
|
.set('bomber', 6).set('bee', 5).set('brute', 4).set('shaman', 5)
|
||||||
w.set('spider', 28).set('ghost', 16).set('frog', 12).set('slime', 8)
|
.set('spore', 5).set('wisp', 4).set('turtle', 4);
|
||||||
.set('bat', 8).set('turret', 8)
|
} else if (wave >= 61) {
|
||||||
.set('archer', 7).set('bomber', 7).set('wisp', 4).set('spore', 5);
|
w.set('spider', 16).set('frog', 11).set('ghost', 11).set('slime', 8)
|
||||||
} else if (wave >= 5) {
|
.set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8)
|
||||||
w.set('spider', 35).set('ghost', 16).set('frog', 12).set('slime', 8)
|
.set('bomber', 7).set('bee', 5).set('brute', 4).set('shaman', 5)
|
||||||
.set('bat', 8)
|
.set('spore', 5).set('wisp', 5);
|
||||||
.set('archer', 7).set('bomber', 7).set('spore', 5);
|
} else if (wave >= 51) {
|
||||||
} else if (wave >= 4) {
|
w.set('spider', 17).set('frog', 12).set('ghost', 12).set('slime', 8)
|
||||||
w.set('spider', 45).set('ghost', 20).set('frog', 16)
|
.set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8)
|
||||||
.set('archer', 6).set('bomber', 6);
|
.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) {
|
} else if (wave >= 3) {
|
||||||
w.set('spider', 75).set('ghost', 25);
|
w.set('spider', 60).set('frog', 40);
|
||||||
} else {
|
} else {
|
||||||
w.set('spider', 100);
|
w.set('spider', 100);
|
||||||
}
|
}
|
||||||
@@ -1036,6 +1264,16 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
|
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);
|
enemy.body.position.set(x, y, z);
|
||||||
this.scene.add(enemy.body);
|
this.scene.add(enemy.body);
|
||||||
this.enemies.push(enemy);
|
this.enemies.push(enemy);
|
||||||
@@ -1150,6 +1388,11 @@ export class Game {
|
|||||||
p.body.position.z += p.velocity.z * dt;
|
p.body.position.z += p.velocity.z * dt;
|
||||||
this.clampToArena(p.body.position);
|
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
|
// Look at ground point
|
||||||
p.body.lookAt(
|
p.body.lookAt(
|
||||||
this.groundPoint.x,
|
this.groundPoint.x,
|
||||||
@@ -1166,6 +1409,7 @@ export class Game {
|
|||||||
// Stationäre Gegner (Turret, Sporen-Pilz, Krake, Spinnenkönigin), die zu
|
// Stationäre Gegner (Turret, Sporen-Pilz, Krake, Spinnenkönigin), die zu
|
||||||
// lange außerhalb des Bildschirms sind, werden wieder in Sicht teleportiert.
|
// lange außerhalb des Bildschirms sind, werden wieder in Sicht teleportiert.
|
||||||
private updateOffscreenReposition(dt: number) {
|
private updateOffscreenReposition(dt: number) {
|
||||||
|
if (this.mazeActive) return;
|
||||||
for (const enemy of this.enemies) {
|
for (const enemy of this.enemies) {
|
||||||
if (enemy.dead) {
|
if (enemy.dead) {
|
||||||
this.offscreenTimers.delete(enemy);
|
this.offscreenTimers.delete(enemy);
|
||||||
@@ -1328,7 +1572,10 @@ export class Game {
|
|||||||
this.uiGasCD.style.width = `${gasPct * 100}%`;
|
this.uiGasCD.style.width = `${gasPct * 100}%`;
|
||||||
|
|
||||||
const wavePct = Math.min(1, this.spawnTimer / SPAWN_TIME);
|
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.uiWaveBarFill.style.width = '100%';
|
||||||
this.uiWaveBarLabel.textContent = 'BOSS!';
|
this.uiWaveBarLabel.textContent = 'BOSS!';
|
||||||
} else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) {
|
} else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) {
|
||||||
@@ -1346,6 +1593,8 @@ export class Game {
|
|||||||
this.uiLowHpVignette.classList.toggle(
|
this.uiLowHpVignette.classList.toggle(
|
||||||
'active', healthPct < 0.3 && healthPct > 0
|
'active', healthPct < 0.3 && healthPct > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.updateMazeArrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerInkVignette() {
|
triggerInkVignette() {
|
||||||
@@ -1454,6 +1703,13 @@ export class Game {
|
|||||||
this.updateFireballs(dt);
|
this.updateFireballs(dt);
|
||||||
this.updateHitzones();
|
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
|
// Update slash effects
|
||||||
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
|
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
|
||||||
if (!this.slashEffects[i].update(dt)) {
|
if (!this.slashEffects[i].update(dt)) {
|
||||||
@@ -1482,13 +1738,25 @@ export class Game {
|
|||||||
|
|
||||||
// Spawn enemies in waves
|
// Spawn enemies in waves
|
||||||
if (this.state === 'playing') {
|
if (this.state === 'playing') {
|
||||||
const arenaCleared = this.enemies.every(e => e.dead);
|
if (this.mazeActive) {
|
||||||
if (this.bossFightActive) {
|
// 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
|
// Boss besiegt -> naechste Welle startet
|
||||||
if (arenaCleared) {
|
if (arenaCleared) {
|
||||||
this.bossFightActive = false;
|
this.bossFightActive = false;
|
||||||
this.spawns++;
|
this.spawns++;
|
||||||
this.spawnTimer = 0;
|
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) {
|
} else if (this.spawnTimer >= SPAWN_TIME) {
|
||||||
if (this.isBossWave(this.spawns + 1)) {
|
if (this.isBossWave(this.spawns + 1)) {
|
||||||
@@ -1499,6 +1767,11 @@ export class Game {
|
|||||||
this.bossFightActive = true;
|
this.bossFightActive = true;
|
||||||
this.spawnTimer = 0;
|
this.spawnTimer = 0;
|
||||||
}
|
}
|
||||||
|
} else if (this.isMazeWave(this.spawns + 1)) {
|
||||||
|
// Labyrinth-Zwischenboss startet (ersetzt die Welle)
|
||||||
|
this.spawns++;
|
||||||
|
this.startMazeWave();
|
||||||
|
this.spawnTimer = 0;
|
||||||
} else {
|
} else {
|
||||||
this.spawns++;
|
this.spawns++;
|
||||||
for (let i = 0; i < this.spawns; i++) {
|
for (let i = 0; i < this.spawns; i++) {
|
||||||
@@ -1510,6 +1783,7 @@ export class Game {
|
|||||||
this.spawnTimer += dt;
|
this.spawnTimer += dt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check close encounters (enemy touching player)
|
// Check close encounters (enemy touching player)
|
||||||
if (this.state === 'playing') {
|
if (this.state === 'playing') {
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ export class Ghost extends Enemy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Chase the player (stays hittable and dangerous even while invisible)
|
// 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()
|
const toPlayer = new THREE.Vector3()
|
||||||
.subVectors(game.player.body.position, this.body.position);
|
.subVectors(game.player.body.position, this.body.position);
|
||||||
toPlayer.y = 0;
|
toPlayer.y = 0;
|
||||||
|
|||||||
@@ -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
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -22,7 +22,7 @@ export const SKILLS: SkillDef[] = [
|
|||||||
type: 'active',
|
type: 'active',
|
||||||
maxLevel: 5,
|
maxLevel: 5,
|
||||||
describe: (l) => l === 1
|
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' : ''}`,
|
: `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@ export const SKILLS: SkillDef[] = [
|
|||||||
name: 'Kettenblitz',
|
name: 'Kettenblitz',
|
||||||
type: 'active',
|
type: 'active',
|
||||||
maxLevel: 5,
|
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',
|
id: 'swordRange',
|
||||||
@@ -132,23 +132,26 @@ export function getSkill(id: string): SkillDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fireballDamage(level: number): number {
|
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 {
|
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 {
|
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 {
|
export function teleportCooldown(level: number): number {
|
||||||
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
|
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
|
||||||
}
|
}
|
||||||
export function lightningDamage(level: number): number {
|
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 {
|
export function lightningJumps(level: number): number {
|
||||||
return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2;
|
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 {
|
export function lightningCooldown(level: number): number {
|
||||||
return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8;
|
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;
|
return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1;
|
||||||
}
|
}
|
||||||
export function trapDamage(level: number): number {
|
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 {
|
export function trapHold(level: number): number {
|
||||||
return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5;
|
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;
|
return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8;
|
||||||
}
|
}
|
||||||
export function gasDamage(level: number): number {
|
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 {
|
export function gasRadius(level: number): number {
|
||||||
return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5;
|
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 {
|
xpNeeded(): number {
|
||||||
return 20 + this.level * 10;
|
return 30 + this.level * 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
xpProgress(): number {
|
xpProgress(): number {
|
||||||
|
|||||||
Reference in New Issue
Block a user