Compare commits

...
6 Commits
29 changed files with 6050 additions and 62 deletions
+23 -3
View File
@@ -19,6 +19,22 @@ npm run dev # Dev-Server mit HMR
- `Makefile`: `make web`/`make dev`/`make build` (Container-Build via Podman).
- UI-Sprache: Deutsch (Skill-Namen, Level-up-Karten); Code-Kommentare bisher gemischt, Git-Messages englisch.
## Modell-Viewer (visuelle Modell-Checks)
- `web/viewer.html` = eigener Vite-Entry (auch im Build): rendert ein Gegner-/Boss-Modell
isoliert auf Grid-Bühne mit hellem Licht, Turntable-Kamera (Auto-Fit per Box3) und
Mini-UI zum Umschalten. Modelle laufen gegen ein Stub-`Game` (Stub-Spieler als grüner
Blob 8 m entfernt als Blick-/Angriffsziel).
- URL-Params: `?m=<id>` (ids wie `dragon`, `krake`, `spider`, … siehe Registry in
`viewer.ts`), `&still=1` = statische Pose (nur `updateAnimation`, kein `update`).
- Automatisierte Screenshots: `scripts/model_shot.sh <id> [shots=4] [intervall=1.5] [still]`
startet eigenen Dev-Server auf Port 3100, öffnet Viewer-Fenster (Titel
„Wackelpeter Modell-Viewer"), minimiert die Konsole per KWin, macht `shots`
Turntable-Screenshots nach `/tmp/modelshot_*_crop.png`, blendet die Konsole wieder
ein, schließt das Viewer-Fenster und stoppt den 3100er-Server. Pfade kommen auf stdout.
- Workflow für Modell-Überarbeitung: Code editieren → `scripts/model_shot.sh <id>`
Crops lesen → iterieren. Keine Cheats, Klicks oder Bosskämpfe nötig.
## Architektur (web/src)
| Datei | Inhalt |
@@ -34,9 +50,10 @@ npm run dev # Dev-Server mit HMR
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` | Bosse (Welle 10 / 20 / 30), 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. 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 |
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` |
| `*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 |
| `MeshLoader.ts` | `loadGLB(name)``web/public/models/*.glb`; Texturen in `web/public/textures/` |
@@ -50,7 +67,10 @@ npm run dev # Dev-Server mit HMR
- **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen").
- **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen.
- **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf).
- **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) und `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf). Alles wird in `Player.reset()` zurückgesetzt.
- **Balance**: XP-Kurve `30 + 15·Level` (steiler, Skills maxen langsamer); Level-Ups geben genau **1** Skill-Punkt (Multi-Level nur bei XP-Überschuss, `pendingPicks`); Gegner-HP skaliert erst NACH den Bossen (jede 10. Welle = 1 Boss-Gate → +20% HP, nur Nicht-Bosse, in `spawnEnemyInstance`); neue Gegner erscheinen in `enemyWeights` nur nach Boss-Wellen (vor Golem nur Spinnen/Frösche, dann nach Golem/Krake/Minotaurus/Spinnenkönigin/Frostriese/Nekromant/Drache); Boss-Modus-Grants `wave·0.4` Skills. Arena-Theme: `Arena.applyTheme(stage)` (8 Stufen, Boden/Wände/Nebel/Licht) wird in `Game` nach jedem Boss-Sieg (`arenaStage++`) getriggert, weiches Lerpen in `arena.update(dt)`.
- **Gegner-Spawn im Sichtbereich**: `spawnEnemy`/`spawnBossForWave` spawnen im Ring 1024 m um den Spieler; stationäre Gegner (`immovable`), die >8 s außerhalb des Bildschirms sind (NDC-Projektion `isOnScreen`), werden wieder in Sicht teleportiert (`updateOffscreenReposition`).
- **Labyrinth-Zwischenboss**: `Game.MAZE_WAVES` (5, 15, 25, …) ersetzt die Welle komplett (`startMazeWave`/`updateMaze`/`endMazeWave`); Zeitlimit (75 s 5 s/Level, Min 50 s) → bei Ablauf wird das Labyrinth neu generiert und der Spieler zur Startecke gesetzt; Gnome nur mit Sichtlinie verletzbar (`hasLineOfSight`); `mazeActive` pausiert Spawn-Logik und Off-Screen-Reposition; Aufräumen über `disposeMaze()` in `resetWorld`; `#maze-arrow`-Overlay zeigt zum Ziel.
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung), `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf) und `rootTime` (Netz-Fessel: keine Bewegung, Angriffe bleiben möglich). Alles wird in `Player.reset()` zurückgesetzt.
- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig).
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Visuelle Modell-Checks fuer Wackelpeter (Bazzite/KDE Wayland).
#
# Öffnet den Modell-Viewer (web/viewer.html) in einem eigenen Firefox-Fenster,
# macht Turntable-Screenshots und räumt danach auf (Fenster zu, Konsole wieder
# sichtbar, eigener Dev-Server gestoppt). Die Pfade der Crops gehen auf stdout.
#
# Usage: scripts/model_shot.sh <modell-id> [shots=4] [intervall-sek=1.5] [still]
# modell-id: spider ghost frog bat bee slime brute crab turret shaman archer
# bomber wisp turtle spore golem krake minotaurus spiderqueen
# frostgiant necromancer dragon gnome mazeguard
# still: beliebiges 4. Argument -> statische Pose (&still=1)
#
# Voraussetzung: maximiertes Firefox-Fenster auf 3440x1440 (feste Crop-Box).
set -u
MODEL="${1:?Modell-ID erforderlich, z.B. dragon}"
SHOTS="${2:-4}"
INTERVAL="${3:-1.5}"
STILL="${4:-}"
PORT=3100
cd "$(dirname "$0")/.." || exit 1
URL="http://localhost:$PORT/viewer.html?m=$MODEL"
[ -n "$STILL" ] && URL="$URL&still=1"
# --- eigener Dev-Server auf 3100 (nicht den Nutzer-Server auf 3000 anfassen) ---
started_server=0
if ! ss -tln | grep -q ":$PORT "; then
(cd web && npx vite --port "$PORT" --strictPort > /tmp/viewer-vite.log 2>&1 &)
started_server=1
for _ in $(seq 1 40); do
ss -tln | grep -q ":$PORT " && break
sleep 0.5
done
fi
# --- KWin-Scripte (Fensterverwaltung ohne Fokus-Raten) ---
kwin_run() {
local name="$1" file="/tmp/qwen_kwin_$1.js"
printf '%s\n' "$2" > "$file"
qdbus org.kde.KWin /Scripting unloadScript "$name" > /dev/null 2>&1
qdbus org.kde.KWin /Scripting loadScript "$file" "$name" > /dev/null
qdbus org.kde.KWin /Scripting start > /dev/null
}
kwin_run viewer_min 'for (const c of workspace.windowList()) { if (c.resourceClass.toLowerCase().includes("konsole")) c.minimized = true; }'
flatpak run org.mozilla.firefox --new-window "$URL" > /dev/null 2>&1 &
sleep 6
# --- Screenshots + feste Crop-Box (Mitte des maximierten Fensters) ---
for i in $(seq 1 "$SHOTS"); do
# Viewer-Fenster vor jedem Shot nach vorn holen (sonst kapert ein anderes
# Fenster den Desktop-Screenshot)
kwin_run viewer_raise 'for (const c of workspace.windowList()) { if (c.caption.includes("Modell-Viewer")) { c.minimized = false; workspace.activeWindow = c; } }'
sleep 0.4
spectacle -b -n -o "/tmp/modelshot_$i.png"
magick "/tmp/modelshot_$i.png" -crop 2200x1200+600+100 +repage "/tmp/modelshot_${i}_crop.png"
echo "/tmp/modelshot_${i}_crop.png"
[ "$i" -lt "$SHOTS" ] && sleep "$INTERVAL"
done
# --- Aufräumen: Konsole einblenden, Viewer-Fenster schließen ---
kwin_run viewer_restore 'for (const c of workspace.windowList()) { const cls = c.resourceClass.toLowerCase(); if (cls.includes("konsole")) c.minimized = false; if (c.caption.includes("Modell-Viewer")) c.closeWindow(); }'
if [ "$started_server" = 1 ]; then
PID=$(ss -tlnp | grep ":$PORT " | grep -oP 'pid=\K[0-9]+' | head -1)
[ -n "${PID:-}" ] && kill "$PID" 2> /dev/null
fi
+20
View File
@@ -120,6 +120,15 @@
text-align: center; line-height: 16px; color: #fff; font-size: 11px;
font-family: monospace;
}
#maze-arrow {
position: absolute;
width: 40px; height: 40px;
color: #ffcc44; font-size: 30px; line-height: 40px; text-align: center;
text-shadow: 0 0 8px #ff8800, 0 0 2px #000;
font-family: monospace;
pointer-events: none; user-select: none;
opacity: 0; visibility: hidden;
}
#game-over {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
@@ -286,6 +295,7 @@
<div id="damage-vignette"></div>
<div id="low-hp-vignette"></div>
<div id="ink-vignette"></div>
<div id="maze-arrow">&#9650;</div>
<div id="boss-bar">
<div id="boss-bar-label">BOSS</div>
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
@@ -400,6 +410,16 @@
<div id="changelog-panel">
<h2>Changelog</h2>
<div id="changelog-list">
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Labyrinth-Zwischenboss (Welle 5, 15, 25, …): generiertes Labyrinth, Gnom als Ziel, Zeitlimit, Wächter-Geister ab Level 2, Gnom 2x finden ab Welle 25, Pfeil zeigt den Weg, Boss-Modus-Buttons</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Level-Up-Bug behoben: genau 1 Skill-Punkt pro Level (vorher 2); Gegner werden erst nach den Bossen stärker, neue Monster erscheinen erst nach jedem Boss</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Balance: steilere XP-Kurve, weniger Boss-Modus-Skills, Feuerball abgeschwächt (Kettenblitz/Falle/Gaswolke gepuffert), Gegner-HP skaliert mit der Welle</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Arena verändert sich nach jedem Boss-Sieg (Boden, Wände, Nebel und Licht färben sich um)</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Neue Gegner werden nach den Bossen nach und nach freigeschaltet statt ab Welle 4-7</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Sechs neue Gegner: Schamane (heilt), Bogenschütze (Telegraf-Pfeile), Bomber (Explosion), Irrlicht (Slow-Sporen), Schildkröte (Frontpanzer + Rückzug) und Sporen-Pilz (Bogen-Sporen mit DoT-Wolke)</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Frostriese (Welle 50): Eispanzer-Rüstung, Eisberst-Ringe und Eiszapfen-Regen</span></div>
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Spinnenkönigin (Welle 40): Netze fesseln den Spieler, Spinngeschwister und Säuresalven</span></div>
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Minotaurus-Boss mit Frontpanzerung, Dash-Stun und überspringbarem Axthieb</span></div>
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Boss-Treffer-Feedback mit Aufblitzen und schwebenden Schadenszahlen</span></div>
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Krake-Boss mit Tinte, Zug-Markierungen und Sweep-Tentakeln plus Boss-Modus</span></div>
+388
View File
@@ -0,0 +1,388 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const IDEAL_MIN = 8;
const IDEAL_MAX = 13;
const SHOOT_RANGE = 18;
const SHOOT_COOLDOWN = 3;
const TELEGRAPH_TIME = 0.6;
const ARROW_DAMAGE = 18;
const HIT_FLASH_TIME = 0.14;
// Bogenschütze: hält Distanz und schießt Pfeile mit Boden-Telegraf (ausweichbar)
export class Archer extends Enemy {
private model: THREE.Group;
private boneMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private bowGroup!: THREE.Group;
private legL!: THREE.Group;
private legR!: THREE.Group;
private walkPhase = 0;
private walkAmp = 0;
private handL!: THREE.Mesh;
private arrowNock!: THREE.Mesh;
private draw = 0; // 0 = Bogen entspannt, 1 = voll ausgezogen
private recoil = 0; // klingt nach dem Abschuss ab
private shootTimer = 1 + Math.random();
private telegraphs: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; timer: number }[] = [];
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor() {
super();
this.health = 55;
this.speed = 4.2;
this.xp = 22;
this.contactDps = 15;
this.contactRadius = 1.5;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.boneMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x8a7a5a });
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff4422 });
// --- Skelett (Blickrichtung +Z) ---
// Becken + Gürtel
const pelvis = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), this.boneMat);
pelvis.scale.set(1.2, 0.7, 0.9);
pelvis.position.y = 0.78;
this.registerFadeMesh(pelvis);
this.model.add(pelvis);
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.17, 0.03, 6, 12), this.darkMat);
belt.rotation.x = Math.PI / 2;
belt.position.y = 0.82;
this.registerFadeMesh(belt);
this.model.add(belt);
// Wirbelsäule + Rippenbögen
const spine = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.045, 0.55, 6), this.boneMat);
spine.position.y = 1.08;
this.registerFadeMesh(spine);
this.model.add(spine);
for (const [y, r] of [[1.02, 0.2], [1.14, 0.23], [1.26, 0.19]] as const) {
const rib = new THREE.Mesh(new THREE.TorusGeometry(r, 0.025, 6, 12), this.boneMat);
rib.rotation.x = Math.PI / 2;
rib.scale.set(1, 1, 0.8);
rib.position.y = y;
this.registerFadeMesh(rib);
this.model.add(rib);
}
// Schädel mit Kiefer, Augenhöhlen und roten Augen (+Z)
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.22, 12, 10), this.boneMat);
skull.scale.set(0.95, 1.05, 1);
skull.position.y = 1.52;
skull.castShadow = true;
this.registerFadeMesh(skull);
this.model.add(skull);
const jaw = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.09, 0.16), this.boneMat);
jaw.position.set(0, 1.38, 0.1);
this.registerFadeMesh(jaw);
this.model.add(jaw);
for (const side of [-1, 1]) {
const socket = new THREE.Mesh(new THREE.SphereGeometry(0.055, 8, 6), this.darkMat);
socket.position.set(side * 0.09, 1.55, 0.17);
this.registerFadeMesh(socket);
this.model.add(socket);
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 6, 5), eyeMat);
eye.position.set(side * 0.09, 1.55, 0.2);
this.model.add(eye);
}
// Schultern + Arme (rechts hält den Bogen, links zieht die Sehne)
for (const side of [-1, 1]) {
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), this.boneMat);
shoulder.position.set(side * 0.28, 1.34, 0);
this.registerFadeMesh(shoulder);
this.model.add(shoulder);
}
const upperArmR = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.32, 6), this.boneMat);
upperArmR.position.set(0.34, 1.26, 0.08);
upperArmR.rotation.z = -1.0;
this.registerFadeMesh(upperArmR);
this.model.add(upperArmR);
const foreArmR = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.3, 6), this.boneMat);
foreArmR.position.set(0.4, 1.18, 0.25);
foreArmR.rotation.x = -1.2;
this.registerFadeMesh(foreArmR);
this.model.add(foreArmR);
const handR = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), this.boneMat);
handR.position.set(0.42, 1.15, 0.4);
this.registerFadeMesh(handR);
this.model.add(handR);
const upperArmL = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.32, 6), this.boneMat);
upperArmL.position.set(-0.34, 1.24, 0.05);
upperArmL.rotation.z = 1.1;
this.registerFadeMesh(upperArmL);
this.model.add(upperArmL);
const foreArmL = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.3, 6), this.boneMat);
foreArmL.position.set(-0.24, 1.25, 0.15);
foreArmL.rotation.x = -1.6;
foreArmL.rotation.z = 0.6;
this.registerFadeMesh(foreArmL);
this.model.add(foreArmL);
this.handL = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), this.boneMat);
this.handL.position.set(-0.1, 1.3, 0.28);
this.registerFadeMesh(this.handL);
this.model.add(this.handL);
// Beine mit Kniegelenken und Füßen (Gruppen mit Hüft-Pivot für Laufanimation)
for (const side of [-1, 1]) {
const leg = new THREE.Group();
leg.position.set(side * 0.15, 0.72, 0);
this.model.add(leg);
const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.07, 0.4, 6), this.boneMat);
thigh.position.y = -0.17;
this.registerFadeMesh(thigh);
leg.add(thigh);
const knee = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), this.boneMat);
knee.position.y = -0.37;
this.registerFadeMesh(knee);
leg.add(knee);
const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 0.35, 6), this.boneMat);
shin.position.y = -0.54;
this.registerFadeMesh(shin);
leg.add(shin);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.06, 0.22), this.boneMat);
foot.position.set(0, -0.68, 0.06);
this.registerFadeMesh(foot);
leg.add(foot);
if (side < 0) {
this.legL = leg;
} else {
this.legR = leg;
}
}
// Zerfetzter Umhang auf dem Rücken
const cape = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.75, 8, 1, true), this.darkMat);
cape.position.set(0, 1.0, -0.2);
cape.scale.set(1.1, 1, 0.5);
this.registerFadeMesh(cape);
this.model.add(cape);
for (const [x, z] of [[-0.15, -0.28], [0, -0.32], [0.15, -0.28]] as const) {
const tatter = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.25, 4), this.darkMat);
tatter.rotation.x = Math.PI;
tatter.position.set(x, 0.62, z);
this.registerFadeMesh(tatter);
this.model.add(tatter);
}
// Köcher mit Pfeilen am Rücken
const quiver = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.11, 0.5, 8), this.darkMat);
quiver.position.set(0.12, 1.15, -0.28);
quiver.rotation.x = 0.5;
this.registerFadeMesh(quiver);
this.model.add(quiver);
for (const dx of [-0.03, 0.02, 0.06]) {
const qArrow = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.28, 5), this.boneMat);
qArrow.position.set(0.12 + dx, 1.42, -0.4);
qArrow.rotation.x = 0.5;
this.registerFadeMesh(qArrow);
this.model.add(qArrow);
}
// Bogen (hängt an Gruppe, wird beim Schuss angehoben):
// Halbkreis-Bogen mit Sehne und eingelegtem Pfeil
this.bowGroup = new THREE.Group();
this.bowGroup.position.set(0.42, 1.15, 0.45);
this.model.add(this.bowGroup);
const bowOrient = new THREE.Group();
bowOrient.rotation.y = -Math.PI / 2; // Bogenebene in Blickrichtung
this.bowGroup.add(bowOrient);
const bow = new THREE.Mesh(new THREE.TorusGeometry(0.4, 0.028, 6, 16, Math.PI), this.darkMat);
bow.rotation.z = -Math.PI / 2; // Bogenbauch zeigt nach +Z
this.registerFadeMesh(bow);
bowOrient.add(bow);
const string = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.8, 5), this.darkMat);
this.registerFadeMesh(string);
bowOrient.add(string);
// Pfeil sitzt auf Sehne und Bogen: Schaft entlang lokaler X-Achse
// (zeigt nach Welt-+Z), Nocke an der Sehne
this.arrowNock = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, 0.75, 5), this.boneMat);
this.arrowNock.rotation.z = -Math.PI / 2;
this.arrowNock.position.x = 0.28;
this.registerFadeMesh(this.arrowNock);
bowOrient.add(this.arrowNock);
const arrowHead = new THREE.Mesh(new THREE.ConeGeometry(0.035, 0.1, 6), this.darkMat);
arrowHead.position.y = 0.42;
this.registerFadeMesh(arrowHead);
this.arrowNock.add(arrowHead);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.9, sink: 0.4 });
}
update(dt: number, game: Game) {
this.time += dt;
this.game = game;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.boneMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
} else {
this.boneMat.color.set(0xe8dcc0);
this.darkMat.color.set(0x8a7a5a);
}
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
let moved = false;
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
// Distanz halten: weg, wenn zu nah; hin, wenn zu weit; sonst seitlich
let move = new THREE.Vector3();
if (dist < IDEAL_MIN) {
move.copy(dir).multiplyScalar(-1);
} else if (dist > IDEAL_MAX) {
move.copy(dir);
} else {
move.set(-dir.z, 0, dir.x).multiplyScalar(Math.sin(this.time * 2) * 0.6);
}
if (this.knockback > 0) {
move = dir.clone().multiplyScalar(-1);
this.knockback -= dt;
}
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar(this.speed);
this.body.position.x += move.x * dt;
this.body.position.z += move.z * dt;
game.clampToArena(this.body.position);
moved = true;
}
}
// Laufanimation: Beine gegengleich schwingen, Amplitude weich ein-/ausblenden
this.walkAmp += ((moved ? 0.55 : 0) - this.walkAmp) * Math.min(1, dt * 8);
if (this.walkAmp > 0.01) {
this.walkPhase += dt * 11;
}
const swing = Math.sin(this.walkPhase) * this.walkAmp;
this.legL.rotation.x = swing;
this.legR.rotation.x = -swing;
this.model.position.y = Math.abs(Math.cos(this.walkPhase)) * this.walkAmp * 0.12;
// Schuss mit Telegraf
this.shootTimer -= dt;
if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) {
this.shootTimer = SHOOT_COOLDOWN + Math.random() * 0.5;
this.startShot(game);
}
// Telegrafen ablaufen lassen -> Pfeil los
for (let i = this.telegraphs.length - 1; i >= 0; i--) {
const t = this.telegraphs[i];
t.timer -= dt;
if (t.timer <= 0) {
this.fireArrow(game, t.mesh.position);
game.scene.remove(t.mesh);
t.mesh.geometry.dispose();
t.mat.dispose();
this.telegraphs.splice(i, 1);
} else {
t.mat.opacity = 0.25 + 0.3 * Math.abs(Math.sin(this.time * 10));
}
}
// Schießanimation: während des Telegrafs Bogen heben und Sehne spannen,
// beim Abschuss Recoil-Kick und schnelles Entspannen
const aiming = this.telegraphs.length > 0;
const drawRate = aiming ? 6 : this.recoil > 0 ? 25 : 10;
this.draw += ((aiming ? 1 : 0) - this.draw) * Math.min(1, dt * drawRate);
this.recoil = Math.max(0, this.recoil - dt);
const aim = this.draw;
this.bowGroup.position.set(
0.42 - aim * 0.17,
1.15 + aim * 0.2,
0.45 + aim * 0.1
);
this.bowGroup.rotation.x = -aim * 0.15;
this.bowGroup.rotation.z =
Math.sin(this.time * 2) * 0.1 * (1 - aim) - this.recoil * 2;
this.handL.position.z = 0.28 - aim * 0.16;
this.arrowNock.position.x = 0.28 - aim * 0.14;
}
private startShot(game: Game) {
// Roter Telegraf an der aktuellen Spielerposition (Zielpunkt des Pfeils)
const mat = new THREE.MeshBasicMaterial({
color: 0xff3344,
transparent: true,
opacity: 0.4,
side: THREE.DoubleSide,
depthWrite: false,
});
const ring = new THREE.Mesh(new THREE.RingGeometry(0.5, 0.8, 24), mat);
ring.rotation.x = -Math.PI / 2;
const pos = game.player.body.position;
ring.position.set(pos.x, 0.06, pos.z);
game.scene.add(ring);
this.telegraphs.push({ mesh: ring, mat, timer: TELEGRAPH_TIME });
playSound('spawn', 0.4);
}
private fireArrow(game: Game, targetPos: THREE.Vector3) {
if (game.player.health <= 0) return;
const from = this.body.position.clone();
from.y += 1.2;
const target = targetPos.clone();
target.y += 0.6;
game.spawnEnemyProjectile(from, target, ARROW_DAMAGE);
// Recoil-Kick, klingt in update() ab
this.recoil = 0.25;
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(game: Game) {
this.cleanupTelegraphs(game);
this.beginFadeDeath();
}
override dispose() {
super.dispose();
if (this.game) this.cleanupTelegraphs(this.game);
}
private game: Game | null = null;
private cleanupTelegraphs(game: Game) {
for (const t of this.telegraphs) {
game.scene.remove(t.mesh);
t.mesh.geometry.dispose();
t.mat.dispose();
}
this.telegraphs = [];
}
}
+62
View File
@@ -1,6 +1,35 @@
import * as THREE from 'three';
interface Theme {
floorLight: number;
floorDark: number;
wall: number;
fog: number;
}
// 8 Stufen: Start + eine pro besiegten Boss (Golem, Krake, Minotaurus,
// Spinnenkönigin, Frostriese, Erz-Nekromant, Drache)
const THEMES: Theme[] = [
{ floorLight: 0x668866, floorDark: 0x557755, wall: 0x776677, fog: 0x0d1117 }, // Start (Grün)
{ floorLight: 0x8a7a55, floorDark: 0x7a6a48, wall: 0x776055, fog: 0x141210 }, // Golem (Erdig)
{ floorLight: 0x3a4a66, floorDark: 0x2f3d55, wall: 0x44506a, fog: 0x0a1220 }, // Krake (Tinte)
{ floorLight: 0x8a4a3a, floorDark: 0x6e3a2c, wall: 0x7a4438, fog: 0x180d0b }, // Minotaurus (Asche)
{ floorLight: 0x4a7a4a, floorDark: 0x3a643a, wall: 0x4a6a4a, fog: 0x0a160d }, // Spinnenkönigin (Gift)
{ floorLight: 0x88bbcc, floorDark: 0x6a9ab0, wall: 0x7aa0b5, fog: 0x0d1a24 }, // Frostriese (Eis)
{ floorLight: 0x6a4a8a, floorDark: 0x543a70, wall: 0x5a3f74, fog: 0x140a20 }, // Erz-Nekromant (Lila)
{ floorLight: 0xaa6a33, floorDark: 0x8a5428, wall: 0x8f5c34, fog: 0x1e0f06 }, // Drache (Glut)
];
const LERP_SPEED = 2;
export class Arena extends THREE.Group {
private lightMats: THREE.MeshStandardMaterial[] = [];
private darkMats: THREE.MeshStandardMaterial[] = [];
private wallMats: THREE.MeshToonMaterial[] = [];
private targets:
| { floorLight: THREE.Color; floorDark: THREE.Color; wall: THREE.Color }
| null = null;
constructor() {
super();
@@ -23,6 +52,7 @@ export class Arena extends THREE.Group {
tile.position.set(x, -0.15, z);
tile.receiveShadow = true;
this.add(tile);
(isDark ? this.darkMats : this.lightMats).push(tileMat);
}
}
@@ -38,6 +68,12 @@ export class Arena extends THREE.Group {
this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
}
for (const wall of this.children) {
const mesh = wall as THREE.Mesh;
if (mesh.material instanceof THREE.MeshToonMaterial) {
this.wallMats.push(mesh.material);
}
}
}
private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) {
@@ -47,4 +83,30 @@ export class Arena extends THREE.Group {
wall.receiveShadow = true;
this.add(wall);
}
// Setzt die Ziel-Farben für eine Theme-Stufe und gibt die Nebel-Farbe zurück
applyTheme(stage: number): THREE.Color {
const t = THEMES[Math.min(Math.max(stage, 0), THEMES.length - 1)];
this.targets = {
floorLight: new THREE.Color(t.floorLight),
floorDark: new THREE.Color(t.floorDark),
wall: new THREE.Color(t.wall),
};
return new THREE.Color(t.fog);
}
// Lerpt die aktuellen Farben weich in Richtung Ziel-Theme
update(dt: number) {
if (!this.targets) return;
const t = Math.min(1, dt * LERP_SPEED);
for (const mat of this.lightMats) {
mat.color.lerp(this.targets.floorLight, t);
}
for (const mat of this.darkMats) {
mat.color.lerp(this.targets.floorDark, t);
}
for (const mat of this.wallMats) {
mat.color.lerp(this.targets.wall, t);
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const EXPLODE_DIST = 2.2;
const EXPLODE_RADIUS = 4.5;
const EXPLODE_DAMAGE = 30;
const FLASH_TIME = 0.35;
// Goblin-Bomber: rast auf den Spieler zu und explodiert in seiner Nähe
export class Bomber extends Enemy {
private model: THREE.Group;
private skinMat!: THREE.MeshToonMaterial;
private clothMat!: THREE.MeshToonMaterial;
private fuseMat!: THREE.MeshBasicMaterial;
private fuse!: THREE.Mesh;
private time = Math.random() * Math.PI * 2;
private exploding = false;
private flashTime = 0;
private flashRing!: THREE.Mesh;
private flashRingMat!: THREE.MeshBasicMaterial;
constructor() {
super();
this.health = 45;
this.speed = 6.5;
this.xp = 18;
this.contactDps = 0;
this.contactRadius = 0;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.skinMat = new THREE.MeshToonMaterial({ color: 0x55aa44 });
this.clothMat = new THREE.MeshToonMaterial({ color: 0xaa4433 });
this.fuseMat = new THREE.MeshBasicMaterial({ color: 0xffaa33 });
// Körper
const body = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 10), this.skinMat);
body.position.y = 0.65;
body.scale.set(1, 1.1, 1);
body.castShadow = true;
this.registerFadeMesh(body);
this.model.add(body);
// Stoff-Höschen
const cloth = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.35, 8, 1, true), this.clothMat);
cloth.position.y = 0.25;
cloth.rotation.x = Math.PI;
this.registerFadeMesh(cloth);
this.model.add(cloth);
// Kopf
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), this.skinMat);
head.position.y = 1.1;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Spitze Ohren
for (const side of [-1, 1]) {
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.3, 6), this.skinMat);
ear.position.set(side * 0.22, 1.32, 0.05);
ear.rotation.z = -side * 0.4;
this.registerFadeMesh(ear);
this.model.add(ear);
}
// Augen (+Z)
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.06, 6, 5), eyeMat);
eye.position.set(side * 0.1, 1.15, 0.26);
this.model.add(eye);
}
// Beine
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.4, 6), this.skinMat);
leg.position.set(side * 0.15, 0.2, 0);
this.registerFadeMesh(leg);
this.model.add(leg);
}
// Zündschnur mit Glut
const fuseGeom = new THREE.CylinderGeometry(0.03, 0.03, 0.3, 6);
this.fuse = new THREE.Mesh(fuseGeom, this.clothMat);
this.fuse.position.set(0, 1.45, 0);
this.fuse.rotation.z = 0.5;
this.registerFadeMesh(this.fuse);
this.model.add(this.fuse);
const spark = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 5), this.fuseMat);
spark.position.set(0.1, 1.6, 0);
this.model.add(spark);
// Explosions-Ring (Kind des Body, wird in updateAnimation animiert)
this.flashRingMat = new THREE.MeshBasicMaterial({
color: 0xff8833,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.flashRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), this.flashRingMat);
this.flashRing.rotation.x = -Math.PI / 2;
this.flashRing.position.y = 0.07;
this.flashRing.visible = false;
this.body.add(this.flashRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.5, shrink: 0.8 });
if (this.exploding && this.flashTime > 0) {
this.flashTime -= dt;
const t = 1 - Math.max(0, this.flashTime) / FLASH_TIME;
this.flashRing.visible = true;
this.flashRing.scale.setScalar(0.5 + t * 8);
this.flashRingMat.opacity = 0.8 * (1 - t);
if (this.flashTime <= 0) {
this.flashRing.visible = false;
this.flashRingMat.opacity = 0;
}
}
}
update(dt: number, game: Game) {
this.time += dt;
// Lauf-Animation: Wippen + Beine
this.body.position.y = 0.12 + Math.abs(Math.sin(this.time * 12)) * 0.06;
// Zündschnur glüht schneller, je näher der Gegner ist
const distToPlayer = Math.hypot(
game.player.body.position.x - this.body.position.x,
game.player.body.position.z - this.body.position.z
);
const fusePulse = Math.min(1, 1 - distToPlayer / 12);
this.fuseMat.color.setHSL(0.08, 1, 0.4 + fusePulse * 0.5);
this.fuse.scale.y = 0.8 + Math.abs(Math.sin(this.time * (4 + fusePulse * 16))) * 0.5;
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(this.speed);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-this.speed);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Explosion bei Annäherung
if (dist <= EXPLODE_DIST && game.player.health > 0 && !this.dead) {
this.explode(game);
}
}
private explode(game: Game) {
this.exploding = true;
this.flashTime = FLASH_TIME;
this.flashRing.scale.setScalar(0.5);
this.flashRing.visible = true;
this.flashRing.position.set(this.body.position.x, 0.07, this.body.position.z);
this.fuseMat.color.set(0xffffff);
playSound('explosion', 0.8);
game.triggerCameraShake(0.3, 0.35);
if (game.player.health > 0) {
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) < EXPLODE_RADIUS && game.player.body.position.y <= 0.8) {
game.player.damage(EXPLODE_DAMAGE, this.body.position);
}
}
this.takeDamage(99999, game, false);
}
protected onDeath(_game: Game) {
if (!this.exploding) {
playSound('damage', 0.5);
}
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.flashRing.geometry.dispose();
this.flashRingMat.dispose();
}
}
+40 -1
View File
@@ -4,7 +4,12 @@ import { Enemy } from './Enemy';
export class Brute extends Enemy {
protected model: THREE.Group;
protected legL!: THREE.Group;
protected legR!: THREE.Group;
protected armL: THREE.Group | null = null;
protected armR: THREE.Group | null = null;
private stepTime = 0;
private walkAmp = 0;
constructor() {
super();
@@ -21,7 +26,11 @@ export class Brute extends Enemy {
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 });
// Torso
// Becken + Torso
const pelvis = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.35, 0.55), darkMat);
pelvis.position.y = 0.62;
this.registerFadeMesh(pelvis);
this.model.add(pelvis);
const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat);
torso.position.y = 0.95;
torso.castShadow = true;
@@ -61,6 +70,26 @@ export class Brute extends Enemy {
this.registerFadeMesh(eye);
this.model.add(eye);
}
// Beine (Gruppen mit Hüft-Pivot für die Laufanimation)
for (const side of [-1, 1]) {
const leg = new THREE.Group();
leg.position.set(side * 0.35, 0.6, 0);
this.model.add(leg);
const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.35, 6), rockMat);
thigh.position.y = -0.17;
this.registerFadeMesh(thigh);
leg.add(thigh);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.12, 0.4), darkMat);
foot.position.set(0, -0.5, 0.06);
this.registerFadeMesh(foot);
leg.add(foot);
if (side < 0) {
this.legL = leg;
} else {
this.legR = leg;
}
}
}
playAnim() {
@@ -81,6 +110,7 @@ export class Brute extends Enemy {
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
let moved = false;
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
@@ -96,7 +126,16 @@ export class Brute extends Enemy {
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
moved = true;
}
// Laufanimation: Beine gegengleich im Takt der Schritte, Arme leicht mit
this.walkAmp += ((moved ? 0.5 : 0) - this.walkAmp) * Math.min(1, dt * 8);
const swing = Math.sin(this.stepTime * 3) * this.walkAmp;
this.legL.rotation.x = swing;
this.legR.rotation.x = -swing;
if (this.armL) this.armL.rotation.x = -swing * 0.4;
if (this.armR) this.armR.rotation.x = swing * 0.4;
}
protected onDeath(_game: Game) {
+3 -3
View File
@@ -1,10 +1,9 @@
import * as THREE from 'three';
import { Weapon } from './Weapon';
import { Player } from './Player';
import { lightningDamage, lightningJumps, lightningCooldown } from './Skills';
import { lightningDamage, lightningJumps, lightningJumpRange, lightningCooldown } from './Skills';
const CHAIN_FIRST_RANGE = 14;
const CHAIN_JUMP_RANGE = 8;
const FLASH_TIME = 0.35;
export class ChainLightning extends Weapon {
@@ -25,6 +24,7 @@ export class ChainLightning extends Weapon {
const game = player.game;
const damage = lightningDamage(level);
const maxJumps = lightningJumps(level);
const jumpRange = lightningJumpRange(level);
// Chain through enemies, always the closest un-hit one
const start = player.body.position.clone();
@@ -35,7 +35,7 @@ export class ChainLightning extends Weapon {
for (let i = 0; i < maxJumps; i++) {
let best: import('./Enemy').Enemy | null = null;
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : CHAIN_JUMP_RANGE;
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : jumpRange;
for (const enemy of game.enemies) {
if (enemy.dead || hit.has(enemy)) continue;
const dist = enemy.body.position.distanceTo(from);
+971
View File
@@ -0,0 +1,971 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const MAX_HEALTH = 16000;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 1.6;
const FLY_TIME = 4.5;
const FLY_TIME_2 = 3.5;
const FLY_HEIGHT = 7;
const FLY_RADIUS = 9;
const FLY_FIREBALL_CD = 1.8;
const DIVEWINDUP_TIME = 1.0;
const DIVEWINDUP_TIME_2 = 0.7;
const DIVE_TIME = 0.9;
const DIVE_IMPACT_RADIUS = 2.8;
const DIVE_DAMAGE = 30;
const DIVERECOVER_TIME = 0.4;
const LANDED_TIME = 4.0;
const LANDED_TIME_2 = 2.5;
const TAKEOFF_TIME = 0.8;
const TAKEOFF_HEIGHT = 6;
const BREATH_WINDUP = 0.9;
const BREATH_TIME = 1.1;
const BREATH_RANGE = 11;
const BREATH_HALF_ANGLE = 0.65;
const BREATH_DAMAGE = 35;
const FIREBALL_SPREAD = 0.35;
const FIREBALL_SPEED = 13;
const FIREBALL_GRAVITY = 6;
const FIREBALL_HOMING = 3.2;
const FIREBALL_DAMAGE = 20;
const FIREBALL_HIT_RADIUS = 1.1;
const FIREBALL_COUNT = 3;
const FIREBALL_COUNT_2 = 5;
const ENRAGE_RATIO = 0.35;
const WALL_LIMIT = 48;
type State = 'fly' | 'divewindup' | 'dive' | 'diverecover' | 'landed' | 'takeoff';
export class Dragon extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private state: State = 'fly';
private stateTime = 0;
private enraged = false;
private hitFlash = 0;
private doubleDiveLeft = 0;
private skinMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private bellyMat!: THREE.MeshToonMaterial;
private wingMat!: THREE.MeshToonMaterial;
private eyeMat!: THREE.MeshBasicMaterial;
private shadow!: THREE.Mesh;
private shadowMat!: THREE.MeshBasicMaterial;
private wingL!: THREE.Group;
private wingR!: THREE.Group;
private jaw!: THREE.Group;
private tailGroup!: THREE.Group;
private breathGroup!: THREE.Group;
private breathMat!: THREE.MeshBasicMaterial;
private telegraphArc!: THREE.Mesh;
private telegraphArcMat!: THREE.MeshBasicMaterial;
private flyAngle = Math.random() * Math.PI * 2;
private flyFireballTimer = 1.5;
private diveTarget = new THREE.Vector3();
private diveFrom = new THREE.Vector3();
private diveShadow!: THREE.Mesh;
private diveShadowMat!: THREE.MeshBasicMaterial;
private diveShadowActive = false;
private diveShadowAdded = false;
private breathHit = false;
private breathActive = false;
private fireballs: DragonFireball[] = [];
private impactFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
private breathPhase = 'idle';
private breathTime = 0;
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Drache';
this.speed = 0;
this.xp = 400;
this.contactDps = 30;
this.contactRadius = 3.6;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
this.fadeOutModel = this.model;
this.body.add(this.model);
this.stateTime = FLY_TIME;
this.skinMat = new THREE.MeshToonMaterial({ color: 0xbb4433 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x772a1e });
this.bellyMat = new THREE.MeshToonMaterial({ color: 0xe8c890 });
this.wingMat = new THREE.MeshToonMaterial({ color: 0x993322, side: THREE.DoubleSide });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xffcc33 });
const boneMat = new THREE.MeshToonMaterial({ color: 0xf0e6cf });
let boneFadeRegistered = false;
const registerBone = (mesh: THREE.Mesh) => {
// Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt)
if (!boneFadeRegistered) {
this.registerFadeMesh(mesh);
boneFadeRegistered = true;
}
};
// Hilfspunkt: Strebe/Knochen zwischen zwei Punkten
const strut = (
a: THREE.Vector3, b: THREE.Vector3, r1: number, r2: number
): THREE.Mesh => {
const dir = new THREE.Vector3().subVectors(b, a);
const len = dir.length();
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r2, r1, len, 6), this.darkMat);
mesh.position.copy(a).addScaledVector(dir, 0.5);
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
return mesh;
};
// --- Körper (Blickrichtung +Z) ---
// Brust, Rumpfmitte, Hüfte (überlappende Kugeln für organische Silhouette)
const chest = new THREE.Mesh(new THREE.SphereGeometry(0.8, 18, 12), this.skinMat);
chest.scale.set(0.95, 0.92, 1.05);
chest.position.set(0, 1.4, 0.85);
chest.castShadow = true;
this.registerFadeMesh(chest);
this.model.add(chest);
const torso = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 14), this.skinMat);
torso.scale.set(1, 0.85, 1.5);
torso.position.set(0, 1.5, -0.1);
torso.castShadow = true;
this.registerFadeMesh(torso);
this.model.add(torso);
const hips = new THREE.Mesh(new THREE.SphereGeometry(0.85, 18, 12), this.skinMat);
hips.scale.set(1.0, 0.9, 1.1);
hips.position.set(0, 1.45, -0.85);
hips.castShadow = true;
this.registerFadeMesh(hips);
this.model.add(hips);
// Bauchplatten (hell, segmentiert)
for (let i = 0; i < 5; i++) {
const plate = new THREE.Mesh(
new THREE.SphereGeometry(0.58 - i * 0.06, 14, 8),
this.bellyMat
);
plate.scale.set(0.95, 0.5, 0.8);
plate.position.set(0, 1.02 - i * 0.02, 1.15 - i * 0.62);
this.registerFadeMesh(plate);
this.model.add(plate);
}
// Schwanz (geschwungen, mit Zacken und Spadel-Spitze)
this.tailGroup = new THREE.Group();
this.tailGroup.position.set(0, 1.35, -1.5);
this.model.add(this.tailGroup);
for (let i = 0; i < 7; i++) {
const radius = 0.46 - i * 0.055;
const seg = new THREE.Mesh(
new THREE.SphereGeometry(radius, 12, 8),
i % 2 === 0 ? this.skinMat : this.darkMat
);
const y = -0.13 * i + Math.max(0, i - 3) * 0.18;
seg.position.set(0, y, -0.15 - i * 0.45);
seg.scale.set(1, 0.92, 1.15);
this.registerFadeMesh(seg);
this.tailGroup.add(seg);
if (i % 2 === 0 && i < 5) {
const spike = new THREE.Mesh(
new THREE.ConeGeometry(0.09 - i * 0.012, 0.32 - i * 0.03, 5),
this.darkMat
);
spike.position.set(0, y + radius * 0.9 + 0.1, -0.15 - i * 0.45);
spike.rotation.x = -0.3;
this.registerFadeMesh(spike);
this.tailGroup.add(spike);
}
}
const tailTip = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.9, 4), this.darkMat);
tailTip.rotation.x = -Math.PI / 2;
tailTip.scale.x = 0.4;
tailTip.position.set(0, -0.35, -3.3);
this.registerFadeMesh(tailTip);
this.tailGroup.add(tailTip);
// Hals (S-Kurve nach oben)
for (const [y, z, r] of [[1.95, 1.3, 0.46], [2.2, 1.55, 0.4]]) {
const neck = new THREE.Mesh(new THREE.SphereGeometry(r, 14, 10), this.skinMat);
neck.scale.set(0.85, 0.95, 1.1);
neck.position.set(0, y, z);
this.registerFadeMesh(neck);
this.model.add(neck);
}
// Kopf-Gruppe (+Z)
const headGroup = new THREE.Group();
headGroup.position.set(0, 2.4, 1.8);
headGroup.rotation.x = 0.06;
this.model.add(headGroup);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), this.skinMat);
skull.scale.set(0.95, 0.9, 1.15);
skull.castShadow = true;
this.registerFadeMesh(skull);
headGroup.add(skull);
// Schnauze (+Z)
const snout = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.32, 0.7), this.skinMat);
snout.position.set(0, -0.1, 0.72);
this.registerFadeMesh(snout);
headGroup.add(snout);
// Stirnleiste (grimmiger Blick)
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.14, 0.26), this.darkMat);
brow.position.set(0, 0.3, 0.36);
brow.rotation.x = 0.12;
this.registerFadeMesh(brow);
headGroup.add(brow);
// Augen (+Z, ragen aus dem Kopf heraus)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 6), this.eyeMat);
eye.position.set(side * 0.3, 0.2, 0.48);
headGroup.add(eye);
}
// Hörner (nach hinten geschwungen, zwei Paare)
for (const side of [-1, 1]) {
const horn = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.75, 6), this.darkMat);
horn.position.set(side * 0.28, 0.42, -0.12);
horn.rotation.set(-1.15, 0, side * 0.35);
this.registerFadeMesh(horn);
headGroup.add(horn);
const horn2 = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.4, 6), this.darkMat);
horn2.position.set(side * 0.17, 0.3, -0.34);
horn2.rotation.set(-1.3, 0, side * 0.25);
this.registerFadeMesh(horn2);
headGroup.add(horn2);
}
// Ohr-Fransen (seitlich nach hinten)
for (const side of [-1, 1]) {
const frill = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.45, 5), this.darkMat);
frill.position.set(side * 0.48, 0.1, -0.18);
frill.rotation.set(0.35, 0, side * -1.75);
frill.scale.set(1, 1, 0.5);
this.registerFadeMesh(frill);
headGroup.add(frill);
}
// Nüstern
for (const side of [-1, 1]) {
const nostril = new THREE.Mesh(new THREE.SphereGeometry(0.04, 6, 4), this.darkMat);
nostril.position.set(side * 0.1, 0.06, 1.0);
headGroup.add(nostril);
}
// Zähne im Oberkiefer (zeigen nach unten)
for (const side of [-1, 1]) {
for (const z of [0.72, 0.88, 1.02]) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.032, 0.11, 5), boneMat);
tooth.rotation.x = Math.PI;
tooth.position.set(side * 0.15, -0.3, z);
registerBone(tooth);
headGroup.add(tooth);
}
}
// Kiefer (beweglich, öffnet sich beim Feuerspeer)
this.jaw = new THREE.Group();
this.jaw.position.set(0, -0.28, 0.28);
const jawMesh = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.16, 0.62), this.darkMat);
jawMesh.position.set(0, -0.05, 0.3);
this.registerFadeMesh(jawMesh);
this.jaw.add(jawMesh);
for (const side of [-1, 1]) {
for (const z of [0.42, 0.56]) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.028, 0.09, 5), boneMat);
tooth.position.set(side * 0.11, 0.06, z);
registerBone(tooth);
this.jaw.add(tooth);
}
}
this.jaw.rotation.x = 0.12;
headGroup.add(this.jaw);
// Rückenplatten (vom Nacken bis in den Schwanz)
const backPlates: [number, number, number, number][] = [
[2.25, 0.95, 0.15, 0.48],
[2.38, 0.35, 0.19, 0.6],
[2.4, -0.25, 0.2, 0.64],
[2.32, -0.85, 0.17, 0.52],
[1.9, -1.5, 0.14, 0.44],
[1.62, -2.4, 0.12, 0.38],
[1.45, -3.3, 0.1, 0.32],
];
for (const [y, z, r, h] of backPlates) {
const plate = new THREE.Mesh(new THREE.ConeGeometry(r, h, 5), this.darkMat);
plate.position.set(0, y, z);
plate.rotation.x = -0.22;
this.registerFadeMesh(plate);
this.model.add(plate);
}
// Beine (Hinterbeine kräftiger, Vorderbeine schlanker)
for (const side of [-1, 1]) {
// Hinterbein
const thigh = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 10), this.skinMat);
thigh.scale.set(0.75, 1.1, 0.95);
thigh.position.set(side * 0.72, 1.0, -0.8);
this.registerFadeMesh(thigh);
this.model.add(thigh);
const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.19, 0.75, 8), this.darkMat);
shin.position.set(side * 0.8, 0.45, -0.9);
shin.rotation.x = 0.12;
this.registerFadeMesh(shin);
this.model.add(shin);
const hindFoot = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.16, 0.6), this.skinMat);
hindFoot.position.set(side * 0.8, 0.09, -0.78);
this.registerFadeMesh(hindFoot);
this.model.add(hindFoot);
for (const dx of [-0.14, 0, 0.14]) {
const claw = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.2, 5), boneMat);
claw.rotation.x = Math.PI / 2;
claw.position.set(side * 0.8 + dx, 0.09, -0.44);
registerBone(claw);
this.model.add(claw);
}
// Vorderbein
const upperArm = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 8), this.skinMat);
upperArm.scale.set(0.8, 1.15, 0.9);
upperArm.position.set(side * 0.62, 1.05, 0.8);
this.registerFadeMesh(upperArm);
this.model.add(upperArm);
const foreArm = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.15, 0.7, 8), this.darkMat);
foreArm.position.set(side * 0.6, 0.5, 0.85);
this.registerFadeMesh(foreArm);
this.model.add(foreArm);
const foreFoot = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.14, 0.5), this.skinMat);
foreFoot.position.set(side * 0.6, 0.08, 0.95);
this.registerFadeMesh(foreFoot);
this.model.add(foreFoot);
for (const dx of [-0.11, 0, 0.11]) {
const claw = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.18, 5), boneMat);
claw.rotation.x = Math.PI / 2;
claw.position.set(side * 0.6 + dx, 0.08, 1.24);
registerBone(claw);
this.model.add(claw);
}
}
// --- Flügel (Fledermaus-Silhouette mit Membran und Fingerknochen) ---
const wingShape = new THREE.Shape();
wingShape.moveTo(0.2, 0.1);
wingShape.quadraticCurveTo(0.8, -0.12, 1.5, 0.0); // Vorderkante zum Handgelenk
wingShape.quadraticCurveTo(2.5, 0.0, 3.05, 0.95); // zur Flügelspitze
// Hinterkante: Fingerspitzen mit konkaven Bögen dazwischen
wingShape.quadraticCurveTo(2.2, 1.0, 2.7, 1.65);
wingShape.quadraticCurveTo(1.7, 1.45, 2.0, 2.1);
wingShape.quadraticCurveTo(1.05, 1.6, 1.1, 2.25);
wingShape.quadraticCurveTo(0.4, 1.2, 0.15, 1.5);
wingShape.closePath();
const wingGeom = new THREE.ShapeGeometry(wingShape, 10);
const buildWing = (): THREE.Group => {
const wing = new THREE.Group();
const membrane = new THREE.Mesh(wingGeom, this.wingMat);
membrane.rotation.x = -Math.PI / 2;
membrane.castShadow = true;
this.registerFadeMesh(membrane);
wing.add(membrane);
// Flügelknochen: Arm, Außenkante, ein Fingerstrahl
const bones = [
strut(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.5, 0.06, 0), 0.07, 0.05),
strut(new THREE.Vector3(1.5, 0.06, 0), new THREE.Vector3(3.05, 0.1, -0.95), 0.05, 0.028),
strut(new THREE.Vector3(1.3, 0.03, -0.25), new THREE.Vector3(2.0, 0.05, -2.1), 0.04, 0.02),
];
for (const bone of bones) {
this.registerFadeMesh(bone);
wing.add(bone);
}
// Daumenkralle am Handgelenk
const thumb = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.22, 5), boneMat);
thumb.rotation.x = Math.PI / 2;
thumb.position.set(1.55, 0.06, 0.14);
registerBone(thumb);
wing.add(thumb);
return wing;
};
this.wingR = buildWing();
this.wingR.position.set(0.7, 2.25, -0.2);
this.model.add(this.wingR);
this.wingL = buildWing();
this.wingL.position.set(-0.7, 2.25, -0.2);
this.wingL.scale.x = -1; // spiegeln für linke Seite
this.model.add(this.wingL);
// --- Effekte ---
// Boden-Schatten (folgt dem Drachen)
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(2.2, 24), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
// Telegraf-Schatten am Sturz-Ziel (wächst)
this.diveShadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0,
depthWrite: false,
});
this.diveShadow = new THREE.Mesh(new THREE.CircleGeometry(2.4, 24), this.diveShadowMat);
this.diveShadow.rotation.x = -Math.PI / 2;
this.diveShadow.position.y = 0.05;
this.diveShadow.scale.setScalar(0.3);
this.diveShadow.visible = false;
// Feuer-Telegraf (Bogen vor dem Drachen) und Feuer-Kegel
this.telegraphArcMat = new THREE.MeshBasicMaterial({
color: 0xff6644,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const arcGeom = new THREE.RingGeometry(
BREATH_RANGE - 0.8, BREATH_RANGE - 0.2, 48, 1,
-BREATH_HALF_ANGLE - Math.PI / 2, BREATH_HALF_ANGLE * 2
);
this.telegraphArc = new THREE.Mesh(arcGeom, this.telegraphArcMat);
this.telegraphArc.rotation.x = -Math.PI / 2;
this.telegraphArc.position.y = 0.05;
this.telegraphArc.visible = false;
this.body.add(this.telegraphArc);
this.breathGroup = new THREE.Group();
this.breathMat = new THREE.MeshBasicMaterial({
color: 0xff8833,
transparent: true,
opacity: 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const flame = new THREE.Mesh(new THREE.ConeGeometry(1.8, BREATH_RANGE, 16, 1, true), this.breathMat);
flame.rotation.x = Math.PI / 2;
flame.position.z = BREATH_RANGE / 2;
this.breathGroup.add(flame);
// auf Maulhöhe und leicht nach unten geneigt
this.breathGroup.position.y = 3.3;
this.breathGroup.rotation.x = 0.3;
this.breathGroup.visible = false;
this.body.add(this.breathGroup);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.skinMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
this.skinMat.color.set(this.enraged ? 0x992211 : 0xbb4433);
this.darkMat.color.set(this.enraged ? 0x551108 : 0x772a1e);
this.model.scale.setScalar(MODEL_SCALE);
}
// Enrage unter 35% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff2200);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
// Flügel-Flattern (schneller im Flug)
const flapSpeed = this.state === 'fly' || this.state === 'takeoff' ? 9 : 2;
const flap = Math.sin(this.waveTime * flapSpeed) * (this.state === 'fly' ? 0.7 : 0.2);
this.wingL.rotation.z = flap;
this.wingR.rotation.z = -flap;
// Flügel-Einstellwinkel (wegen scale.x-Spiegelung auf beiden Seiten gleich)
this.wingL.rotation.x = 0.3;
this.wingR.rotation.x = 0.3;
// Schwebe-Wippen im Flug und sanftes Schwanz-Schwenken
const airborne = this.state === 'fly' || this.state === 'takeoff';
this.model.position.y = airborne ? Math.sin(this.waveTime * 2.3) * 0.15 : 0;
this.tailGroup.rotation.y = Math.sin(this.waveTime * 1.7) * 0.12;
// Schatten bleibt am Boden
this.shadow.position.y = 0.06 - this.body.position.y;
const shadowScale = 0.6 + this.body.position.y * 0.15;
this.shadow.scale.setScalar(shadowScale);
// Sturz-Telegraf-Schatten
if (this.diveShadowActive) {
if (!this.diveShadowAdded) {
game.scene.add(this.diveShadow);
this.diveShadowAdded = true;
}
const t = 1 - this.stateTime / DIVEWINDUP_TIME;
this.diveShadow.position.set(this.diveTarget.x, 0.05, this.diveTarget.z);
this.diveShadow.scale.setScalar(0.3 + t * 0.9);
this.diveShadowMat.opacity = 0.2 + t * 0.35;
} else {
this.diveShadow.visible = false;
}
switch (this.state) {
case 'fly': this.updateFly(dt, game); break;
case 'divewindup': this.updateDiveWindup(dt, game); break;
case 'dive': this.updateDive(dt, game); break;
case 'diverecover': this.updateDiveRecover(dt); break;
case 'landed': this.updateLanded(dt, game); break;
case 'takeoff': this.updateTakeoff(dt); break;
}
// Feuerbälle
for (let i = this.fireballs.length - 1; i >= 0; i--) {
if (!this.fireballs[i].update(dt, game)) {
this.fireballs[i].dispose();
this.fireballs.splice(i, 1);
}
}
// Aufprall-Blitzflächen
for (let i = this.impactFlashes.length - 1; i >= 0; i--) {
const f = this.impactFlashes[i];
f.life -= dt;
const t = 1 - Math.max(0, f.life) / 0.35;
f.mesh.scale.setScalar(0.5 + t * 7);
f.mat.opacity = 0.7 * (1 - t);
if (f.life <= 0) {
game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
this.impactFlashes.splice(i, 1);
}
}
}
// --- Flug ---
private updateFly(dt: number, game: Game) {
this.stateTime -= dt;
this.body.position.y = FLY_HEIGHT;
// Kreisen über dem Spieler
const playerPos = game.player.body.position;
this.flyAngle += dt * 0.8;
const cx = playerPos.x;
const cz = playerPos.z;
const x = cx + Math.cos(this.flyAngle) * FLY_RADIUS;
const z = cz + Math.sin(this.flyAngle) * FLY_RADIUS;
this.body.position.x = THREE.MathUtils.clamp(x, -WALL_LIMIT + 3, WALL_LIMIT - 3);
this.body.position.z = THREE.MathUtils.clamp(z, -WALL_LIMIT + 3, WALL_LIMIT - 3);
// Flugrichtung anschauen (kreisen)
const lookX = cx + Math.cos(this.flyAngle + 0.5) * FLY_RADIUS;
const lookZ = cz + Math.sin(this.flyAngle + 0.5) * FLY_RADIUS;
this.body.lookAt(lookX, this.body.position.y, lookZ);
// Feuerbälle abwerfen
this.flyFireballTimer -= dt;
if (this.flyFireballTimer <= 0) {
this.flyFireballTimer = FLY_FIREBALL_CD;
this.throwFireballs(game);
}
if (this.stateTime <= 0) {
// Sturzangriff vorbereiten
this.state = 'divewindup';
this.stateTime = this.enraged ? DIVEWINDUP_TIME_2 : DIVEWINDUP_TIME;
this.doubleDiveLeft = this.enraged ? 1 : 0;
this.diveTarget.copy(game.player.body.position);
this.diveTarget.y = 0;
this.game?.clampToArena(this.diveTarget);
this.diveShadow.visible = true;
this.diveShadow.position.set(this.diveTarget.x, 0.05, this.diveTarget.z);
this.diveShadowActive = true;
playSound('spawn', 0.7);
}
}
// --- Sturzangriff ---
private updateDiveWindup(dt: number, game: Game) {
this.stateTime -= dt;
this.body.position.y = FLY_HEIGHT;
// Über dem Ziel schweben
this.body.position.x += (this.diveTarget.x - this.body.position.x) * Math.min(1, dt * 4);
this.body.position.z += (this.diveTarget.z - this.body.position.z) * Math.min(1, dt * 4);
this.body.lookAt(
this.diveTarget.x,
this.body.position.y,
this.diveTarget.z
);
if (this.stateTime <= 0) {
this.diveShadowActive = false;
this.diveShadow.visible = false;
this.state = 'dive';
this.stateTime = DIVE_TIME;
this.diveFrom.copy(this.body.position);
playSound('spawn', 0.9);
}
}
private updateDive(dt: number, game: Game) {
this.stateTime -= dt;
const t = 1 - Math.max(0, this.stateTime) / DIVE_TIME;
const ease = t * t;
this.body.position.x = this.diveFrom.x + (this.diveTarget.x - this.diveFrom.x) * ease;
this.body.position.z = this.diveFrom.z + (this.diveTarget.z - this.diveFrom.z) * ease;
this.body.position.y = FLY_HEIGHT * (1 - ease);
if (this.stateTime <= 0) {
this.body.position.y = 0.15;
this.diveImpact(game);
}
}
private diveImpact(game: Game) {
// Aufprall: Schock + Schaden (überspringbar: nur am Boden)
game.triggerCameraShake(0.4, 0.5);
playSound('explosion', 0.8);
const flashMat = new THREE.MeshBasicMaterial({
color: 0xff8844,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat);
flash.rotation.x = -Math.PI / 2;
flash.position.set(this.body.position.x, 0.07, this.body.position.z);
game.scene.add(flash);
this.impactFlashes.push({ mesh: flash, mat: flashMat, life: 0.35 });
if (game.player.health > 0 && game.player.body.position.y <= 0.8) {
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) < DIVE_IMPACT_RADIUS) {
game.player.damage(DIVE_DAMAGE, this.body.position);
}
}
if (this.doubleDiveLeft > 0) {
this.doubleDiveLeft--;
this.state = 'diverecover';
this.stateTime = DIVERECOVER_TIME;
} else {
this.state = 'landed';
this.stateTime = this.enraged ? LANDED_TIME_2 : LANDED_TIME;
this.breathPhase = 'idle';
this.breathHit = false;
this.breathActive = false;
}
}
private updateDiveRecover(dt: number) {
this.stateTime -= dt;
if (this.stateTime <= 0) {
// Doppel-Sturz: neues Ziel = aktuelle Spielerposition
this.state = 'divewindup';
this.stateTime = this.enraged ? DIVEWINDUP_TIME_2 : DIVEWINDUP_TIME;
const p = this.game?.player.body.position;
if (p) {
this.diveTarget.set(p.x, 0, p.z);
this.game?.clampToArena(this.diveTarget);
}
this.diveShadow.visible = true;
this.diveShadowActive = true;
}
}
// --- Gelandet (Angriffs-Fenster) ---
private updateLanded(dt: number, game: Game) {
this.stateTime -= dt;
this.body.position.y = 0.15;
// Zum Spieler drehen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, 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
);
}
// Feuerspeer: Windup -> aktiv -> fertig
if (this.breathPhase === 'idle') {
this.breathPhase = 'windup';
this.breathTime = BREATH_WINDUP;
this.breathHit = false;
this.telegraphArc.visible = true;
playSound('spawn', 0.6);
} else if (this.breathPhase === 'windup') {
this.breathTime -= dt;
(this.telegraphArc.material as THREE.MeshBasicMaterial).opacity =
0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 12));
if (this.breathTime <= 0) {
this.breathPhase = 'active';
this.breathTime = BREATH_TIME;
this.telegraphArc.visible = false;
this.breathGroup.visible = true;
this.breathActive = true;
this.jaw.rotation.x = 0.55;
}
} else if (this.breathPhase === 'active') {
this.breathTime -= dt;
const t = 1 - this.breathTime / BREATH_TIME;
this.breathMat.opacity = 0.35 + Math.sin(t * Math.PI) * 0.4;
this.breathGroup.scale.z = 0.8 + Math.sin(t * Math.PI * 2) * 0.15;
// Treffer in der Mitte des Feuers
if (!this.breathHit && t >= 0.4) {
this.breathHit = true;
const dx = playerPos.x - this.body.position.x;
const dz = playerPos.z - this.body.position.z;
const dist = Math.hypot(dx, dz);
if (dist < BREATH_RANGE && playerPos.y <= 0.6) {
const ang = Math.atan2(dx, dz);
const yaw = this.body.rotation.y;
let diff = ang - yaw;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
if (Math.abs(diff) < BREATH_HALF_ANGLE) {
game.player.damage(BREATH_DAMAGE, this.body.position);
playSound('explosion', 0.7);
}
}
}
if (this.breathTime <= 0) {
this.breathPhase = 'done';
this.breathGroup.visible = false;
this.breathMat.opacity = 0;
this.jaw.rotation.x = 0.12;
}
}
if (this.stateTime <= 0) {
this.state = 'takeoff';
this.stateTime = TAKEOFF_TIME;
this.telegraphArc.visible = false;
this.breathGroup.visible = false;
playSound('spawn', 0.6);
}
}
// --- Abheben ---
private updateTakeoff(dt: number) {
this.stateTime -= dt;
const t = 1 - Math.max(0, this.stateTime) / TAKEOFF_TIME;
this.body.position.y = 0.15 + t * TAKEOFF_HEIGHT;
if (this.stateTime <= 0) {
this.state = 'fly';
this.stateTime = this.enraged ? FLY_TIME_2 : FLY_TIME;
this.flyFireballTimer = 1.2;
this.breathPhase = 'idle';
}
}
// --- Feuerbälle (Flug) ---
private throwFireballs(game: Game) {
const from = new THREE.Vector3(this.body.position.x, this.body.position.y - 2, this.body.position.z);
const target = game.player.body.position.clone();
const count = this.enraged ? FIREBALL_COUNT_2 : FIREBALL_COUNT;
const baseDir = new THREE.Vector3().subVectors(target, from);
baseDir.y = 0;
const horizDist = baseDir.length();
baseDir.normalize();
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
// Ballistisches Zielen: Abwärtsgeschwindigkeit so wählen, dass der Ball
// die Spielerhöhe genau beim Überflug erreicht (sonst segeln sie drüber).
const flightTime = Math.max(0.3, horizDist / FIREBALL_SPEED);
const dropVy = (target.y - from.y + 0.5 * FIREBALL_GRAVITY * flightTime * flightTime) / flightTime;
for (let i = 0; i < count; i++) {
const off = (i / (count - 1) - 0.5) * 2 * FIREBALL_SPREAD;
const dir = new THREE.Vector3()
.addScaledVector(baseDir, 1)
.addScaledVector(perp, off)
.normalize()
.multiplyScalar(FIREBALL_SPEED);
dir.y = dropVy;
const fb = new DragonFireball(from.clone(), dir);
game.scene.add(fb.body);
this.fireballs.push(fb);
}
playSound('spawn', 0.5);
}
// --- Schaden ---
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
// Im Flug unverwundbar: nur am Boden (landed) verletzbar
if (this.state !== 'landed') return;
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
for (const fb of this.fireballs) fb.dispose();
this.fireballs = [];
if (this.game) {
for (const f of this.impactFlashes) {
this.game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
}
}
this.impactFlashes = [];
this.diveShadowActive = false;
this.diveShadow.visible = false;
if (this.diveShadowAdded && this.game) {
this.game.scene.remove(this.diveShadow);
}
this.diveShadowAdded = false;
this.telegraphArc.visible = false;
this.breathGroup.visible = false;
this.skinMat.color.set(0xbb4433);
this.darkMat.color.set(0x772a1e);
this.eyeMat.color.set(0xffcc33);
this.model.scale.setScalar(MODEL_SCALE);
}
}
// Feuerball: gerades Projektil mit leichter Gravitation
class DragonFireball {
body: THREE.Group;
private vel: THREE.Vector3;
private life = 4;
dead = false;
constructor(from: THREE.Vector3, dir: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.copy(from);
this.vel = dir.clone();
const glowMat = new THREE.MeshBasicMaterial({
color: 0xffaa33,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.45, 12, 12), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshBasicMaterial({
color: 0xffdd55,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const core = new THREE.Mesh(new THREE.SphereGeometry(0.24, 10, 10), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
// Homing: sanft in Richtung des aktuellen Spielerstandorts lenken,
// damit die Bälle nicht über den Kopf segeln. Der begrenzte Lenkwinkel
// (FIREBALL_HOMING als Lerp-Rate) hält sie ausweichbar.
if (game.player.health > 0) {
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position)
.normalize();
const desired = toPlayer.multiplyScalar(FIREBALL_SPEED);
this.vel.lerp(desired, Math.min(1, dt * FIREBALL_HOMING));
}
this.body.position.addScaledVector(this.vel, dt);
if (this.body.position.y < 0.15) {
this.body.position.y = 0.15;
}
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < FIREBALL_HIT_RADIUS) {
game.player.damage(FIREBALL_DAMAGE, this.body.position);
this.dead = true;
return false;
}
}
if (this.life <= 0) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+692
View File
@@ -0,0 +1,692 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const MAX_HEALTH = 10500;
const MAX_ARMOR = 2500;
const ARMOR_REGEN_TIME = 10;
const ARMOR_REDUCTION = 0.7;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 2.0;
const MOVE_SPEED = 3.5;
const BURST_COOLDOWN = 7;
const BURST_COOLDOWN_2 = 5;
const BURST_MARK_COUNT = 8;
const BURST_MARK_COUNT_2 = 12;
const BURST_RING2_RADIUS = 13;
const BURST_MARK_RADIUS = 2.8;
const BURST_TELEGRAPH = 1.6;
const BURST_STAGGER = 0.18;
const BURST_DAMAGE = 22;
const BURST_RADIUS = 9;
const RAIN_COOLDOWN = 5;
const RAIN_COOLDOWN_2 = 4;
const RAIN_COUNT = 7;
const RAIN_COUNT_2 = 10;
const STOMP_COOLDOWN = 10;
const STOMP_COOLDOWN_2 = 8;
const STOMP_RADIUS = 11;
const STOMP_DAMAGE = 15;
const ENRAGE_RATIO = 0.35;
const ICICLE_DAMAGE = 30;
const ICICLE_TELEGRAPH = 0.9;
const ICICLE_IMPACT_RADIUS = 1.8;
export class FrostGiant extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private enraged = false;
private hitFlash = 0;
private skinMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private eyeMat!: THREE.MeshBasicMaterial;
private armorShards: THREE.Mesh[] = [];
private armorShardMats: THREE.MeshToonMaterial[] = [];
private armorBroken = false;
private armorHp = MAX_ARMOR;
private armorRegenTimer = 0;
private armorGrow = 0;
private burstTimer = 3;
private rainTimer = 2;
private stompTimer = 6;
private burstMarks: {
group: THREE.Group;
ringMat: THREE.MeshBasicMaterial;
discMat: THREE.MeshBasicMaterial;
index: number;
exploded: boolean;
explAt: number;
}[] = [];
private burstActive = false;
private burstExplodeStart = 0;
private burstStartTime = 0;
private burstFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
private icicles: FallingIcicle[] = [];
private shockRing!: THREE.Mesh;
private shockRingMat!: THREE.MeshBasicMaterial;
private shockRingLife = 0;
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Frostriese';
this.speed = MOVE_SPEED;
this.xp = 280;
this.contactDps = 35;
this.contactRadius = 3.2;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
this.fadeOutModel = this.model;
this.body.add(this.model);
this.skinMat = new THREE.MeshToonMaterial({ color: 0x77bbdd });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x4477aa });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0x88ddff });
const crystalMat = new THREE.MeshToonMaterial({ color: 0xccf0ff, emissive: 0x223344 });
const metalMat = new THREE.MeshToonMaterial({ color: 0x5599bb });
// Beine
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.45, 1.2, 10), this.darkMat);
leg.position.set(side * 0.55, -0.6, 0);
leg.castShadow = true;
this.registerFadeMesh(leg);
this.model.add(leg);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.25, 0.9), this.skinMat);
foot.position.set(side * 0.55, -1.25, 0.1);
this.registerFadeMesh(foot);
this.model.add(foot);
}
// Torso
const torso = new THREE.Mesh(new THREE.BoxGeometry(2.0, 2.2, 1.4), this.skinMat);
torso.position.y = 0.9;
torso.castShadow = true;
this.registerFadeMesh(torso);
this.model.add(torso);
// Bauchpanzer
const belly = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.9, 0.1), metalMat);
belly.position.set(0, 0.55, 0.72);
this.registerFadeMesh(belly);
this.model.add(belly);
// Schultern
for (const side of [-1, 1]) {
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.7, 12, 10), this.skinMat);
shoulder.position.set(side * 1.3, 1.7, 0);
shoulder.castShadow = true;
this.registerFadeMesh(shoulder);
this.model.add(shoulder);
// Arme
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.42, 1.4, 8), this.darkMat);
arm.position.set(side * 1.6, 0.6, 0);
this.registerFadeMesh(arm);
this.model.add(arm);
// Faust
const fist = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), this.skinMat);
fist.position.set(side * 1.7, 0.0, 0.2);
this.registerFadeMesh(fist);
this.model.add(fist);
}
// Kopf
const head = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.9, 0.95), this.skinMat);
head.position.y = 2.5;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Frostbart (+Z)
const beard = new THREE.Mesh(new THREE.ConeGeometry(0.45, 0.7, 8), this.darkMat);
beard.position.set(0, 1.95, 0.5);
beard.rotation.x = Math.PI;
this.registerFadeMesh(beard);
this.model.add(beard);
// Augen (+Z)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), this.eyeMat);
eye.position.set(side * 0.3, 2.62, 0.5);
this.model.add(eye);
}
// Eiskristall-Panzer (verdeckt den Rumpf, solange die Rüstung hält)
const shardPositions: [number, number, number, number][] = [
[0, 1.5, 0.75, 0],
[-0.7, 1.3, 0.7, 0.4],
[0.7, 1.3, 0.7, -0.4],
[0, 2.1, 0.7, 0],
[-1.4, 1.7, 0, 0.8],
[1.4, 1.7, 0, -0.8],
[-0.6, 0.7, 0.75, 0.3],
[0.6, 0.7, 0.75, -0.3],
[-0.3, 2.6, 0.5, 0.5],
[0.3, 2.6, 0.5, -0.5],
];
for (const [x, y, z, rotY] of shardPositions) {
const mat = crystalMat.clone();
const shard = new THREE.Mesh(new THREE.ConeGeometry(0.35, 0.9, 6), mat);
shard.position.set(x, y, z);
shard.rotation.set(-0.15, rotY, 0);
this.registerFadeMesh(shard);
this.model.add(shard);
this.armorShards.push(shard);
this.armorShardMats.push(mat);
}
// Schockwellen-Ring
this.shockRingMat = new THREE.MeshBasicMaterial({
color: 0x88ccff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.shockRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.0, 48), this.shockRingMat);
this.shockRing.rotation.x = -Math.PI / 2;
this.shockRing.position.y = 0.06;
this.shockRing.visible = false;
this.body.add(this.shockRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.skinMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
const dark = this.armorBroken;
this.skinMat.color.set(dark ? 0x4477aa : 0x77bbdd);
this.darkMat.color.set(dark ? 0x2a4a6a : 0x4477aa);
this.model.scale.setScalar(MODEL_SCALE);
}
// Enrage unter 35% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff4422);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
// Rüstungs-Regeneration
if (this.armorBroken) {
this.armorRegenTimer -= dt;
const t = 1 - Math.max(0, this.armorRegenTimer) / ARMOR_REGEN_TIME;
// Kristalle wachsen langsam nach, kurz vor Abschluss deutlich sichtbar
this.armorGrow = Math.min(1, t * 1.6);
const showScale = this.armorGrow;
for (const shard of this.armorShards) shard.visible = showScale > 0.02;
for (let i = 0; i < this.armorShards.length; i++) {
this.armorShards[i].scale.setScalar(showScale);
this.armorShardMats[i].opacity = Math.min(1, showScale * 2);
}
if (this.armorRegenTimer <= 0) {
this.armorBroken = false;
this.armorHp = MAX_ARMOR;
this.armorRegenTimer = 0;
for (const shard of this.armorShards) shard.scale.setScalar(1);
for (const mat of this.armorShardMats) mat.opacity = 1;
playSound('spawn', 0.7);
}
}
// Zum Spieler laufen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(MOVE_SPEED);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-MOVE_SPEED);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Angriffe
this.burstTimer -= dt;
if (this.burstTimer <= 0 && !this.burstActive) {
this.burstTimer = this.enraged ? BURST_COOLDOWN_2 : BURST_COOLDOWN;
this.startBurst(game);
}
this.rainTimer -= dt;
if (this.rainTimer <= 0) {
this.rainTimer = this.enraged ? RAIN_COOLDOWN_2 : RAIN_COOLDOWN;
this.icicleRain(game);
}
this.stompTimer -= dt;
if (this.stompTimer <= 0) {
this.stompTimer = this.enraged ? STOMP_COOLDOWN_2 : STOMP_COOLDOWN;
this.stomp(game);
}
this.updateBurst(dt, game);
this.updateBurstFlashes(dt);
// Schockwellen-Ring expandieren
if (this.shockRing.visible) {
const s = this.shockRing.scale.x + dt * 26;
this.shockRing.scale.setScalar(s);
this.shockRingMat.opacity = Math.max(0, 0.5 - (s / STOMP_RADIUS) * 0.5);
if (s >= STOMP_RADIUS) this.shockRing.visible = false;
}
// Eiszapfen
for (let i = this.icicles.length - 1; i >= 0; i--) {
if (!this.icicles[i].update(dt, game)) {
this.icicles[i].dispose();
this.icicles.splice(i, 1);
}
}
}
// --- Eispanzer ---
private breakArmor(game: Game) {
this.armorBroken = true;
this.armorRegenTimer = ARMOR_REGEN_TIME;
this.armorGrow = 0;
for (const shard of this.armorShards) shard.visible = false;
for (const mat of this.armorShardMats) mat.opacity = 0;
game.spawnDamageText('PANZER WEG!', this.body.position, '#88ccff');
game.triggerCameraShake(0.4, 0.5);
playSound('explosion', 0.9);
}
// --- Eisberst-Ring ---
private startBurst(game: Game) {
this.burstActive = true;
this.burstExplodeStart = BURST_TELEGRAPH;
this.burstStartTime = this.waveTime;
this.burstMarks = [];
const count = this.enraged ? BURST_MARK_COUNT_2 : BURST_MARK_COUNT;
const offset = Math.random() * Math.PI * 2;
const rings = this.enraged
? [{ radius: BURST_RADIUS, count: BURST_MARK_COUNT }]
: [{ radius: BURST_RADIUS, count }];
if (this.enraged) {
rings.push({ radius: BURST_RING2_RADIUS, count: BURST_MARK_COUNT - 2 });
}
const cx = this.body.position.x;
const cz = this.body.position.z;
let index = 0;
for (const ring of rings) {
for (let i = 0; i < ring.count; i++) {
const a = offset + (i / ring.count) * Math.PI * 2;
const ringMat = new THREE.MeshBasicMaterial({
color: 0x66ccff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const discMat = new THREE.MeshBasicMaterial({
color: 0x4488ff,
transparent: true,
opacity: 0,
depthWrite: false,
});
const ringMesh = new THREE.Mesh(
new THREE.RingGeometry(BURST_MARK_RADIUS - 0.15, BURST_MARK_RADIUS + 0.15, 32),
ringMat
);
ringMesh.rotation.x = -Math.PI / 2;
ringMesh.position.y = 0.05;
const disc = new THREE.Mesh(
new THREE.CircleGeometry(BURST_MARK_RADIUS, 32),
discMat
);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.03;
const group = new THREE.Group();
group.add(ringMesh, disc);
group.position.set(
cx + Math.cos(a) * ring.radius,
0,
cz + Math.sin(a) * ring.radius
);
game.scene.add(group);
this.burstMarks.push({ group, ringMat, discMat, index, exploded: false, explAt: 0 });
index++;
}
}
playSound('spawn', 0.7);
}
private updateBurst(dt: number, game: Game) {
if (!this.burstActive) return;
if (this.burstExplodeStart > 0) {
// Telegraf: Marken pulsieren
this.burstExplodeStart -= dt;
const pulse = Math.abs(Math.sin(this.waveTime * 9));
for (const mark of this.burstMarks) {
mark.ringMat.opacity = 0.3 + 0.5 * pulse;
mark.discMat.opacity = 0.12 + 0.08 * pulse;
}
if (this.burstExplodeStart <= 0) {
// Explosionsfenster: nacheinander
this.burstExplodeStart = -1;
}
return;
}
let allDone = true;
for (const mark of this.burstMarks) {
if (!mark.exploded) {
allDone = false;
const t = (this.waveTime - this.burstStartTime) - (mark.index * BURST_STAGGER);
if (t >= 0) {
mark.exploded = true;
this.explodeMark(mark, game);
}
}
}
if (allDone) {
this.burstActive = false;
for (const mark of this.burstMarks) {
game.scene.remove(mark.group);
mark.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
this.burstMarks = [];
}
}
private explodeMark(mark: { group: THREE.Group }, game: Game) {
// Expandierender Frost-Ring
const flashMat = new THREE.MeshBasicMaterial({
color: 0x99ddff,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat);
flash.rotation.x = -Math.PI / 2;
flash.position.set(mark.group.position.x, 0.07, mark.group.position.z);
game.scene.add(flash);
this.burstFlashes.push({ mesh: flash, mat: flashMat, life: 0.35 });
// Schaden (überspringbar: nur am Boden)
if (game.player.health > 0 && game.player.body.position.y <= 0.8) {
const dx = game.player.body.position.x - mark.group.position.x;
const dz = game.player.body.position.z - mark.group.position.z;
if (Math.hypot(dx, dz) < BURST_MARK_RADIUS + 0.3) {
game.player.damage(BURST_DAMAGE, mark.group.position);
}
}
playSound('explosion', 0.4);
}
private updateBurstFlashes(dt: number) {
for (let i = this.burstFlashes.length - 1; i >= 0; i--) {
const f = this.burstFlashes[i];
f.life -= dt;
const t = 1 - Math.max(0, f.life) / 0.35;
f.mesh.scale.setScalar(0.5 + t * 6);
f.mat.opacity = 0.7 * (1 - t);
if (f.life <= 0) {
this.game?.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
this.burstFlashes.splice(i, 1);
}
}
}
// --- Eiszapfen-Regen ---
private icicleRain(game: Game) {
const playerPos = game.player.body.position;
const count = this.enraged ? RAIN_COUNT_2 : RAIN_COUNT;
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = 2 + Math.random() * 6;
const icicle = new FallingIcicle(new THREE.Vector3(
playerPos.x + Math.cos(a) * r,
0,
playerPos.z + Math.sin(a) * r
));
game.scene.add(icicle.body);
this.icicles.push(icicle);
}
playSound('spawn', 0.5);
}
// --- Stampf ---
private stomp(game: Game) {
this.shockRing.visible = true;
this.shockRing.scale.setScalar(0.2);
this.shockRingMat.opacity = 0.5;
playSound('explosion', 0.7);
const playerPos = game.player.body.position;
const dist = Math.hypot(
playerPos.x - this.body.position.x,
playerPos.z - this.body.position.z
);
// Überspringbar: nur Treffer am Boden
if (dist < STOMP_RADIUS && playerPos.y <= 0.6) {
game.player.damage(STOMP_DAMAGE, this.body.position);
}
game.triggerCameraShake(0.3, 0.3);
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
if (this.armorBroken === false && this.armorShards.length > 0) {
const absorbed = Math.min(MAX_ARMOR, damage * ARMOR_REDUCTION);
damage *= 1 - ARMOR_REDUCTION;
game.spawnDamageText('Eispanzer!', this.body.position, '#99ccff');
// Rüstung abbauen: Kristalle werden mit sinkender Armor-HP blasser
const pct = 1 - this.armorHp / MAX_ARMOR;
for (let i = 0; i < this.armorShards.length; i++) {
const shard = this.armorShards[i];
const threshold = (i + 1) / this.armorShards.length;
shard.visible = pct < threshold;
if (!shard.visible) {
this.armorShardMats[i].opacity = 0;
}
}
this.armorHp -= absorbed;
if (this.armorHp <= 0) {
this.armorHp = 0;
this.breakArmor(game);
}
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
this.burstActive = false;
if (this.game) {
for (const mark of this.burstMarks) {
this.game.scene.remove(mark.group);
mark.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
for (const f of this.burstFlashes) {
this.game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
}
}
this.burstMarks = [];
this.burstFlashes = [];
for (const icicle of this.icicles) icicle.dispose();
this.icicles = [];
this.shockRing.visible = false;
this.skinMat.color.set(0x77bbdd);
this.darkMat.color.set(0x4477aa);
this.eyeMat.color.set(0x88ddff);
this.model.scale.setScalar(MODEL_SCALE);
for (const shard of this.armorShards) shard.visible = true;
for (const mat of this.armorShardMats) mat.opacity = 1;
}
}
// Eiszapfen: fällt mit wachsendem Schatten-Telegrafen vom Himmel
class FallingIcicle {
body: THREE.Group;
private shadow: THREE.Mesh;
private icicle: THREE.Mesh;
private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph';
private timer = ICICLE_TELEGRAPH;
private dust: THREE.Mesh[] = [];
private dustDirs: THREE.Vector3[] = [];
private dustMat: THREE.MeshBasicMaterial;
constructor(pos: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.set(pos.x, 0, pos.z);
const shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
depthWrite: false,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.position.y = 0.06;
this.shadow.scale.setScalar(0.4);
this.body.add(this.shadow);
const iceMat = new THREE.MeshToonMaterial({ color: 0xbbeeff, emissive: 0x334455 });
this.icicle = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.6, 6), iceMat);
this.icicle.position.y = 16;
this.icicle.rotation.x = Math.PI;
this.body.add(this.icicle);
this.dustMat = new THREE.MeshBasicMaterial({
color: 0x99bbdd,
transparent: true,
opacity: 0,
depthWrite: false,
});
}
update(dt: number, game: Game): boolean {
this.timer -= dt;
if (this.phase === 'telegraph') {
const t = 1 - this.timer / ICICLE_TELEGRAPH;
this.shadow.scale.setScalar(0.4 + t * 0.6);
(this.shadow.material as THREE.MeshBasicMaterial).opacity = 0.2 + t * 0.2;
if (this.timer <= 0) {
this.phase = 'fall';
this.timer = 2;
}
} else if (this.phase === 'fall') {
this.icicle.position.y -= 30 * dt;
this.icicle.rotation.z += dt * 2;
if (this.icicle.position.y <= 0.15) {
this.icicle.position.y = 0.15;
this.icicle.visible = false;
this.shadow.visible = false;
if (game.player.health > 0) {
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) < ICICLE_IMPACT_RADIUS) {
game.player.damage(ICICLE_DAMAGE, this.body.position);
}
}
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2;
const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a));
const puff = new THREE.Mesh(new THREE.SphereGeometry(0.25, 6, 5), this.dustMat);
this.body.add(puff);
this.dust.push(puff);
this.dustDirs.push(dir);
}
this.phase = 'dust';
this.timer = 0.4;
}
} else {
const t = 1 - this.timer / 0.4;
for (let i = 0; i < this.dust.length; i++) {
this.dust[i].position.copy(this.dustDirs[i]).multiplyScalar(0.5 + t * 1.8);
this.dust[i].position.y = 0.2 + t * 0.8;
}
this.dustMat.opacity = 0.5 * (1 - t);
if (this.timer <= 0) return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
this.body.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
}
+395 -27
View File
@@ -11,8 +11,21 @@ import { Brute } from './Brute';
import { Golem } from './Golem';
import { Krake } from './Krake';
import { Minotaurus } from './Minotaurus';
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';
import { Archer } from './Archer';
import { Bomber } from './Bomber';
import { Wisp } from './Wisp';
import { Turtle } from './Turtle';
import { SporeMushroom } from './SporeMushroom';
import { EnemyProjectile } from './EnemyProjectile';
import { Sword } from './Sword';
import { Staff } from './Staff';
@@ -78,11 +91,46 @@ export class Game {
spawns = 0;
killed = 0;
// Stationäre Gegner, die zu lange außerhalb des Bildschirms sind, werden
// wieder in die Nähe des Spielers teleportiert (Off-Screen-Reset).
private offscreenTimers = new Map<Enemy, number>();
private static readonly OFFSCREEN_REPOSITION_TIME = 8;
private static readonly SPAWN_VIEW_MIN = 10;
private static readonly SPAWN_VIEW_MAX = 24;
// Arena-Theme: eine Stufe pro besiegten Boss
private arena!: Arena;
private arenaStage = 0;
private themeFogTarget = new THREE.Color(0x0d1117);
private themeLightTarget = new THREE.Color(0xffeebb);
private static readonly THEME_LIGHTS = [
0xffeebb, 0xffddaa, 0x99ccff, 0xffbb99, 0xbbffcc, 0xbbddff, 0xddbbff, 0xffcc88,
];
// Labyrinth-Zwischenboss (Welle 5, 15, 25, ...): ersetzt die Welle komplett
maze: Maze | null = null;
private mazeActive = false;
private mazeTimer = 0;
private mazeTotal = 1;
private mazeLevel = 0;
private mazeGoalsLeft = 0;
private mazeGnome: Gnome | null = null;
private mazeGuards: MazeGuard[] = [];
private uiMazeArrow!: HTMLElement;
private static readonly MAZE_WAVES = [5, 15, 25, 35, 45, 55, 65];
private static readonly MAZE_BASE_TIME = 75;
private static readonly MAZE_TIME_STEP = 5;
private static readonly MAZE_MIN_TIME = 50;
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
[10, { name: 'Golem', spawn: () => new Golem() }],
[20, { name: 'Krake', spawn: () => new Krake() }],
[30, { name: 'Minotaurus', spawn: () => new Minotaurus() }],
[40, { name: 'Spinnenkönigin', spawn: () => new SpiderQueen() }],
[50, { name: 'Frostriese', spawn: () => new FrostGiant() }],
[60, { name: 'Erz-Nekromant', spawn: () => new Necromancer() }],
[70, { name: 'Drache', spawn: () => new Dragon() }],
]);
private bossFightActive = false;
private shakeTime = 0;
@@ -219,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')!;
@@ -309,7 +358,8 @@ export class Game {
return;
}
if (e.code === 'Space' && this.state === 'playing'
&& this.player.pullTime <= 0 && this.player.stunTime <= 0) {
&& this.player.pullTime <= 0 && this.player.stunTime <= 0
&& this.player.rootTime <= 0) {
this.player.jump();
e.preventDefault();
}
@@ -433,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() {
@@ -576,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() {
@@ -611,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;
@@ -624,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();
@@ -756,6 +976,19 @@ export class Game {
this.spawns = 0;
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();
@@ -877,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();
@@ -922,8 +1156,13 @@ export class Game {
}
private spawnEnemy() {
const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80;
// Immer im Sichtbereich spawnen (Ring um den Spieler), damit auch
// stationäre Gegner nicht auf der Arena gesucht werden müssen.
const p = this.player.body.position;
const a = Math.random() * Math.PI * 2;
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
const x = Math.max(-46, Math.min(46, p.x + Math.cos(a) * r));
const z = Math.max(-46, Math.min(46, p.z + Math.sin(a) * r));
const weights = this.enemyWeights(this.spawns);
let total = 0;
@@ -948,24 +1187,52 @@ export class Game {
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
case 'turret': this.spawnEnemyInstance(new Turret(), x, z, 0.1); break;
case 'shaman': this.spawnEnemyInstance(new Shaman(), x, z, 0.1); break;
case 'archer': this.spawnEnemyInstance(new Archer(), x, z, 0.1); break;
case 'bomber': this.spawnEnemyInstance(new Bomber(), x, z, 0.1); break;
case 'wisp': this.spawnEnemyInstance(new Wisp(), x, z, 1.0); break;
case 'turtle': this.spawnEnemyInstance(new Turtle(), x, z, 0.1); break;
case 'spore': this.spawnEnemyInstance(new SporeMushroom(), x, z, 0.1); break;
}
}
private enemyWeights(wave: number): Map<string, number> {
const w = new Map<string, number>();
if (wave >= 7) {
w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10)
.set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5);
} else if (wave >= 6) {
w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10)
.set('bat', 10).set('turret', 10);
} else if (wave >= 5) {
w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10)
.set('bat', 10);
} else if (wave >= 4) {
w.set('spider', 55).set('ghost', 25).set('frog', 20);
// 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);
}
@@ -980,13 +1247,33 @@ export class Game {
const entry = this.bossWaves.get(wave);
if (!entry) return;
const enemy = entry.spawn();
const x = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
const z = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
let x = 0;
let z = 0;
if (enemy.spawnAtCenter) {
// Stationäre Bosse (Krake, Spinnenkönigin) spawnen in der Arena-Mitte
} else {
// Alle anderen Bosse im Sichtbereich spawnen
const p = this.player.body.position;
const a = Math.random() * Math.PI * 2;
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
x = Math.max(-46, Math.min(46, p.x + Math.cos(a) * r));
z = Math.max(-46, Math.min(46, p.z + Math.sin(a) * r));
}
this.spawnEnemyInstance(enemy, x, z, 0.15);
playSound('spawn', 0.8);
}
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);
@@ -1053,6 +1340,9 @@ export class Game {
if (p.stunTime > 0) {
// Gestunnt (Stampf-Schockwelle): keine Eingaben
p.velocity.set(0, 0, 0);
} else if (p.rootTime > 0) {
// Netz-Fessel (Spinnenkönigin): keine Bewegung, Angriffe bleiben möglich
p.velocity.set(0, 0, 0);
} else if (p.pullTime > 0) {
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
p.pullTime -= dt;
@@ -1083,7 +1373,7 @@ export class Game {
}
// Jump / gravity
if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0) {
if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0 && p.rootTime <= 0) {
p.jumpVelocity -= 20 * dt;
p.body.position.y += p.jumpVelocity * dt;
if (p.body.position.y <= 0.5) {
@@ -1098,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,
@@ -1111,6 +1406,48 @@ export class Game {
pos.z = Math.max(-47, Math.min(47, pos.z));
}
// 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);
continue;
}
if (!enemy.immovable) continue;
if (this.isOnScreen(enemy.body.position)) {
this.offscreenTimers.delete(enemy);
} else {
const t = (this.offscreenTimers.get(enemy) ?? 0) + dt;
if (t >= Game.OFFSCREEN_REPOSITION_TIME) {
this.offscreenTimers.delete(enemy);
this.repositionToView(enemy);
} else {
this.offscreenTimers.set(enemy, t);
}
}
}
}
// NDC-Projektion mit Toleranz: |x|,|y| < 1.15 gilt als im Bild
private isOnScreen(pos: THREE.Vector3): boolean {
const v = pos.clone().project(this.camera);
return v.x > -1.15 && v.x < 1.15 && v.y > -1.15 && v.y < 1.15 && v.z < 1;
}
private repositionToView(enemy: Enemy) {
const p = this.player.body.position;
const a = Math.random() * Math.PI * 2;
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
enemy.body.position.set(
Math.max(-46, Math.min(46, p.x + Math.cos(a) * r)),
enemy.body.position.y,
Math.max(-46, Math.min(46, p.z + Math.sin(a) * r))
);
playSound('spawn', 0.5);
}
private updateEnemies(dt: number) {
for (const enemy of this.enemies) {
if (enemy.dead || this.state !== 'playing') {
@@ -1235,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)) {
@@ -1253,6 +1593,8 @@ export class Game {
this.uiLowHpVignette.classList.toggle(
'active', healthPct < 0.3 && healthPct > 0
);
this.updateMazeArrow();
}
triggerInkVignette() {
@@ -1357,9 +1699,17 @@ export class Game {
this.updateGroundPoint();
this.updatePlayerMovement(dt);
this.updateEnemies(dt);
this.updateOffscreenReposition(dt);
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)) {
@@ -1388,6 +1738,10 @@ export class Game {
// Spawn enemies in waves
if (this.state === 'playing') {
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
@@ -1395,6 +1749,14 @@ export class Game {
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)) {
@@ -1405,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++) {
@@ -1416,6 +1783,7 @@ export class Game {
this.spawnTimer += dt;
}
}
}
// Check close encounters (enemy touching player)
if (this.state === 'playing') {
+5
View File
@@ -198,6 +198,11 @@ export class Ghost extends Enemy {
}
// Chase the player (stays hittable and dangerous even while invisible)
this.chase(dt, game);
}
// Getrennt für Subklassen (z. B. MazeGuard: erst aktiv, wenn der Spieler nah ist)
protected chase(dt: number, game: Game) {
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
+142
View File
@@ -0,0 +1,142 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const HIT_FLASH_TIME = 0.14;
// Gnom: das Ziel im Labyrinth. Stationär, nur mit Sichtlinie (durch die
// Wände hindurch) verletzbar -> man muss wirklich zu ihm hingehen.
export class Gnome extends Enemy {
private model: THREE.Group;
private skinMat!: THREE.MeshToonMaterial;
private hatMat!: THREE.MeshToonMaterial;
private beardMat!: THREE.MeshToonMaterial;
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor(hp: number, xp: number) {
super();
this.health = hp;
this.maxHealth = hp;
this.isBoss = true;
this.displayName = 'Gnom';
this.speed = 0;
this.xp = xp;
this.contactDps = 0;
this.contactRadius = 0;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.skinMat = new THREE.MeshToonMaterial({ color: 0xe8c8a0 });
this.hatMat = new THREE.MeshToonMaterial({ color: 0x44aa44 });
this.beardMat = new THREE.MeshToonMaterial({ color: 0xf8f0e0 });
// Körper
const body = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), this.skinMat);
body.scale.set(1, 1.15, 1);
body.position.y = 0.5;
body.castShadow = true;
this.registerFadeMesh(body);
this.model.add(body);
// Mantel
const coat = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.45, 8, 1, true), this.hatMat);
coat.position.y = 0.28;
coat.rotation.x = Math.PI;
this.registerFadeMesh(coat);
this.model.add(coat);
// Kopf
const head = new THREE.Mesh(new THREE.SphereGeometry(0.24, 12, 10), this.skinMat);
head.position.y = 0.95;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Spitzer Hut
const hat = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.55, 8), this.hatMat);
hat.position.set(0, 1.3, 0.05);
hat.rotation.x = 0.12;
this.registerFadeMesh(hat);
this.model.add(hat);
// Bart (+Z)
const beard = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), this.beardMat);
beard.scale.set(1, 1.2, 0.7);
beard.position.set(0, 0.72, 0.2);
this.registerFadeMesh(beard);
this.model.add(beard);
// Augen (+Z)
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x223322 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.04, 6, 5), eyeMat);
eye.position.set(side * 0.09, 1.0, 0.22);
this.model.add(eye);
}
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.0, shrink: 0.8 });
}
update(dt: number, game: Game) {
this.time += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.skinMat.color.set(0xffffff);
this.hatMat.color.set(0xffffff);
} else {
this.skinMat.color.set(0xe8c8a0);
this.hatMat.color.set(0x44aa44);
}
// Wippen + ab und zu aufhüpfen
this.body.position.y = 0.1 + Math.abs(Math.sin(this.time * 2.2)) * 0.08;
this.model.rotation.y = Math.sin(this.time * 1.5) * 0.15;
// Zum Spieler drehen
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
if (toPlayer.length() > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
// Nur mit Sichtlinie verletzbar (kein Feuerball durch die Wand)
const src = sourcePos ?? game.player.body.position;
if (game.maze && !game.maze.hasLineOfSight(src, this.body.position)) {
game.spawnDamageText('WAND!', this.body.position, '#8899aa');
return;
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
playSound('damage', 0.6);
}
protected onDeath(_game: Game) {
this.beginFadeDeath();
}
}
+139 -2
View File
@@ -15,15 +15,152 @@ export class Golem extends Brute {
this.contactRadius = 3.4;
this.guaranteedDrop = true;
// Brute-Standardmodell verwerfen und als Fels-Golem neu aufbauen
this.model.clear();
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 });
const mossMat = new THREE.MeshToonMaterial({ color: 0x4a8a4a });
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 });
// Facettierte Fels-Optik: flache Normalen auf der unindizierten Geometrie
const rockGeom = (r: number) => {
const g = new THREE.DodecahedronGeometry(r);
g.computeVertexNormals();
return g;
};
// Becken + Rumpf (facettierte Felsbrocken)
const pelvis = new THREE.Mesh(rockGeom(0.42), darkMat);
pelvis.scale.set(1.2, 0.8, 0.9);
pelvis.position.y = 0.75;
this.registerFadeMesh(pelvis);
this.model.add(pelvis);
const torso = new THREE.Mesh(rockGeom(0.72), rockMat);
torso.scale.set(1.25, 1.05, 0.95);
torso.position.y = 1.4;
torso.castShadow = true;
this.registerFadeMesh(torso);
this.model.add(torso);
// Glühender Kern in der Brust (+Z)
const core = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.36, 0.08), eyeMat);
core.position.set(0, 1.45, 0.66);
this.model.add(core);
// Kopf mit Stirnplatte, Augen und Kiefer (+Z)
const head = new THREE.Mesh(rockGeom(0.38), rockMat);
head.position.y = 2.15;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.1, 0.12), darkMat);
brow.position.set(0, 2.3, 0.3);
this.registerFadeMesh(brow);
this.model.add(brow);
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.09, 0.05), eyeMat);
eye.position.set(side * 0.15, 2.15, 0.33);
this.model.add(eye);
}
const jaw = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.12, 0.2), darkMat);
jaw.position.set(0, 1.93, 0.25);
this.registerFadeMesh(jaw);
this.model.add(jaw);
// Felsstacheln auf dem Rücken
for (const side of [-1, 1]) {
const spike = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.45, 5), darkMat);
spike.position.set(side * 0.3, 1.75, -0.6);
spike.rotation.x = 0.6;
this.registerFadeMesh(spike);
this.model.add(spike);
}
// Moosflecken
const mossSpots: [number, number, number, number][] = [
[-0.9, 2.15, 0.1, 0.12],
[0.5, 1.95, 0.3, 0.1],
[0.3, 2.4, -0.1, 0.09],
];
for (const [x, y, z, r] of mossSpots) {
const moss = new THREE.Mesh(rockGeom(r), mossMat);
moss.position.set(x, y, z);
this.registerFadeMesh(moss);
this.model.add(moss);
}
// Schultern + gegliederte Arme (Schulter-Pivot für Laufanimation)
for (const side of [-1, 1]) {
const shoulder = new THREE.Mesh(rockGeom(0.48), rockMat);
shoulder.position.set(side * 0.95, 1.75, 0);
shoulder.castShadow = true;
this.registerFadeMesh(shoulder);
this.model.add(shoulder);
const arm = new THREE.Group();
arm.position.set(side * 0.95, 1.7, 0);
this.model.add(arm);
const upperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.55, 6), rockMat);
upperArm.position.y = -0.3;
this.registerFadeMesh(upperArm);
arm.add(upperArm);
const elbow = new THREE.Mesh(rockGeom(0.18), darkMat);
elbow.position.y = -0.6;
this.registerFadeMesh(elbow);
arm.add(elbow);
const foreArm = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.5, 6), darkMat);
foreArm.position.y = -0.85;
this.registerFadeMesh(foreArm);
arm.add(foreArm);
const fist = new THREE.Mesh(rockGeom(0.24), rockMat);
fist.scale.set(1, 0.8, 1.1);
fist.position.y = -1.15;
this.registerFadeMesh(fist);
arm.add(fist);
if (side < 0) {
this.armL = arm;
} else {
this.armR = arm;
}
}
// Beine (Hüft-Pivot für Laufanimation)
for (const side of [-1, 1]) {
const leg = new THREE.Group();
leg.position.set(side * 0.4, 0.75, 0);
this.model.add(leg);
const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.4, 6), rockMat);
thigh.position.y = -0.18;
this.registerFadeMesh(thigh);
leg.add(thigh);
const knee = new THREE.Mesh(rockGeom(0.16), darkMat);
knee.position.y = -0.38;
this.registerFadeMesh(knee);
leg.add(knee);
const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.35, 6), darkMat);
shin.position.y = -0.52;
this.registerFadeMesh(shin);
leg.add(shin);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.16, 0.5), rockMat);
foot.position.set(0, -0.67, 0.08);
this.registerFadeMesh(foot);
leg.add(foot);
if (side < 0) {
this.legL = leg;
} else {
this.legR = leg;
}
}
this.model.scale.setScalar(2.1);
// Crown
// Krone (Boss-Markierung)
const crownMat = new THREE.MeshToonMaterial({ color: 0xffcc33 });
const crown = new THREE.Mesh(
new THREE.ConeGeometry(0.3, 0.4, 8, 1, true),
crownMat
);
crown.position.y = 2.25;
crown.position.y = 2.55;
crown.castShadow = true;
this.registerFadeMesh(crown);
this.model.add(crown);
+240
View File
@@ -0,0 +1,240 @@
import * as THREE from 'three';
const GRID = 11;
const CELL = 92 / GRID;
const HALF = 46;
const WALL_THICK = 1;
const WALL_HEIGHT = 2.5;
interface WallBox {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
// Labyrinth-Zwischenboss: randomisierter Prim auf einem 11x11-Grid.
// Erzeugt ein "perfektes" Labyrinth (jede Zelle erreichbar, genau ein Pfad
// zwischen zwei beliebigen Zellen) mit kurzen, verzweigten Lösungswegen.
export class Maze {
private scene: THREE.Scene;
private wallMeshes: THREE.Mesh[] = [];
private wallBoxes: WallBox[] = [];
private openH: boolean[][] = []; // Passage (i,j)-(i+1,j)
private openV: boolean[][] = []; // Passage (i,j)-(i,j+1)
constructor(scene: THREE.Scene) {
this.scene = scene;
this.generate();
this.buildWalls();
}
// Start-Ecke = Zelle (0,0), Ziel = Zelle (10,10)
startPosition(): THREE.Vector3 {
return this.cellCenter(0, 0);
}
// goalIndex 0 = Ziel-Ecke (max,max), 1 = Neben-Ecke (min,max) für 2x-Finden
goalPosition(goalIndex: number): THREE.Vector3 {
if (goalIndex === 1) return this.cellCenter(0, GRID - 1);
return this.cellCenter(GRID - 1, GRID - 1);
}
private cellCenter(i: number, j: number): THREE.Vector3 {
return new THREE.Vector3(-HALF + (i + 0.5) * CELL, 0, -HALF + (j + 0.5) * CELL);
}
private generate() {
for (let i = 0; i < GRID; i++) {
this.openH.push(new Array(GRID - 1).fill(false));
this.openV.push(new Array(GRID - 1).fill(false));
}
// Randomized Prim: startet bei Zelle (0,0), wählt zufällige Frontier-Kante
const visited: boolean[][] = Array.from({ length: GRID }, () => new Array(GRID).fill(false));
const frontier: { i: number; j: number; dir: 'h' | 'v' }[] = [];
const addFrontier = (i: number, j: number) => {
if (i < GRID - 1 && !visited[i + 1][j]) frontier.push({ i, j, dir: 'h' });
if (i > 0 && !visited[i - 1][j]) frontier.push({ i: i - 1, j, dir: 'h' });
if (j < GRID - 1 && !visited[i][j + 1]) frontier.push({ i, j, dir: 'v' });
if (j > 0 && !visited[i][j - 1]) frontier.push({ i, j: j - 1, dir: 'v' });
};
visited[0][0] = true;
addFrontier(0, 0);
while (frontier.length > 0) {
const idx = Math.floor(Math.random() * frontier.length);
const { i, j, dir } = frontier[idx];
frontier.splice(idx, 1);
const ci = dir === 'h' ? i + 1 : i;
const cj = dir === 'h' ? j : j + 1;
if (visited[ci][cj]) continue;
visited[ci][cj] = true;
if (dir === 'h') this.openH[i][j] = true;
else this.openV[i][j] = true;
addFrontier(ci, cj);
}
}
private buildWalls() {
const wallMat = new THREE.MeshToonMaterial({ color: 0x776677 });
// Außenwände: Labyrinth bis zur Begrenzung abdichten,
// damit man nicht außen herumlaufen kann
const outer = HALF + WALL_THICK / 2;
this.addWall(-outer, -HALF + WALL_THICK / 2, -outer, outer, WALL_THICK, 2 * outer, -HALF, 0, 0, wallMat);
this.addWall(HALF - WALL_THICK / 2, outer, -outer, outer, WALL_THICK, 2 * outer, HALF, 0, 0, wallMat);
this.addWall(-outer, outer, -outer, -HALF + WALL_THICK / 2, 2 * outer, WALL_THICK, 0, 0, -HALF, wallMat);
this.addWall(-outer, outer, HALF - WALL_THICK / 2, outer, 2 * outer, WALL_THICK, 0, 0, HALF, wallMat);
// Vertikale Gitterlinien (konstantes x): Wand blockiert Bewegung in x
for (let i = 1; i < GRID; i++) {
const bx = -HALF + i * CELL;
for (let j = 0; j < GRID; j++) {
if (this.openH[i - 1][j]) continue;
const zc = -HALF + (j + 0.5) * CELL;
this.addWall(bx - WALL_THICK / 2, bx + WALL_THICK / 2, zc - CELL / 2, zc + CELL / 2, WALL_THICK, CELL, bx, 0, zc, wallMat);
}
}
// Horizontale Gitterlinien (konstantes z): Wand blockiert Bewegung in z
for (let j = 1; j < GRID; j++) {
const bz = -HALF + j * CELL;
for (let i = 0; i < GRID; i++) {
if (this.openV[i][j - 1]) continue;
const xc = -HALF + (i + 0.5) * CELL;
this.addWall(xc - CELL / 2, xc + CELL / 2, bz - WALL_THICK / 2, bz + WALL_THICK / 2, CELL, WALL_THICK, xc, 0, bz, wallMat);
}
}
}
private addWall(
minX: number, maxX: number, minZ: number, maxZ: number,
sizeX: number, sizeZ: number,
px: number, py: number, pz: number,
mat: THREE.Material
) {
const geom = new THREE.BoxGeometry(sizeX, WALL_HEIGHT, sizeZ);
const mesh = new THREE.Mesh(geom, mat);
mesh.position.set(px, py + WALL_HEIGHT / 2, pz);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
this.wallMeshes.push(mesh);
this.wallBoxes.push({ minX, maxX, minZ, maxZ });
}
// Circle-vs-AABB-Kollision: Spieler aus Wänden schieben (3 Durchgänge)
collidePlayer(pos: THREE.Vector3, radius: number) {
for (let pass = 0; pass < 3; pass++) {
let moved = false;
for (const w of this.wallBoxes) {
const cx = Math.max(w.minX, Math.min(pos.x, w.maxX));
const cz = Math.max(w.minZ, Math.min(pos.z, w.maxZ));
const dx = pos.x - cx;
const dz = pos.z - cz;
const d2 = dx * dx + dz * dz;
if (d2 < radius * radius) {
if (d2 > 1e-6) {
const d = Math.sqrt(d2);
pos.x += (dx / d) * (radius - d);
pos.z += (dz / d) * (radius - d);
} else {
// Mittelpunkt in der Wand: kleinste Überlappung wählen
const ox = Math.min(pos.x - w.minX, w.maxX - pos.x);
const oz = Math.min(pos.z - w.minZ, w.maxZ - pos.z);
if (ox < oz) {
pos.x += (pos.x < (w.minX + w.maxX) / 2 ? -1 : 1) * (radius + ox);
} else {
pos.z += (pos.z < (w.minZ + w.maxZ) / 2 ? -1 : 1) * (radius + oz);
}
}
moved = true;
}
}
if (!moved) break;
}
}
// Sichtlinie: Segment alle ~0.8 m abtasten, Wand dazwischen = kein Sichtkontakt
hasLineOfSight(a: THREE.Vector3, b: THREE.Vector3): boolean {
const dx = b.x - a.x;
const dz = b.z - a.z;
const dist = Math.hypot(dx, dz);
const steps = Math.max(1, Math.ceil(dist / 0.8));
for (let s = 1; s < steps; s++) {
const t = s / steps;
const px = a.x + dx * t;
const pz = a.z + dz * t;
for (const w of this.wallBoxes) {
if (px > w.minX - 0.3 && px < w.maxX + 0.3 &&
pz > w.minZ - 0.3 && pz < w.maxZ + 0.3) {
return false;
}
}
}
return true;
}
// Wächter-Positionen: gleichmäßig verteilte Zellen entlang des Lösungswegs
guardPositions(count: number): THREE.Vector3[] {
if (count <= 0) return [];
// BFS von (0,0) zu (10,10)
const prev: ([number, number] | null)[][] = Array.from(
{ length: GRID },
() => new Array<[number, number] | null>(GRID).fill(null)
);
const queue: [number, number][] = [[0, 0]];
prev[0][0] = [0, 0];
while (queue.length > 0) {
const [i, j] = queue.shift()!;
if (i === GRID - 1 && j === GRID - 1) break;
const neigh: [number, number][] = [];
if (i < GRID - 1 && this.openH[i][j]) neigh.push([i + 1, j]);
if (i > 0 && this.openH[i - 1][j]) neigh.push([i - 1, j]);
if (j < GRID - 1 && this.openV[i][j]) neigh.push([i, j + 1]);
if (j > 0 && this.openV[i][j - 1]) neigh.push([i, j - 1]);
for (const [ni, nj] of neigh) {
if (prev[ni][nj]) continue;
prev[ni][nj] = [i, j];
queue.push([ni, nj]);
}
}
// Pfad rekonstruieren
const path: [number, number][] = [];
let cur: [number, number] | null = [GRID - 1, GRID - 1];
while (cur) {
path.push(cur);
const p: [number, number] | null = prev[cur[0]][cur[1]];
if (!p || (p[0] === cur[0] && p[1] === cur[1])) break;
cur = p;
}
path.reverse();
if (path.length < 3) return [];
// count Zellen gleichmäßig auf dem Weg verteilen (ohne Start/Ziel)
const res: THREE.Vector3[] = [];
const used = new Set<string>();
for (let k = 1; k <= count; k++) {
const idx = Math.min(path.length - 2, Math.max(1, Math.round((k * path.length) / (count + 1))));
const [i, j] = path[idx];
const key = `${i},${j}`;
if (used.has(key)) continue;
used.add(key);
res.push(this.cellCenter(i, j));
}
return res;
}
dispose() {
for (const mesh of this.wallMeshes) {
this.scene.remove(mesh);
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
}
this.wallMeshes = [];
this.wallBoxes = [];
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Game } from './Game';
import { Ghost } from './Ghost';
const ACTIVATE_RADIUS = 8;
// Labyrinth-Wächter: steht still am Wegpunkt und greift erst an,
// wenn der Spieler in die Nähe kommt.
export class MazeGuard extends Ghost {
private activated = false;
protected chase(dt: number, game: Game) {
if (!this.activated) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < ACTIVATE_RADIUS) {
this.activated = true;
}
return;
}
super.chase(dt, game);
}
}
+646
View File
@@ -0,0 +1,646 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { Ghost } from './Ghost';
import { Slime } from './Slime';
import { Spider } from './Spider';
import { Archer } from './Archer';
import { Shaman } from './Shaman';
import { Turtle } from './Turtle';
import { playSound } from './SoundManager';
const MAX_HEALTH = 13000;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 1.8;
const FLOAT_HEIGHT = 1.2;
const BOB_AMPLITUDE = 0.25;
const DRIFT_SPEED = 2.5;
const TELEPORT_COOLDOWN = 6;
const TELEPORT_COOLDOWN_2 = 4;
const TELEPORT_CHARGE = 1.0;
const TELEPORT_CHARGE_2 = 0.6;
const TELEPORT_RADIUS = 3.2;
const TELEPORT_DAMAGE = 15;
const TELEPORT_RING_PULSE = 6;
const VOLLEY_COOLDOWN = 4;
const VOLLEY_COOLDOWN_2 = 3;
const VOLLEY_COUNT = 5;
const VOLLEY_COUNT_2 = 7;
const VOLLEY_SPREAD = 0.4;
const BOLT_DAMAGE = 18;
const SUMMON_COOLDOWN = 8;
const SUMMON_COOLDOWN_2 = 6;
const SUMMON_COUNT = 3;
const SUMMON_COUNT_2 = 5;
const SUMMON_TELEGRAPH = 0.8;
const ENRAGE_RATIO = 0.4;
export class Necromancer extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private enraged = false;
private hitFlash = 0;
private robeMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private glowMat!: THREE.MeshBasicMaterial;
private eyeMat!: THREE.MeshBasicMaterial;
private shadow!: THREE.Mesh;
private shadowMat!: THREE.MeshBasicMaterial;
private teleportTimer = 3;
private volleyTimer = 1.5;
private summonTimer = 5;
private teleportState: 'idle' | 'charge' = 'idle';
private teleportCharge = 0;
private teleportTarget = new THREE.Vector3();
private teleportRing!: THREE.Group;
private teleportRingMesh!: THREE.Mesh;
private teleportRingDisc!: THREE.Mesh;
private teleportRingMat!: THREE.MeshBasicMaterial;
private teleportDiscMat!: THREE.MeshBasicMaterial;
private teleportRingInScene = false;
private spawnRings: { group: THREE.Group; timer: number; spawn: () => void }[] = [];
private arrivalFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Erz-Nekromant';
this.speed = 0;
this.xp = 320;
this.contactDps = 15;
this.contactRadius = 1.8;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
this.fadeOutModel = this.model;
this.body.add(this.model);
this.robeMat = new THREE.MeshToonMaterial({ color: 0x6a3a9a, emissive: 0x1a0a2a });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x2a1445 });
this.glowMat = new THREE.MeshBasicMaterial({ color: 0xbb66ff });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xdd88ff });
const boneMat = new THREE.MeshToonMaterial({ color: 0xf0e6cf });
const faceMat = new THREE.MeshBasicMaterial({ color: 0x0a0614 });
let boneFadeRegistered = false;
const registerBone = (mesh: THREE.Mesh) => {
// Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt)
if (!boneFadeRegistered) {
this.registerFadeMesh(mesh);
boneFadeRegistered = true;
}
};
// --- Robe (Blickrichtung +Z, schwebt: unten offen und zerfetzt) ---
const robeProfile = [
new THREE.Vector2(0.75, -1.15), // Saum
new THREE.Vector2(0.62, -0.75),
new THREE.Vector2(0.45, -0.2),
new THREE.Vector2(0.38, 0.25),
new THREE.Vector2(0.5, 0.55), // Schulteransatz
new THREE.Vector2(0.32, 0.78), // Hals
];
const robe = new THREE.Mesh(new THREE.LatheGeometry(robeProfile, 14), this.robeMat);
robe.castShadow = true;
this.registerFadeMesh(robe);
this.model.add(robe);
// Innenschatten im Saum (von unten sichtbar)
const hemInner = new THREE.Mesh(new THREE.CircleGeometry(0.7, 14), this.darkMat);
hemInner.rotation.x = Math.PI / 2;
hemInner.position.y = -1.08;
this.registerFadeMesh(hemInner);
this.model.add(hemInner);
// Zerfetzte Saumfetzen
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const tatter = new THREE.Mesh(
new THREE.ConeGeometry(0.08, 0.4, 4),
i % 2 === 0 ? this.robeMat : this.darkMat
);
tatter.rotation.x = Math.PI;
tatter.position.set(Math.sin(a) * 0.68, -1.28, Math.cos(a) * 0.68);
this.registerFadeMesh(tatter);
this.model.add(tatter);
}
// Gürtelkordel + Quaste
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.42, 0.035, 6, 16), this.darkMat);
belt.rotation.x = Math.PI / 2;
belt.position.y = 0.12;
this.registerFadeMesh(belt);
this.model.add(belt);
const tasselCord = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.28, 5), this.darkMat);
tasselCord.position.set(-0.3, -0.05, 0.36);
this.registerFadeMesh(tasselCord);
this.model.add(tasselCord);
const tassel = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.14, 5), boneMat);
tassel.rotation.x = Math.PI;
tassel.position.set(-0.3, -0.24, 0.38);
registerBone(tassel);
this.model.add(tassel);
// Oberkörper
const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), this.robeMat);
body.position.y = 0.6;
body.scale.set(1, 1.2, 1);
body.castShadow = true;
this.registerFadeMesh(body);
this.model.add(body);
// Kragen
const collar = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.045, 6, 14), this.darkMat);
collar.rotation.x = Math.PI / 2 - 0.2;
collar.position.set(0, 0.85, 0.08);
this.registerFadeMesh(collar);
this.model.add(collar);
// Schulterpanzer mit Spitzen
for (const side of [-1, 1]) {
const pauldron = new THREE.Mesh(new THREE.SphereGeometry(0.26, 12, 8), this.darkMat);
pauldron.scale.set(1.25, 0.65, 1.25);
pauldron.position.set(side * 0.45, 0.78, 0);
this.registerFadeMesh(pauldron);
this.model.add(pauldron);
const spike = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.22, 5), this.darkMat);
spike.position.set(side * 0.55, 0.95, 0);
spike.rotation.z = side * -0.5;
this.registerFadeMesh(spike);
this.model.add(spike);
}
// Kapuze mit Krempe und schwarzem Gesichts-Leerraum
const hood = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.95, 12, 1, true), this.darkMat);
hood.position.set(0, 1.18, 0.02);
hood.rotation.x = 0.22;
hood.castShadow = true;
this.registerFadeMesh(hood);
this.model.add(hood);
const hoodRim = new THREE.Mesh(new THREE.TorusGeometry(0.34, 0.05, 8, 16), this.darkMat);
hoodRim.rotation.x = -0.5;
hoodRim.position.set(0, 1.05, 0.34);
this.registerFadeMesh(hoodRim);
this.model.add(hoodRim);
const face = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), faceMat);
face.scale.set(1, 1.1, 0.8);
face.position.set(0, 1.04, 0.26);
this.registerFadeMesh(face);
this.model.add(face);
// Leuchtende Augen in der Kapuze (+Z)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.075, 8, 6), this.eyeMat);
eye.position.set(side * 0.12, 1.08, 0.48);
this.model.add(eye);
}
// Amulett + Runen auf der Robe (glühen, färben sich im Enrage)
const amulet = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), this.glowMat);
amulet.position.set(0, 0.6, 0.5);
this.model.add(amulet);
const runeSpots: [number, number, number][] = [
[0, -0.15, 0.5],
[-0.2, -0.5, 0.5],
[0.2, -0.5, 0.5],
];
for (const [x, y, z] of runeSpots) {
const rune = new THREE.Mesh(new THREE.OctahedronGeometry(0.06), this.glowMat);
rune.scale.set(1, 1.7, 0.5);
rune.position.set(x, y, z);
this.model.add(rune);
}
// Rechter Arm: Ärmel zum Stab
const sleeveR = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.16, 0.55, 8), this.robeMat);
sleeveR.position.set(0.65, 0.84, 0.14);
sleeveR.rotation.z = 1.2;
this.registerFadeMesh(sleeveR);
this.model.add(sleeveR);
const handR = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat);
handR.position.set(0.85, 0.9, 0.23);
registerBone(handR);
this.model.add(handR);
// Linker Arm: Ärmel nach vorn (Beschwörungs-Geste) + Energiekugel
const sleeveL = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.18, 0.6, 8), this.robeMat);
sleeveL.position.set(-0.55, 0.75, 0.32);
sleeveL.rotation.x = -1.25;
sleeveL.rotation.z = -0.35;
this.registerFadeMesh(sleeveL);
this.model.add(sleeveL);
const handL = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat);
handL.position.set(-0.68, 0.72, 0.62);
registerBone(handL);
this.model.add(handL);
const castOrb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.glowMat);
castOrb.position.set(-0.7, 0.92, 0.68);
this.model.add(castOrb);
// Stab mit Manschetten, Totenkopf und schwebendem Orb
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 2.4, 8), this.darkMat);
staff.position.set(0.8, 1.2, 0.28);
staff.rotation.z = 0.18;
staff.rotation.x = 0.15;
this.registerFadeMesh(staff);
this.model.add(staff);
for (const [x, y, z] of [[0.88, 0.76, 0.21], [0.96, 0.31, 0.15]] as const) {
const collarM = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.07, 8), this.darkMat);
collarM.position.set(x, y, z);
this.registerFadeMesh(collarM);
this.model.add(collarM);
}
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.15, 10, 8), boneMat);
skull.position.set(0.58, 2.42, 0.47);
registerBone(skull);
this.model.add(skull);
for (const side of [-1, 1]) {
const socket = new THREE.Mesh(new THREE.SphereGeometry(0.03, 6, 4), this.darkMat);
socket.position.set(0.58 + side * 0.05, 2.44, 0.6);
this.model.add(socket);
}
const orbMat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.14, 10, 8), orbMat);
orb.position.set(0.58, 2.66, 0.47);
this.model.add(orb);
this.orbMat = orbMat;
this.orb = orb;
// Boden-Schatten
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.9, 20), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
// Teleport-Telegraf: Ring + Scheibe am Zielpunkt
this.teleportRingMat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.teleportDiscMat = new THREE.MeshBasicMaterial({
color: 0x8833cc,
transparent: true,
opacity: 0,
depthWrite: false,
});
const ringMesh = new THREE.Mesh(
new THREE.RingGeometry(1.6, 2.0, 32),
this.teleportRingMat
);
ringMesh.rotation.x = -Math.PI / 2;
ringMesh.position.y = 0.05;
const disc = new THREE.Mesh(new THREE.CircleGeometry(1.8, 32), this.teleportDiscMat);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.03;
this.teleportRingMesh = ringMesh;
this.teleportRingDisc = disc;
this.teleportRing = new THREE.Group();
this.teleportRing.add(ringMesh, disc);
this.teleportRing.visible = false;
}
private orb!: THREE.Mesh;
private orbMat!: THREE.MeshBasicMaterial;
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Schweben
this.body.position.y = FLOAT_HEIGHT + Math.sin(this.waveTime * 2.2) * BOB_AMPLITUDE;
this.shadow.position.y = 0.06 - this.body.position.y;
const sway = Math.sin(this.waveTime * 1.5) * 0.06;
this.model.rotation.z = sway;
this.model.rotation.x = Math.sin(this.waveTime * 2.2) * 0.04;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.robeMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
this.robeMat.color.set(this.enraged ? 0x8a2233 : 0x6a3a9a);
this.darkMat.color.set(this.enraged ? 0x551520 : 0x2a1445);
this.model.scale.setScalar(MODEL_SCALE);
}
// Orb pulsiert
const orbPulse = 1 + Math.abs(Math.sin(this.waveTime * 5)) * 0.25;
this.orb.scale.setScalar(orbPulse);
this.orbMat.opacity = 0.6 + Math.abs(Math.sin(this.waveTime * 5)) * 0.3;
// Enrage unter 40% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff4422);
this.glowMat.color.set(0xff6644);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
// Zum Spieler driften (langsam)
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(DRIFT_SPEED);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-DRIFT_SPEED);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Teleport
if (this.teleportState === 'idle') {
this.teleportTimer -= dt;
if (this.teleportTimer <= 0) {
this.teleportTimer = this.enraged ? TELEPORT_COOLDOWN_2 : TELEPORT_COOLDOWN;
this.startTeleport(game);
}
} else {
this.updateTeleportCharge(dt, game);
}
// Salven
this.volleyTimer -= dt;
if (this.volleyTimer <= 0) {
this.volleyTimer = this.enraged ? VOLLEY_COOLDOWN_2 : VOLLEY_COOLDOWN;
this.fireVolley(game);
}
// Beschwörung
this.summonTimer -= dt;
if (this.summonTimer <= 0) {
this.summonTimer = this.enraged ? SUMMON_COOLDOWN_2 : SUMMON_COOLDOWN;
this.startSummon(game);
}
// Beschwörungs-Telegraphen
for (let i = this.spawnRings.length - 1; i >= 0; i--) {
const r = this.spawnRings[i];
r.timer -= dt;
if (r.timer <= 0) {
r.spawn();
game.scene.remove(r.group);
r.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
this.spawnRings.splice(i, 1);
} else {
const t = 1 - r.timer / SUMMON_TELEGRAPH;
r.group.scale.setScalar(0.5 + t * 1.2);
}
}
// Ankunfts-Blitz-Flächen
for (let i = this.arrivalFlashes.length - 1; i >= 0; i--) {
const f = this.arrivalFlashes[i];
f.life -= dt;
const t = 1 - Math.max(0, f.life) / 0.3;
f.mesh.scale.setScalar(0.5 + t * 5);
f.mat.opacity = 0.7 * (1 - t);
if (f.life <= 0) {
game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
this.arrivalFlashes.splice(i, 1);
}
}
}
// --- Teleport ---
private startTeleport(game: Game) {
this.teleportState = 'charge';
this.teleportCharge = this.enraged ? TELEPORT_CHARGE_2 : TELEPORT_CHARGE;
const playerPos = game.player.body.position;
// Ziel = aktuelle Spielerposition (leicht zufällig versetzt)
this.teleportTarget.set(
playerPos.x + (Math.random() - 0.5) * 2,
0,
playerPos.z + (Math.random() - 0.5) * 2
);
game.clampToArena(this.teleportTarget);
if (!this.teleportRingInScene) {
game.scene.add(this.teleportRing);
this.teleportRingInScene = true;
}
this.teleportRing.position.set(this.teleportTarget.x, 0, this.teleportTarget.z);
this.teleportRing.visible = true;
playSound('spawn', 0.7);
}
private updateTeleportCharge(dt: number, game: Game) {
this.teleportCharge -= dt;
const pulse = Math.abs(Math.sin(this.waveTime * TELEPORT_RING_PULSE));
this.teleportRingMat.opacity = 0.3 + 0.5 * pulse;
this.teleportDiscMat.opacity = 0.1 + 0.08 * pulse;
if (this.teleportCharge <= 0) {
this.teleportState = 'idle';
this.teleportRing.visible = false;
this.teleportRingMat.opacity = 0;
this.teleportDiscMat.opacity = 0;
// Blitz
this.body.position.x = this.teleportTarget.x;
this.body.position.z = this.teleportTarget.z;
playSound('teleport', 0.8);
// Ankunftsexplosion + Schaden, wenn der Spieler unter dem Zielpunkt steht
const flashMat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat);
flash.rotation.x = -Math.PI / 2;
flash.position.set(this.teleportTarget.x, 0.07, this.teleportTarget.z);
game.scene.add(flash);
this.arrivalFlashes.push({ mesh: flash, mat: flashMat, life: 0.3 });
if (game.player.health > 0 && game.player.body.position.y <= 0.8) {
const dx = game.player.body.position.x - this.teleportTarget.x;
const dz = game.player.body.position.z - this.teleportTarget.z;
if (Math.hypot(dx, dz) < TELEPORT_RADIUS) {
game.player.damage(TELEPORT_DAMAGE, this.teleportTarget);
}
}
// Salve vom neuen Standort
this.fireVolley(game);
}
}
// --- Projektilsalve ---
private fireVolley(game: Game) {
const from = new THREE.Vector3(this.body.position.x, FLOAT_HEIGHT + 0.6, this.body.position.z);
const target = game.player.body.position.clone();
const count = this.enraged ? VOLLEY_COUNT_2 : VOLLEY_COUNT;
const baseDir = new THREE.Vector3().subVectors(target, from);
baseDir.y = 0;
baseDir.normalize();
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
for (let i = 0; i < count; i++) {
const off = (i / (count - 1) - 0.5) * 2 * VOLLEY_SPREAD;
const dir = new THREE.Vector3()
.addScaledVector(baseDir, 1)
.addScaledVector(perp, off)
.normalize();
const t = from.clone().add(dir.multiplyScalar(25));
t.y = 0.5;
game.spawnEnemyProjectile(from, t, BOLT_DAMAGE);
}
playSound('spawn', 0.5);
}
// --- Beschwörung ---
private startSummon(game: Game) {
const count = this.enraged ? SUMMON_COUNT_2 : SUMMON_COUNT;
const types = [() => new Ghost(), () => new Slime(), () => new Spider(),
() => new Archer(), () => new Shaman(), () => new Turtle()];
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = 5 + Math.random() * 8;
const [x, z] = this.clampToArena(game,
this.body.position.x + Math.cos(a) * r,
this.body.position.z + Math.sin(a) * r
);
// Wachsender Boden-Glow als Telegraf
const mat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0.35,
depthWrite: false,
});
const ring = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), mat);
ring.rotation.x = -Math.PI / 2;
ring.position.set(x, 0.05, z);
ring.scale.setScalar(0.5);
const group = new THREE.Group();
group.add(ring);
game.scene.add(group);
const type = types[Math.floor(Math.random() * types.length)];
this.spawnRings.push({
group,
timer: SUMMON_TELEGRAPH,
spawn: () => {
game.spawnEnemyInstance(type(), x, z, 0.1);
playSound('spawn', 0.5);
},
});
}
playSound('spawn', 0.6);
}
private clampToArena(game: Game, x: number, z: number): [number, number] {
const v = new THREE.Vector3(x, 0, z);
game.clampToArena(v);
return [v.x, v.z];
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
if (this.game) {
for (const r of this.spawnRings) {
this.game.scene.remove(r.group);
r.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
for (const f of this.arrivalFlashes) {
this.game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
}
}
this.spawnRings = [];
this.arrivalFlashes = [];
this.teleportState = 'idle';
this.teleportRing.visible = false;
if (this.teleportRingInScene && this.game) {
this.game.scene.remove(this.teleportRing);
}
this.teleportRingInScene = false;
this.robeMat.color.set(0x6a3a9a);
this.darkMat.color.set(0x2a1445);
this.eyeMat.color.set(0xdd88ff);
this.glowMat.color.set(0xbb66ff);
this.model.scale.setScalar(MODEL_SCALE);
}
}
+6
View File
@@ -35,6 +35,8 @@ export class Player {
pullTime = 0;
pullDir = new THREE.Vector3();
stunTime = 0;
rootTime = 0;
rootCooldown = 0;
stats: PlayerStats = {
maxHealth: 100,
regen: 0,
@@ -239,6 +241,8 @@ export class Player {
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
if (this.slowTimer > 0) this.slowTimer -= dt;
if (this.stunTime > 0) this.stunTime -= dt;
if (this.rootTime > 0) this.rootTime -= dt;
if (this.rootCooldown > 0) this.rootCooldown -= dt;
// Stun-Sterne animieren
this.stunStars.visible = this.stunTime > 0;
@@ -360,6 +364,8 @@ export class Player {
this.slowTimer = 0;
this.pullTime = 0;
this.stunTime = 0;
this.rootTime = 0;
this.rootCooldown = 0;
this.body.position.set(0, 0.5, 0);
this.velocity.set(0, 0, 0);
this.jumpVelocity = 0;
+327
View File
@@ -0,0 +1,327 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const HEAL_RADIUS = 8;
const HEAL_TICK = 0.9;
const HEAL_AMOUNT = 6;
const HIT_FLASH_TIME = 0.14;
// Schamane: heilt andere Gegner im Radius -> Prioritätsziel
export class Shaman extends Enemy {
private model: THREE.Group;
private robeMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private healRing!: THREE.Mesh;
private healRingMat!: THREE.MeshBasicMaterial;
private healTimer = 0.5;
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor() {
super();
this.health = 60;
this.speed = 2.8;
this.xp = 25;
this.contactDps = 10;
this.contactRadius = 1.8;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.robeMat = new THREE.MeshToonMaterial({ color: 0x33aa55, emissive: 0x0a1f10 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x1c5c2e });
const boneMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
const faceMat = new THREE.MeshBasicMaterial({ color: 0x06120a });
let boneFadeRegistered = false;
const registerBone = (mesh: THREE.Mesh) => {
// Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt)
if (!boneFadeRegistered) {
this.registerFadeMesh(mesh);
boneFadeRegistered = true;
}
};
// --- Robe (Blickrichtung +Z, bis zum Boden) ---
const robeProfile = [
new THREE.Vector2(0.52, 0.02), // Saum
new THREE.Vector2(0.44, 0.35),
new THREE.Vector2(0.32, 0.7),
new THREE.Vector2(0.3, 0.95),
new THREE.Vector2(0.38, 1.1), // Schulteransatz
new THREE.Vector2(0.24, 1.25), // Hals
];
const robe = new THREE.Mesh(new THREE.LatheGeometry(robeProfile, 12), this.robeMat);
robe.castShadow = true;
this.registerFadeMesh(robe);
this.model.add(robe);
// Zerfetzter Saum
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
const tatter = new THREE.Mesh(
new THREE.ConeGeometry(0.06, 0.28, 4),
i % 2 === 0 ? this.robeMat : this.darkMat
);
tatter.rotation.x = Math.PI;
tatter.position.set(Math.sin(a) * 0.48, -0.02, Math.cos(a) * 0.48);
this.registerFadeMesh(tatter);
this.model.add(tatter);
}
// Gürtel mit hängenden Knochen-Amuletten
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.33, 0.03, 6, 14), this.darkMat);
belt.rotation.x = Math.PI / 2;
belt.position.y = 0.75;
this.registerFadeMesh(belt);
this.model.add(belt);
for (const [x, z] of [[-0.14, 0.3], [0, 0.34], [0.14, 0.3]] as const) {
const charm = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.16, 5), boneMat);
charm.position.set(x, 0.62, z);
registerBone(charm);
this.model.add(charm);
}
// Brust + Fellkragen mit Zacken
const chest = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), this.robeMat);
chest.position.y = 1.12;
chest.scale.set(1, 1.15, 1);
this.registerFadeMesh(chest);
this.model.add(chest);
const collar = new THREE.Mesh(new THREE.TorusGeometry(0.26, 0.09, 8, 14), this.darkMat);
collar.rotation.x = Math.PI / 2;
collar.scale.set(1, 1, 0.8);
collar.position.y = 1.28;
this.registerFadeMesh(collar);
this.model.add(collar);
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const fur = new THREE.Mesh(new THREE.ConeGeometry(0.04, 0.16, 4), this.darkMat);
fur.position.set(Math.sin(a) * 0.28, 1.36, Math.cos(a) * 0.24);
fur.rotation.x = -0.4;
fur.rotation.y = a;
this.registerFadeMesh(fur);
this.model.add(fur);
}
// Knochenkette mit Zähnen auf der Brust
const necklace = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.02, 6, 12), this.darkMat);
necklace.rotation.x = Math.PI / 2 - 0.3;
necklace.position.set(0, 1.2, 0.2);
this.registerFadeMesh(necklace);
this.model.add(necklace);
for (const [x, z] of [[-0.08, 0.32], [0, 0.35], [0.08, 0.32]] as const) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.1, 4), boneMat);
tooth.rotation.x = Math.PI;
tooth.position.set(x, 1.1, z);
registerBone(tooth);
this.model.add(tooth);
}
// Kapuze mit Krempe, schwarzem Gesicht und grünen Augen (+Z)
const hood = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.6, 10, 1, true), this.darkMat);
hood.position.set(0, 1.55, 0.02);
hood.rotation.x = 0.2;
hood.castShadow = true;
this.registerFadeMesh(hood);
this.model.add(hood);
const hoodRim = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.04, 8, 14), this.darkMat);
hoodRim.rotation.x = -0.5;
hoodRim.position.set(0, 1.44, 0.22);
this.registerFadeMesh(hoodRim);
this.model.add(hoodRim);
const face = new THREE.Mesh(new THREE.SphereGeometry(0.2, 12, 10), faceMat);
face.scale.set(1, 1.1, 0.8);
face.position.set(0, 1.44, 0.16);
this.registerFadeMesh(face);
this.model.add(face);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x66ff88 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 5), eyeMat);
eye.position.set(side * 0.08, 1.47, 0.35);
this.model.add(eye);
}
// Federn an der Kapuze (hängen nach hinten)
for (const side of [-1, 1]) {
const feather = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.4, 4), boneMat);
feather.rotation.x = Math.PI + side * 0.15;
feather.scale.set(1, 1, 0.3);
feather.position.set(side * 0.14, 1.35, -0.22);
registerBone(feather);
this.model.add(feather);
}
// Rechter Arm zum Stab
const sleeveR = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.12, 0.45, 8), this.robeMat);
sleeveR.position.set(0.32, 1.05, 0.1);
sleeveR.rotation.z = 1.0;
this.registerFadeMesh(sleeveR);
this.model.add(sleeveR);
const handR = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), boneMat);
handR.position.set(0.45, 0.95, 0.18);
registerBone(handR);
this.model.add(handR);
// Linker Arm erhoben mit kleiner Energiekugel
const sleeveL = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.12, 0.45, 8), this.robeMat);
sleeveL.position.set(-0.34, 1.12, 0.18);
sleeveL.rotation.x = -1.1;
sleeveL.rotation.z = -0.5;
this.registerFadeMesh(sleeveL);
this.model.add(sleeveL);
const handL = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), boneMat);
handL.position.set(-0.4, 1.22, 0.34);
registerBone(handL);
this.model.add(handL);
const orbMat = new THREE.MeshBasicMaterial({
color: 0x66ff88,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const castOrb = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), orbMat);
castOrb.position.set(-0.42, 1.38, 0.38);
this.model.add(castOrb);
// Totem-Stab: Schädel mit Klauenkrone, Glüh-Orb, hängender Anhänger
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.04, 1.7, 6), this.darkMat);
staff.position.set(0.5, 0.85, 0.2);
staff.rotation.z = 0.15;
staff.rotation.x = 0.1;
this.registerFadeMesh(staff);
this.model.add(staff);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), boneMat);
skull.position.set(0.37, 1.72, 0.29);
registerBone(skull);
this.model.add(skull);
for (const side of [-1, 1]) {
const claw = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.22, 5), boneMat);
claw.position.set(0.37 + side * 0.1, 1.84, 0.29);
claw.rotation.z = side * -0.5;
registerBone(claw);
this.model.add(claw);
const socket = new THREE.Mesh(new THREE.SphereGeometry(0.025, 6, 4), this.darkMat);
socket.position.set(0.37 + side * 0.04, 1.74, 0.39);
this.model.add(socket);
}
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), orbMat);
orb.position.set(0.37, 1.94, 0.29);
this.model.add(orb);
const dangle = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.14, 5), boneMat);
dangle.position.set(0.44, 1.58, 0.27);
dangle.rotation.z = 0.4;
registerBone(dangle);
this.model.add(dangle);
// Heil-Aura-Ring am Boden
this.healRingMat = new THREE.MeshBasicMaterial({
color: 0x44ff77,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.healRing = new THREE.Mesh(
new THREE.RingGeometry(HEAL_RADIUS - 0.15, HEAL_RADIUS + 0.15, 32),
this.healRingMat
);
this.healRing.rotation.x = -Math.PI / 2;
this.healRing.position.y = 0.05;
this.body.add(this.healRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.0, sink: 0.5 });
}
update(dt: number, game: Game) {
this.time += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.robeMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
} else {
this.robeMat.color.set(0x33aa55);
this.darkMat.color.set(0x1c5c2e);
}
// Langsames Schwebe-Wippen
this.model.rotation.z = Math.sin(this.time * 1.5) * 0.04;
// Zum Spieler laufen
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(this.speed);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-this.speed);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Heil-Tick
this.healTimer -= dt;
if (this.healTimer <= 0) {
this.healTimer = HEAL_TICK;
let healed = false;
for (const e of game.enemies) {
if (e === this || e.dead) continue;
const d = Math.hypot(
e.body.position.x - this.body.position.x,
e.body.position.z - this.body.position.z
);
if (d < HEAL_RADIUS && e.health < e.maxHealth) {
e.health = Math.min(e.maxHealth, e.health + HEAL_AMOUNT);
healed = true;
}
}
if (healed) playSound('spawn', 0.35);
}
// Aura-Ring pulsiert mit dem Tick
const pulse = 0.15 + Math.max(0, this.healTimer / HEAL_TICK) * 0.25;
this.healRingMat.opacity = pulse;
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.healRing.geometry.dispose();
this.healRingMat.dispose();
}
}
+12 -9
View File
@@ -22,7 +22,7 @@ export const SKILLS: SkillDef[] = [
type: 'active',
maxLevel: 5,
describe: (l) => l === 1
? 'Schießt einen Feuerball (100 DMG, 5s CD)'
? 'Schießt einen Feuerball (85 DMG, 5.5s CD)'
: `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`,
},
{
@@ -37,7 +37,7 @@ export const SKILLS: SkillDef[] = [
name: 'Kettenblitz',
type: 'active',
maxLevel: 5,
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD)`,
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD, ${lightningJumpRange(l)} m Sprungweite)`,
},
{
id: 'swordRange',
@@ -132,23 +132,26 @@ export function getSkill(id: string): SkillDef {
}
export function fireballDamage(level: number): number {
return [100, 150, 200, 250, 300][Math.min(level, 5) - 1] ?? 100;
return [85, 125, 165, 205, 245][Math.min(level, 5) - 1] ?? 85;
}
export function fireballCooldown(level: number): number {
return [5, 4.5, 4, 3.5, 3][Math.min(level, 5) - 1] ?? 5;
return [5.5, 5, 4.5, 4, 3.5][Math.min(level, 5) - 1] ?? 5.5;
}
export function fireballRadius(level: number): number {
return [8, 8, 10, 10, 12][Math.min(level, 5) - 1] ?? 8;
return [7, 7, 9, 9, 11][Math.min(level, 5) - 1] ?? 7;
}
export function teleportCooldown(level: number): number {
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
}
export function lightningDamage(level: number): number {
return [40, 60, 80, 100, 120][Math.min(level, 5) - 1] ?? 40;
return [55, 75, 95, 115, 135][Math.min(level, 5) - 1] ?? 55;
}
export function lightningJumps(level: number): number {
return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2;
}
export function lightningJumpRange(level: number): number {
return [9, 10, 11, 12, 13][Math.min(level, 5) - 1] ?? 9;
}
export function lightningCooldown(level: number): number {
return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8;
}
@@ -174,7 +177,7 @@ export function boomerangCount(level: number): number {
return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1;
}
export function trapDamage(level: number): number {
return [25, 35, 45, 55, 70][Math.min(level, 5) - 1] ?? 25;
return [35, 45, 55, 65, 80][Math.min(level, 5) - 1] ?? 35;
}
export function trapHold(level: number): number {
return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5;
@@ -186,7 +189,7 @@ export function trapCooldown(level: number): number {
return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8;
}
export function gasDamage(level: number): number {
return [8, 12, 16, 20, 25][Math.min(level, 5) - 1] ?? 8;
return [12, 16, 20, 24, 30][Math.min(level, 5) - 1] ?? 12;
}
export function gasRadius(level: number): number {
return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5;
@@ -226,7 +229,7 @@ export class SkillSystem {
}
xpNeeded(): number {
return 20 + this.level * 10;
return 30 + this.level * 15;
}
xpProgress(): number {
+24 -12
View File
@@ -16,29 +16,41 @@ export async function initSpiderModel() {
}
}
export class Spider extends Enemy {
constructor() {
super();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
if (spiderGLTF) {
const model = cloneSkeleton(spiderGLTF.scene);
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
model.scale.set(0.5, 0.5, 0.5);
// Wiederverwendbarer Spinnen-Modell-Builder (für Spinne UND Spinnenkönigin).
// tint färbt die Textur mit ein (0xffffff = Original).
export function buildSpiderModel(scale: number, tint: number): THREE.Group | null {
if (!spiderGLTF) return null;
const model = cloneSkeleton(spiderGLTF.scene) as THREE.Group;
model.scale.set(scale, scale, scale);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
child.material = new THREE.MeshToonMaterial({ map: spiderTexture, color: tint });
}
});
return model;
}
// Animationen des Spinnen-Modells (leer, wenn nicht geladen)
export function getSpiderAnimations(): THREE.AnimationClip[] {
return spiderGLTF ? spiderGLTF.animations : [];
}
export class Spider extends Enemy {
constructor() {
super();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
const model = buildSpiderModel(0.5, 0xffffff);
if (model) {
this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
if (getSpiderAnimations().length > 0 && ANIMATIONS_ENABLED) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of spiderGLTF.animations) {
for (const clip of getSpiderAnimations()) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
+408
View File
@@ -0,0 +1,408 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { Spider, buildSpiderModel, getSpiderAnimations } from './Spider';
import { playSound } from './SoundManager';
import { WebPatch } from './WebPatch';
const MAX_HEALTH = 8500;
const WEB_COOLDOWN = 4.5;
const SCREAM_COOLDOWN = 6;
const SPIT_COOLDOWN = 5;
const WEB_RADIUS = 2.2;
const WEB_DURATION = 3;
const WEB_DIRECT_ROOT = 0.5;
const SPIDERLING_COUNT = 3;
const SPIDERLING_COUNT_2 = 5;
const SPIDERLING_MAX_ALIVE = 8;
const SPIT_COUNT = 3;
const SPIT_COUNT_2 = 5;
const SPIT_DAMAGE = 15;
const ENRAGE_RATIO = 0.4;
const HIT_FLASH_TIME = 0.14;
const WEB_BLOB_SPEED = 7;
const WEB_BLOB_PROXIMITY = 1.3;
const WEB_BLOB_DAMAGE = 10;
export class SpiderQueen extends Enemy {
spawnAtCenter = true;
private game: Game | null = null;
private waveTime = 0;
private webTimer = 2;
private screamTimer = 4;
private spitTimer = 5;
private enraged = false;
private effectsReady = false;
private hitFlash = 0;
private model!: THREE.Group;
private tintMats: THREE.MeshToonMaterial[] = [];
private webBlobs: WebBlob[] = [];
private webs: WebPatch[] = [];
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Spinnenkönigin';
this.immovable = true;
this.speed = 0;
this.xp = 240;
this.contactDps = 0;
this.contactRadius = 0;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
// Königinnen-Modell: bestehende Spinne, größer + violett eingefärbt
const spiderModel = buildSpiderModel(3.2, 0x9a5ac8);
if (spiderModel) {
this.model.add(spiderModel);
spiderModel.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshToonMaterial) {
this.tintMats.push(child.material);
}
});
if (getSpiderAnimations().length > 0) {
this.mixer = new THREE.AnimationMixer(spiderModel);
for (const clip of getSpiderAnimations()) {
this.clips.set(clip.name, this.mixer.clipAction(clip));
}
this.playAnim('stand');
}
} else {
// Fallback (Modell nicht geladen): einfache große Spinne
const skinMat = new THREE.MeshToonMaterial({ color: 0x7a3a8a });
const darkMat = new THREE.MeshToonMaterial({ color: 0x4a2260 });
this.tintMats.push(skinMat, darkMat);
const abdomen = new THREE.Mesh(new THREE.SphereGeometry(1.6, 24, 18), skinMat);
abdomen.scale.set(1, 1.05, 1.35);
abdomen.position.set(0, 2.0, -1.7);
abdomen.castShadow = true;
this.registerFadeMesh(abdomen);
this.model.add(abdomen);
const cephalo = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 16), skinMat);
cephalo.scale.set(1, 0.85, 1.1);
cephalo.position.set(0, 1.9, 0.6);
cephalo.castShadow = true;
this.registerFadeMesh(cephalo);
this.model.add(cephalo);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.6, 16, 12), darkMat);
head.scale.set(1, 0.8, 0.9);
head.position.set(0, 2.0, 1.5);
this.registerFadeMesh(head);
this.model.add(head);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff3333 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), eyeMat);
eye.position.set(side * 0.32, 2.15, 1.85);
this.model.add(eye);
}
const legMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a });
this.tintMats.push(legMat);
for (let i = 0; i < 8; i++) {
const side = i < 4 ? -1 : 1;
const back = i % 2 === 0;
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 1.1, 6), legMat);
leg.position.set(side * (back ? 1.0 : 0.9), 0.6, back ? -0.9 : 0.4);
leg.rotation.z = side * 1.1;
this.registerFadeMesh(leg);
this.model.add(leg);
}
}
}
playAnim(name: string, loop = true, speed = 1) {
if (!this.clips.has(name) || this.currentAnim === name) return;
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.play();
}
updateAnimation(dt: number) {
if (this.mixer) this.mixer.update(dt);
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback: Materialien kurz weiß aufblitzen
if (this.hitFlash > 0) {
this.hitFlash -= dt;
for (const mat of this.tintMats) mat.color.set(0xffffff);
this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05);
} else {
for (const mat of this.tintMats) mat.color.set(this.enraged ? 0xff6644 : 0x9a5ac8);
this.model.scale.setScalar(1);
}
// Zum Spieler drehen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, 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
);
}
// Enrage unter 40% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
playSound('damage', 0.8);
game.triggerCameraShake(0.35, 0.4);
}
// Angriffe
this.webTimer -= dt;
if (this.webTimer <= 0) {
this.webTimer = this.enraged ? WEB_COOLDOWN * 0.65 : WEB_COOLDOWN;
this.spitWeb(game);
}
this.screamTimer -= dt;
if (this.screamTimer <= 0) {
this.screamTimer = this.enraged ? SCREAM_COOLDOWN * 0.6 : SCREAM_COOLDOWN;
this.scream(game);
}
this.spitTimer -= dt;
if (this.spitTimer <= 0) {
this.spitTimer = this.enraged ? SPIT_COOLDOWN * 0.7 : SPIT_COOLDOWN;
this.spitAcid(game);
}
// Netz-Bälle aktualisieren
for (let i = this.webBlobs.length - 1; i >= 0; i--) {
const blob = this.webBlobs[i];
if (!blob.update(dt, game)) {
this.webBlobs.splice(i, 1);
const patch = new WebPatch(
blob.body.position,
this.enraged ? WEB_RADIUS + 0.4 : WEB_RADIUS,
this.enraged ? WEB_DURATION * 1.5 : WEB_DURATION
);
game.scene.add(patch.body);
this.webs.push(patch);
blob.dispose();
playSound('explosion', 0.4);
}
}
// Netze aktualisieren
for (let i = this.webs.length - 1; i >= 0; i--) {
const web = this.webs[i];
if (!web.update(dt, game)) {
this.webs.splice(i, 1);
web.dispose();
}
}
}
private spitWeb(game: Game) {
const from = new THREE.Vector3(this.body.position.x, 2.2, this.body.position.z);
const target = game.player.body.position.clone();
target.y = 0;
const blob = new WebBlob(from, target);
game.scene.add(blob.body);
this.webBlobs.push(blob);
playSound('spawn', 0.5);
}
private scream(game: Game) {
// Nicht endlos viele Spinnlinge anhäufen
let alive = 0;
for (const e of game.enemies) {
if (e instanceof Spiderling && !e.dead) alive++;
}
const count = Math.min(
this.enraged ? SPIDERLING_COUNT_2 : SPIDERLING_COUNT,
SPIDERLING_MAX_ALIVE - alive
);
if (count <= 0) return;
for (let i = 0; i < count; i++) {
const mini = new Spiderling();
const a = Math.random() * Math.PI * 2;
const r = 4 + Math.random() * 4;
game.spawnEnemyInstance(
mini,
this.body.position.x + Math.cos(a) * r,
this.body.position.z + Math.sin(a) * r,
0.1
);
}
playSound('spawn', 0.7);
}
private spitAcid(game: Game) {
const from = new THREE.Vector3(this.body.position.x, 2.4, this.body.position.z);
const target = game.player.body.position.clone();
const count = this.enraged ? SPIT_COUNT_2 : SPIT_COUNT;
const spread = 0.35;
const baseDir = new THREE.Vector3().subVectors(target, from);
baseDir.y = 0;
baseDir.normalize();
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
for (let i = 0; i < count; i++) {
const off = (i / (count - 1) - 0.5) * 2 * spread;
const dir = new THREE.Vector3()
.addScaledVector(baseDir, 1)
.addScaledVector(perp, off)
.normalize();
const t = from.clone().add(dir.multiplyScalar(20));
t.y = 0;
game.spawnEnemyProjectile(from, t, SPIT_DAMAGE);
}
playSound('spawn', 0.6);
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
for (const blob of this.webBlobs) blob.dispose();
this.webBlobs = [];
for (const web of this.webs) web.dispose();
this.webs = [];
for (const mat of this.tintMats) mat.color.set(0x9a5ac8);
this.model.scale.setScalar(1);
}
}
// Spinnling: wie die normale Spinne, stirbt aber sichtbar (schrumpft + sinkt),
// damit keine stehenden Leichen in der Arena zurückbleiben.
export class Spiderling extends Spider {
private fadeDying = false;
private fadeDieTime = 0;
constructor() {
super();
this.health = 25;
this.speed = 4.2;
this.xp = 5;
this.contactDps = 12;
this.contactRadius = 1.5;
// Kein grace: Spider dekrementiert grace nie, sonst wären die Spinnlinge unverwundbar.
this.body.scale.setScalar(0.5);
}
protected onDeath(_game: Game) {
this.fadeDying = true;
}
updateAnimation(dt: number) {
super.updateAnimation(dt);
if (this.fadeDying) {
this.fadeDieTime += dt;
const t = Math.min(this.fadeDieTime / 0.5, 1);
this.body.scale.setScalar(0.5 * (1 - t));
this.body.position.y -= dt * 1.2;
}
}
}
// Netz-Ball: fliegt zum Spieler und hinterlässt beim Auftreffen ein WebPatch
class WebBlob {
body: THREE.Group;
target: THREE.Vector3;
private vel: THREE.Vector3;
private life = 6;
dead = false;
constructor(from: THREE.Vector3, target: THREE.Vector3) {
this.target = target.clone();
this.body = new THREE.Group();
this.body.position.copy(from);
this.vel = new THREE.Vector3().subVectors(target, from);
this.vel.y = 0;
const dist = this.vel.length();
if (dist > 0.01) {
this.vel.normalize().multiplyScalar(WEB_BLOB_SPEED);
}
const glowMat = new THREE.MeshBasicMaterial({
color: 0xddddff,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 12), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshToonMaterial({ color: 0xf0f0ff });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 10), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
this.body.position.x += this.vel.x * dt;
this.body.position.z += this.vel.z * dt;
this.body.position.y = 1.4 + Math.sin(this.life * 4) * 0.1;
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < WEB_BLOB_PROXIMITY && game.player.rootCooldown <= 0) {
game.player.rootTime = WEB_DIRECT_ROOT;
game.player.rootCooldown = 1.0;
game.player.damage(WEB_BLOB_DAMAGE, this.body.position);
}
}
const dx = this.target.x - this.body.position.x;
const dz = this.target.z - this.body.position.z;
if (this.life <= 0 || dx * dx + dz * dz < 0.25) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+314
View File
@@ -0,0 +1,314 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const SHOOT_INTERVAL = 2.5;
const SHOOT_RANGE = 18;
const BOLT_SPEED = 9;
const BOLT_DAMAGE = 12;
const BOLT_GRAVITY = 10;
const CLOUD_RADIUS = 2.4;
const CLOUD_DURATION = 3;
const CLOUD_TICK = 0.7;
const CLOUD_TICK_DAMAGE = 4;
const HIT_FLASH_TIME = 0.14;
// Sporen-Pilz: stationär, schießt Bogen-Sporen; am Einschlag entsteht eine Sporenwolke (DoT)
export class SporeMushroom extends Enemy {
private model: THREE.Group;
private stalkMat!: THREE.MeshToonMaterial;
private capMat!: THREE.MeshToonMaterial;
private glowMat!: THREE.MeshBasicMaterial;
private cap!: THREE.Mesh;
private shootTimer = 1 + Math.random();
private swayTime = Math.random() * Math.PI * 2;
private hitFlash = 0;
private bolts: SporeBolt[] = [];
private clouds: SporeCloud[] = [];
constructor() {
super();
this.health = 70;
this.speed = 0;
this.xp = 25;
this.contactDps = 0;
this.immovable = true;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.stalkMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
this.capMat = new THREE.MeshToonMaterial({ color: 0x55aa44 });
this.glowMat = new THREE.MeshBasicMaterial({ color: 0x88ff66 });
// Stiel
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.32, 0.9, 10), this.stalkMat);
stalk.position.y = 0.45;
stalk.castShadow = true;
this.registerFadeMesh(stalk);
this.model.add(stalk);
// Hut
this.cap = new THREE.Mesh(new THREE.SphereGeometry(0.6, 18, 14), this.capMat);
this.cap.scale.set(1, 0.7, 1);
this.cap.position.y = 1.05;
this.cap.castShadow = true;
this.registerFadeMesh(this.cap);
this.model.add(this.cap);
// Glüh-Flecken
for (const [dx, dz] of [[0, 0], [0.3, 0.12], [-0.28, 0.18], [0.05, -0.32]] as const) {
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.glowMat);
spot.scale.set(1, 0.4, 1);
spot.position.set(dx, 1.32, dz);
this.model.add(spot);
}
// Augen am Stiel (+Z)
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x224422 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 5), eyeMat);
eye.position.set(side * 0.16, 0.6, 0.3);
this.model.add(eye);
}
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.9, sink: 0.3 });
}
update(dt: number, game: Game) {
this.swayTime += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.capMat.color.set(0xffffff);
this.stalkMat.color.set(0xffffff);
} else {
this.capMat.color.set(0x55aa44);
this.stalkMat.color.set(0xe8dcc0);
}
// Leichtes Schwanken + Puls
this.model.rotation.z = Math.sin(this.swayTime * 1.5) * 0.05;
const pulse = 1 + Math.sin(this.swayTime * 4) * 0.05;
this.cap.scale.set(1, 0.7 * pulse, 1);
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
this.shootTimer -= dt;
if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) {
this.shootTimer = SHOOT_INTERVAL + Math.random() * 0.5;
const from = new THREE.Vector3(this.body.position.x, 1.3, this.body.position.z);
const target = playerPos.clone();
const bolt = new SporeBolt(from, target);
game.scene.add(bolt.body);
this.bolts.push(bolt);
playSound('spawn', 0.3);
}
for (let i = this.bolts.length - 1; i >= 0; i--) {
const bolt = this.bolts[i];
if (!bolt.update(dt, game)) {
this.bolts.splice(i, 1);
if (bolt.landed) {
const cloud = new SporeCloud(bolt.body.position, CLOUD_RADIUS, CLOUD_DURATION);
game.scene.add(cloud.body);
this.clouds.push(cloud);
}
bolt.dispose();
}
}
for (let i = this.clouds.length - 1; i >= 0; i--) {
const cloud = this.clouds[i];
if (!cloud.update(dt, game)) {
this.clouds.splice(i, 1);
cloud.dispose();
}
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
for (const bolt of this.bolts) bolt.dispose();
this.bolts = [];
for (const cloud of this.clouds) cloud.dispose();
this.clouds = [];
}
}
// Bogen-Spore: ballistisches Projektil (parabelförmig zum Zielpunkt)
class SporeBolt {
body: THREE.Group;
private vel: THREE.Vector3;
private life = 5;
dead = false;
landed = false;
constructor(from: THREE.Vector3, target: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.copy(from);
// Ballistik: Flugzeit aus horizontaler Distanz, Abwärts-Geschwindigkeit passend
const dx = target.x - from.x;
const dz = target.z - from.z;
const horiz = Math.hypot(dx, dz);
const t = Math.max(0.35, horiz / BOLT_SPEED);
const vy = (target.y - from.y + 0.5 * BOLT_GRAVITY * t * t) / t;
this.vel = new THREE.Vector3(dx / t, vy, dz / t);
const glowMat = new THREE.MeshBasicMaterial({
color: 0x88ff66,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.24, 8, 6), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshToonMaterial({ color: 0x3a8a2a });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.13, 6, 5), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
this.vel.y -= BOLT_GRAVITY * dt;
this.body.position.addScaledVector(this.vel, dt);
if (this.body.position.y <= 0.1) {
this.body.position.y = 0.1;
this.landed = true;
return false;
}
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < 1.0) {
game.player.damage(BOLT_DAMAGE, this.body.position);
this.landed = true;
return false;
}
}
if (this.life <= 0) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
// Sporenwolke: Boden-DoT wie die Tintenpfütze, aber grün
class SporeCloud {
body: THREE.Group;
private life: number;
private tickTimer = 0;
private puffs: THREE.Mesh[] = [];
constructor(position: THREE.Vector3, radius: number, duration: number) {
this.life = duration;
this.body = new THREE.Group();
this.body.position.set(position.x, 0.1, position.z);
const puffGeom = new THREE.SphereGeometry(1, 10, 8);
const puffMat = new THREE.MeshToonMaterial({
color: 0x55cc44,
transparent: true,
opacity: 0.32,
depthWrite: false,
});
for (let i = 0; i < 6; i++) {
const puff = new THREE.Mesh(puffGeom, puffMat);
const a = (i / 6) * Math.PI * 2;
puff.position.set(
Math.cos(a) * radius * 0.4,
0.35 + Math.random() * 0.3,
Math.sin(a) * radius * 0.4
);
puff.scale.setScalar(radius * (0.2 + Math.random() * 0.12));
this.body.add(puff);
this.puffs.push(puff);
}
}
update(dt: number, game: Game): boolean {
this.life -= dt;
if (this.life <= 0) return false;
const fade = Math.min(1, this.life / 0.6);
for (const puff of this.puffs) {
(puff.material as THREE.MeshToonMaterial).opacity = 0.32 * fade;
puff.rotation.y += dt * 0.3;
}
if (game.player.health > 0) {
const dist = Math.hypot(
game.player.body.position.x - this.body.position.x,
game.player.body.position.z - this.body.position.z
);
if (dist < 2.4) {
this.tickTimer -= dt;
if (this.tickTimer <= 0) {
this.tickTimer = CLOUD_TICK;
game.player.damage(CLOUD_TICK_DAMAGE, this.body.position);
}
}
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const puff of this.puffs) {
puff.geometry.dispose();
(puff.material as THREE.Material).dispose();
}
}
}
+230
View File
@@ -0,0 +1,230 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const STRAFE_DIST = 3.2;
const SHELL_IN = 0.4;
const SHELL_TIME = 2.0;
const SHELL_OUT = 0.4;
const SHELL_CYCLE = 7;
const HIT_FLASH_TIME = 0.14;
type State = 'strafe' | 'shellin' | 'shell' | 'shellout';
// Schildkröte: Frontpanzerung blockt Schaden komplett, zieht sich periodisch in den Panzer zurück
export class Turtle extends Enemy {
private model: THREE.Group;
private shellMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private skinMat!: THREE.MeshToonMaterial;
private shell!: THREE.Mesh;
private headMesh!: THREE.Mesh;
private legs: THREE.Mesh[] = [];
private eyeMat!: THREE.MeshBasicMaterial;
private state: State = 'strafe';
private stateTimer = 0;
private shellTimer = SHELL_CYCLE * 0.6;
private side = Math.random() < 0.5 ? 1 : -1;
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor() {
super();
this.health = 160;
this.speed = 2.8;
this.xp = 30;
this.contactDps = 18;
this.contactRadius = 1.8;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.shellMat = new THREE.MeshToonMaterial({ color: 0x55aa55 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x2f5c2f });
this.skinMat = new THREE.MeshToonMaterial({ color: 0x99bb77 });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0x223322 });
// Panzer (Kuppel)
this.shell = new THREE.Mesh(new THREE.SphereGeometry(0.85, 18, 12, 0, Math.PI * 2, 0, Math.PI / 2), this.shellMat);
this.shell.position.y = 0.6;
this.shell.castShadow = true;
this.registerFadeMesh(this.shell);
this.model.add(this.shell);
// Panzer-Platten
for (let i = 0; i < 4; i++) {
const plate = new THREE.Mesh(
new THREE.BoxGeometry(0.5, 0.08, 0.3),
this.darkMat
);
const a = (i / 4) * Math.PI * 2;
plate.position.set(Math.cos(a) * 0.55, 0.85, Math.sin(a) * 0.55);
plate.rotation.y = -a;
plate.scale.set(1.3, 1, 1);
this.registerFadeMesh(plate);
this.model.add(plate);
}
// Beine
for (const side of [-1, 1]) {
for (const back of [true, false]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 0.25, 6), this.skinMat);
leg.position.set(side * 0.55, 0.15, back ? -0.45 : 0.5);
this.registerFadeMesh(leg);
this.model.add(leg);
this.legs.push(leg);
}
}
// Kopf (+Z)
this.headMesh = new THREE.Mesh(new THREE.SphereGeometry(0.22, 10, 8), this.skinMat);
this.headMesh.scale.set(1, 0.8, 1.3);
this.headMesh.position.set(0, 0.35, 0.9);
this.registerFadeMesh(this.headMesh);
this.model.add(this.headMesh);
// Augen (+Z)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), this.eyeMat);
eye.position.set(side * 0.12, 0.45, 1.05);
this.model.add(eye);
}
// Schwanzstummel (-Z)
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.3, 6), this.skinMat);
tail.rotation.x = Math.PI / 2;
tail.position.set(0, 0.2, -0.95);
this.registerFadeMesh(tail);
this.model.add(tail);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.0, sink: 0.4 });
}
update(dt: number, game: Game) {
this.time += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.shellMat.color.set(0xffffff);
this.skinMat.color.set(0xffffff);
} else {
this.shellMat.color.set(0x55aa55);
this.skinMat.color.set(0x99bb77);
}
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
switch (this.state) {
case 'strafe':
this.stateTimer -= dt;
this.shellTimer -= dt;
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
if (this.knockback > 0) {
this.knockback -= dt;
const away = dir.clone().multiplyScalar(-this.speed);
this.body.position.x += away.x * dt;
this.body.position.z += away.z * dt;
} else {
// Seitwärts kreisen (Panzerfront zeigt zum Spieler)
if (Math.random() < dt * 0.25) this.side *= -1;
const perp = new THREE.Vector3(-dir.z, 0, dir.x);
const move = perp.multiplyScalar(this.side * this.speed);
this.body.position.x += move.x * dt;
this.body.position.z += move.z * dt;
}
game.clampToArena(this.body.position);
}
if (this.shellTimer <= 0) {
this.shellTimer = SHELL_CYCLE;
this.state = 'shellin';
this.stateTimer = SHELL_IN;
playSound('spawn', 0.4);
}
break;
case 'shellin':
this.stateTimer -= dt;
this.shell.scale.y = THREE.MathUtils.lerp(this.shell.scale.y, 1.25, 0.25);
this.headMesh.visible = this.shell.scale.y < 1.1;
if (this.stateTimer <= 0) {
this.state = 'shell';
this.stateTimer = SHELL_TIME;
this.shell.scale.y = 1.25;
this.headMesh.visible = false;
for (const leg of this.legs) leg.visible = false;
}
break;
case 'shell':
this.stateTimer -= dt;
if (this.stateTimer <= 0) {
this.state = 'shellout';
this.stateTimer = SHELL_OUT;
this.headMesh.visible = true;
for (const leg of this.legs) leg.visible = true;
}
break;
case 'shellout':
this.stateTimer -= dt;
this.shell.scale.y = THREE.MathUtils.lerp(this.shell.scale.y, 1, 0.25);
if (this.stateTimer <= 0) {
this.state = 'strafe';
this.shell.scale.y = 1;
}
break;
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
// Im Panzer komplett unverwundbar
if (this.state === 'shell' || this.state === 'shellin') return;
const src = sourcePos ?? game.player.body.position;
const front = new THREE.Vector3(0, 0, 1).applyQuaternion(this.body.quaternion);
front.y = 0;
front.normalize();
const incoming = new THREE.Vector3().subVectors(src, this.body.position);
incoming.y = 0;
if (incoming.lengthSq() < 0.0001) incoming.copy(front);
incoming.normalize();
// Frontpanzerung blockt kompletten Schaden
if (front.dot(incoming) > 0.3) {
game.spawnDamageText('PANZER!', this.body.position, '#88cc66');
playSound('sword_hit1', 0.5);
return;
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.beginFadeDeath();
}
}
+105
View File
@@ -0,0 +1,105 @@
import * as THREE from 'three';
import type { Game } from './Game';
const ROOT_DURATION = 0.35;
const ROOT_TICK = 1.0;
const ROOT_COOLDOWN = 1.0;
const FADE_TIME = 0.6;
export class WebPatch {
body: THREE.Group;
private radius: number;
private life: number;
private rootTick = 0;
private meshes: THREE.Mesh[] = [];
constructor(position: THREE.Vector3, radius = 3, duration = 6) {
this.radius = radius;
this.life = duration;
this.body = new THREE.Group();
this.body.position.set(position.x, 0.08, position.z);
const mat = new THREE.MeshToonMaterial({
color: 0xdfe6ff,
transparent: true,
opacity: 0.55,
depthWrite: false,
});
const disc = new THREE.Mesh(new THREE.CircleGeometry(radius, 24), mat);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.01;
this.body.add(disc);
this.meshes.push(disc);
// Radiale Fäden
const threadMat = new THREE.MeshToonMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const thread = new THREE.Mesh(
new THREE.BoxGeometry(radius * 2, 0.03, 0.05),
threadMat
);
thread.rotation.y = -a;
thread.position.y = 0.02;
this.body.add(thread);
this.meshes.push(thread);
}
// Konzentrische Ringe
for (let r = 1; r <= 3; r++) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(r * radius / 4 - 0.04, r * radius / 4 + 0.04, 24),
threadMat
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.025;
this.body.add(ring);
this.meshes.push(ring);
}
}
update(dt: number, game: Game): boolean {
this.life -= dt;
if (this.life <= 0) return false;
const fade = Math.min(1, this.life / FADE_TIME);
for (const mesh of this.meshes) {
(mesh.material as THREE.MeshToonMaterial).opacity =
(mesh === this.meshes[0] ? 0.55 : 0.7) * fade;
}
if (game.player.health > 0) {
const dist = Math.hypot(
game.player.body.position.x - this.body.position.x,
game.player.body.position.z - this.body.position.z
);
if (dist < this.radius) {
// Gepulste Fessel: kurzer Root, dann Lücke zum Rauslaufen.
// rootCooldown verhindert Endlos-Fessel durch überlappende Netze.
this.rootTick -= dt;
if (this.rootTick <= 0 && game.player.rootCooldown <= 0) {
game.player.rootTime = ROOT_DURATION;
game.player.rootCooldown = ROOT_COOLDOWN;
this.rootTick = ROOT_TICK;
}
} else {
this.rootTick = 0;
}
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const mesh of this.meshes) {
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
}
}
}
+257
View File
@@ -0,0 +1,257 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const IDLE_DIST_MIN = 7;
const IDLE_DIST_MAX = 11;
const SPORE_COOLDOWN = 3;
const SPORE_SPEED = 5;
const SPORE_HOMING = 1.6;
const SPORE_DAMAGE = 8;
const SPORE_SLOW = 1.5;
const SPORE_HIT_RADIUS = 1.0;
const SPORE_LIFE = 4;
const FLOAT_HEIGHT = 1.2;
const HIT_FLASH_TIME = 0.14;
// Irrlicht: schwebender Caster, schießt langsame Homing-Sporen (Slow)
export class Wisp extends Enemy {
private model: THREE.Group;
private glowMat!: THREE.MeshBasicMaterial;
private coreMat!: THREE.MeshToonMaterial;
private tailMat!: THREE.MeshBasicMaterial;
private tail!: THREE.Mesh;
private shadow!: THREE.Mesh;
private shadowMat!: THREE.MeshBasicMaterial;
private sporeTimer = 1.5 + Math.random();
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
private spores: WispSpore[] = [];
constructor() {
super();
this.health = 35;
this.speed = 3.8;
this.xp = 18;
this.contactDps = 8;
this.contactRadius = 1.6;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.glowMat = new THREE.MeshBasicMaterial({
color: 0xaaffcc,
transparent: true,
opacity: 0.55,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.45, 12, 10), this.glowMat);
this.model.add(glow);
this.coreMat = new THREE.MeshToonMaterial({ color: 0x66ffaa, emissive: 0x114422 });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.28, 10, 8), this.coreMat);
this.model.add(core);
// Schweif (verjüngter Kegel, wackelt)
this.tailMat = new THREE.MeshBasicMaterial({
color: 0x88ffbb,
transparent: true,
opacity: 0.4,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
this.tail = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.7, 6), this.tailMat);
this.tail.rotation.x = Math.PI / 2;
this.tail.position.set(0, 0, -0.55);
this.model.add(this.tail);
// Boden-Schatten
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.22,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.4, 16), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.8, shrink: 0.7 });
}
update(dt: number, game: Game) {
this.time += dt;
// Schweben + Pulsieren
this.body.position.y = FLOAT_HEIGHT + Math.sin(this.time * 2.4) * 0.25;
this.shadow.position.y = 0.06 - this.body.position.y;
const pulse = 1 + Math.sin(this.time * 5) * 0.15;
this.model.scale.setScalar(pulse);
this.glowMat.opacity = 0.4 + Math.abs(Math.sin(this.time * 5)) * 0.2;
this.tail.rotation.z = Math.sin(this.time * 3) * 0.3;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.coreMat.color.set(0xffffff);
} else {
this.coreMat.color.set(0x66ffaa);
}
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
// Abstand halten: schwebt in mittlerer Reichweite
let move = new THREE.Vector3();
if (dist < IDLE_DIST_MIN) {
move.copy(dir).multiplyScalar(-1);
} else if (dist > IDLE_DIST_MAX) {
move.copy(dir);
}
if (this.knockback > 0) {
move = dir.clone().multiplyScalar(-1);
this.knockback -= dt;
}
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar(this.speed);
this.body.position.x += move.x * dt;
this.body.position.z += move.z * dt;
game.clampToArena(this.body.position);
}
}
// Spore schießen
this.sporeTimer -= dt;
if (this.sporeTimer <= 0 && game.player.health > 0) {
this.sporeTimer = SPORE_COOLDOWN + Math.random() * 0.5;
const from = new THREE.Vector3(this.body.position.x, this.body.position.y, this.body.position.z);
const spore = new WispSpore(from);
game.scene.add(spore.body);
this.spores.push(spore);
playSound('spawn', 0.35);
}
// Sporen aktualisieren
for (let i = this.spores.length - 1; i >= 0; i--) {
if (!this.spores[i].update(dt, game)) {
this.spores[i].dispose();
this.spores.splice(i, 1);
}
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupSpores();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupSpores();
}
private cleanupSpores() {
for (const s of this.spores) s.dispose();
this.spores = [];
}
}
// Spore: langsames Homing-Projektil, verlangsamt beim Treffen
class WispSpore {
body: THREE.Group;
private vel: THREE.Vector3;
private life = SPORE_LIFE;
dead = false;
constructor(from: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.copy(from);
this.vel = new THREE.Vector3(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
);
const glowMat = new THREE.MeshBasicMaterial({
color: 0x88ffbb,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 8), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshToonMaterial({ color: 0x44cc77 });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
// Sanftes Homing auf den Spieler
if (game.player.health > 0) {
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position)
.normalize();
const desired = toPlayer.multiplyScalar(SPORE_SPEED);
this.vel.lerp(desired, Math.min(1, dt * SPORE_HOMING));
}
this.body.position.addScaledVector(this.vel, dt);
this.body.position.y = Math.max(0.3, this.body.position.y);
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < SPORE_HIT_RADIUS) {
game.player.damage(SPORE_DAMAGE, this.body.position);
game.player.slowTimer = SPORE_SLOW;
this.dead = true;
return false;
}
}
if (this.life <= 0) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
// Modell-Viewer: rendert Gegner-/Boss-Modelle isoliert auf einer Bühne mit
// heller Beleuchtung und Turntable-Kamera — fuer visuelle Modell-Checks ohne
// Spiel-UI, Cheats oder Bosskaempfe. Siehe AGENTS.md "Modell-Viewer".
//
// URL: viewer.html?m=<id> Modell waehlen
// viewer.html?m=<id>&still=1 statische Pose (nur updateAnimation, kein update)
import * as THREE from 'three';
import type { Game } from './Game';
import type { Enemy } from './Enemy';
import { Spider, initSpiderModel } from './Spider';
import { Ghost } from './Ghost';
import { MazeGuard } from './MazeGuard';
import { Frog } from './Frog';
import { Bat } from './Bat';
import { BeeSwarm } from './BeeSwarm';
import { Slime } from './Slime';
import { Brute } from './Brute';
import { Golem } from './Golem';
import { Crab } from './Crab';
import { Turret } from './Turret';
import { Shaman } from './Shaman';
import { Archer } from './Archer';
import { Bomber } from './Bomber';
import { Wisp } from './Wisp';
import { Turtle } from './Turtle';
import { SporeMushroom } from './SporeMushroom';
import { Krake } from './Krake';
import { Minotaurus } from './Minotaurus';
import { SpiderQueen } from './SpiderQueen';
import { FrostGiant } from './FrostGiant';
import { Necromancer } from './Necromancer';
import { Dragon } from './Dragon';
import { Gnome } from './Gnome';
const VIEWER_MODELS: { id: string; create: () => Enemy | Promise<Enemy> }[] = [
{ id: 'spider', create: async () => { await initSpiderModel(); return new Spider(); } },
{ id: 'ghost', create: () => new Ghost() },
{ id: 'frog', create: () => new Frog() },
{ id: 'bat', create: () => new Bat() },
{ id: 'bee', create: () => new BeeSwarm() },
{ id: 'slime', create: () => new Slime() },
{ id: 'brute', create: () => new Brute() },
{ id: 'crab', create: () => new Crab() },
{ id: 'turret', create: () => new Turret() },
{ id: 'shaman', create: () => new Shaman() },
{ id: 'archer', create: () => new Archer() },
{ id: 'bomber', create: () => new Bomber() },
{ id: 'wisp', create: () => new Wisp() },
{ id: 'turtle', create: () => new Turtle() },
{ id: 'spore', create: () => new SporeMushroom() },
{ id: 'golem', create: () => new Golem() },
{ id: 'krake', create: () => new Krake() },
{ id: 'minotaurus', create: () => new Minotaurus() },
{ id: 'spiderqueen', create: async () => { await initSpiderModel(); return new SpiderQueen(); } },
{ id: 'frostgiant', create: () => new FrostGiant() },
{ id: 'necromancer', create: () => new Necromancer() },
{ id: 'dragon', create: () => new Dragon() },
{ id: 'gnome', create: () => new Gnome(250, 90) },
{ id: 'mazeguard', create: () => new MazeGuard() },
];
// --- Buehne ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a2030);
const camera = new THREE.PerspectiveCamera(
50, window.innerWidth / window.innerHeight, 0.1, 300
);
// Bewusst heller als das Spiel, damit Formen gut sichtbar sind
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(8, 14, 6);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.left = -16;
dirLight.shadow.camera.right = 16;
dirLight.shadow.camera.top = 16;
dirLight.shadow.camera.bottom = -16;
scene.add(dirLight);
const ground = new THREE.Mesh(
new THREE.CircleGeometry(40, 48),
new THREE.MeshLambertMaterial({ color: 0x2a3a2a })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(80, 80, 0x44608a, 0x334455);
grid.position.y = 0.02;
scene.add(grid);
// --- Stub-Spieler (8 m entfernt: Blick-/Angriffsziel) ---
// Nur ein flacher Boden-Ring als Markierung — eine Kugel wuerde die Sicht
// auf Modelle verdecken, wenn Mobs hineingelaufen kommen.
const stubPlayer = {
body: new THREE.Group(),
health: 1000,
pullTime: 0,
pullDir: new THREE.Vector3(),
stunTime: 0,
rootTime: 0,
rootCooldown: 0,
slowTimer: 0,
damage: () => {},
};
stubPlayer.body.position.set(0, 0, 8);
const playerMarker = new THREE.Mesh(
new THREE.RingGeometry(0.5, 0.8, 24),
new THREE.MeshBasicMaterial({ color: 0x44aa44, side: THREE.DoubleSide })
);
playerMarker.rotation.x = -Math.PI / 2;
playerMarker.position.y = 0.05;
stubPlayer.body.add(playerMarker);
scene.add(stubPlayer.body);
// --- Stub-Game: deckt die game.*-Oberflaeche aller Gegner ab (per grep ermittelt) ---
const stubGame = {
scene,
player: stubPlayer,
enemies: [],
traps: [],
gasClouds: [],
fireballs: [],
slashEffects: [],
maze: null,
groundPoint: stubPlayer.body.position,
clampToArena: (v: THREE.Vector3) => v,
triggerCameraShake: () => {},
triggerInkVignette: () => {},
spawnDamageText: () => {},
spawnEnemyProjectile: () => {},
spawnEnemyInstance: () => {},
removeEnemy: () => {},
onDamageDealt: () => {},
onPlayerHurt: () => {},
destroyProjectilesInArc: () => {},
} as unknown as Game;
// --- Modell-Wechsel + Turntable ---
const params = new URLSearchParams(location.search);
const still = params.has('still');
let current: Enemy | null = null;
let orbitAngle = 0;
let fitRadius = 8;
let fitHeight = 2;
let fitCenterY = 1;
let fitTimer = 0;
let frame = 0;
const nameEl = document.getElementById('name')!;
const uiEl = document.getElementById('ui')!;
function refit() {
if (!current) return;
// Auto-Fit nur ueber Modell-Meshes: grosse Effekt-Geometrien (Heil-Ring,
// Telegrafen) wuerden sonst die Kamera-Distanz dominieren
const box = new THREE.Box3();
const tmp = new THREE.Box3();
current.body.updateMatrixWorld(true);
current.body.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
if (!obj.geometry.boundingSphere) obj.geometry.computeBoundingSphere();
if ((obj.geometry.boundingSphere?.radius ?? 0) < 5) {
tmp.setFromObject(obj);
box.union(tmp);
}
}
});
if (box.isEmpty()) return;
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z, 2);
fitRadius = maxDim * 1.15 + 3;
// deutlich erhoehte Kamera, damit Fluegel/Flaechen nicht kantenzeitig wirken
fitHeight = center.y + maxDim * 1.0;
fitCenterY = center.y;
}
async function show(id: string) {
const entry = VIEWER_MODELS.find((m) => m.id === id) ?? VIEWER_MODELS[0];
if (current) {
try {
current.dispose();
} catch {
// dispose ist nur Kosmetik
}
scene.remove(current.body);
current = null;
}
current = await entry.create();
scene.add(current.body);
refit();
nameEl.textContent = current.displayName || entry.id;
history.replaceState(null, '', `?m=${entry.id}${still ? '&still=1' : ''}`);
for (const btn of uiEl.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.id === entry.id);
}
}
for (const m of VIEWER_MODELS) {
const btn = document.createElement('button');
btn.textContent = m.id;
btn.dataset.id = m.id;
btn.addEventListener('click', () => show(m.id));
uiEl.appendChild(btn);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
if (current) {
if (!still) current.update(dt, stubGame);
current.updateAnimation(dt);
// Auto-Fit alle 30 Frames nachjustieren (States aendern die Ausdehnung)
fitTimer += dt;
if (frame % 30 === 0 || fitTimer > 2) {
fitTimer = 0;
refit();
}
orbitAngle += dt * 0.35;
const c = current.body.position;
camera.position.set(
c.x + Math.sin(orbitAngle) * fitRadius,
fitHeight,
c.z + Math.cos(orbitAngle) * fitRadius
);
camera.lookAt(c.x, fitCenterY * 0.85, c.z);
}
frame++;
renderer.render(scene, camera);
}
show(params.get('m') ?? 'dragon');
animate();
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wackelpeter Modell-Viewer</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1a2030; }
canvas { display: block; }
#ui {
position: absolute; top: 10px; left: 10px; max-width: 320px;
display: flex; flex-wrap: wrap; gap: 4px; align-content: flex-start;
font-family: monospace; pointer-events: none;
}
#name {
width: 100%; color: #ffd54a; font-size: 16px; margin: 0 0 6px;
text-shadow: 0 1px 2px #000;
}
#ui button {
pointer-events: auto; cursor: pointer; font-family: monospace; font-size: 11px;
color: #dfe8ff; background: #2a3550cc; border: 1px solid #4a5a80;
padding: 3px 8px; border-radius: 3px;
}
#ui button:hover { background: #3a4a70; }
#ui button.active { background: #6a5a20; border-color: #ffd54a; }
#hint { width: 100%; color: #8899bb; font-size: 11px; margin-top: 6px; }
</style>
</head>
<body>
<div id="ui">
<h1 id="name"></h1>
<div id="hint">?m=&lt;id&gt; Modell · &amp;still=1 statische Pose</div>
</div>
<script type="module" src="/src/viewer.ts"></script>
</body>
</html>
+6
View File
@@ -5,6 +5,12 @@ export default defineConfig({
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
input: {
main: 'index.html',
viewer: 'viewer.html',
},
},
},
server: {
host: true,