Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8065104d8a | ||
|
|
b05835df74 | ||
|
|
d1ba3fc9c3 | ||
|
|
1f311d3208 | ||
|
|
e6a370f952 | ||
|
|
2bffefb02c |
@@ -19,6 +19,22 @@ npm run dev # Dev-Server mit HMR
|
|||||||
- `Makefile`: `make web`/`make dev`/`make build` (Container-Build via Podman).
|
- `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.
|
- 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)
|
## Architektur (web/src)
|
||||||
|
|
||||||
| Datei | Inhalt |
|
| Datei | Inhalt |
|
||||||
@@ -34,9 +50,10 @@ npm run dev # Dev-Server mit HMR
|
|||||||
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
|
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
|
||||||
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
|
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
|
||||||
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
|
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
|
||||||
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` | 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 |
|
| `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 |
|
| `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/` |
|
| `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").
|
- **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen").
|
||||||
- **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen.
|
- **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen.
|
||||||
- **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf).
|
- **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf).
|
||||||
- **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 10–24 m um den Spieler; stationäre Gegner (`immovable`), die >8 s außerhalb des Bildschirms sind (NDC-Projektion `isOnScreen`), werden wieder in Sicht teleportiert (`updateOffscreenReposition`).
|
||||||
|
- **Labyrinth-Zwischenboss**: `Game.MAZE_WAVES` (5, 15, 25, …) ersetzt die Welle komplett (`startMazeWave`/`updateMaze`/`endMazeWave`); Zeitlimit (75 s − 5 s/Level, Min 50 s) → bei Ablauf wird das Labyrinth neu generiert und der Spieler zur Startecke gesetzt; Gnome nur mit Sichtlinie verletzbar (`hasLineOfSight`); `mazeActive` pausiert Spawn-Logik und Off-Screen-Reposition; Aufräumen über `disposeMaze()` in `resetWorld`; `#maze-arrow`-Overlay zeigt zum Ziel.
|
||||||
|
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung), `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf) und `rootTime` (Netz-Fessel: keine Bewegung, Angriffe bleiben möglich). Alles wird in `Player.reset()` zurückgesetzt.
|
||||||
- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig).
|
- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig).
|
||||||
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
|
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
|
||||||
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
|
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
|
||||||
|
|||||||
Executable
+71
@@ -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
|
||||||
@@ -120,6 +120,15 @@
|
|||||||
text-align: center; line-height: 16px; color: #fff; font-size: 11px;
|
text-align: center; line-height: 16px; color: #fff; font-size: 11px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
#maze-arrow {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
color: #ffcc44; font-size: 30px; line-height: 40px; text-align: center;
|
||||||
|
text-shadow: 0 0 8px #ff8800, 0 0 2px #000;
|
||||||
|
font-family: monospace;
|
||||||
|
pointer-events: none; user-select: none;
|
||||||
|
opacity: 0; visibility: hidden;
|
||||||
|
}
|
||||||
#game-over {
|
#game-over {
|
||||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
|
font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
|
||||||
@@ -286,6 +295,7 @@
|
|||||||
<div id="damage-vignette"></div>
|
<div id="damage-vignette"></div>
|
||||||
<div id="low-hp-vignette"></div>
|
<div id="low-hp-vignette"></div>
|
||||||
<div id="ink-vignette"></div>
|
<div id="ink-vignette"></div>
|
||||||
|
<div id="maze-arrow">▲</div>
|
||||||
<div id="boss-bar">
|
<div id="boss-bar">
|
||||||
<div id="boss-bar-label">BOSS</div>
|
<div id="boss-bar-label">BOSS</div>
|
||||||
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
|
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
|
||||||
@@ -400,6 +410,16 @@
|
|||||||
<div id="changelog-panel">
|
<div id="changelog-panel">
|
||||||
<h2>Changelog</h2>
|
<h2>Changelog</h2>
|
||||||
<div id="changelog-list">
|
<div id="changelog-list">
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Labyrinth-Zwischenboss (Welle 5, 15, 25, …): generiertes Labyrinth, Gnom als Ziel, Zeitlimit, Wächter-Geister ab Level 2, Gnom 2x finden ab Welle 25, Pfeil zeigt den Weg, Boss-Modus-Buttons</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Level-Up-Bug behoben: genau 1 Skill-Punkt pro Level (vorher 2); Gegner werden erst nach den Bossen stärker, neue Monster erscheinen erst nach jedem Boss</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Balance: steilere XP-Kurve, weniger Boss-Modus-Skills, Feuerball abgeschwächt (Kettenblitz/Falle/Gaswolke gepuffert), Gegner-HP skaliert mit der Welle</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Arena verändert sich nach jedem Boss-Sieg (Boden, Wände, Nebel und Licht färben sich um)</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Neue Gegner werden nach den Bossen nach und nach freigeschaltet statt ab Welle 4-7</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Sechs neue Gegner: Schamane (heilt), Bogenschütze (Telegraf-Pfeile), Bomber (Explosion), Irrlicht (Slow-Sporen), Schildkröte (Frontpanzer + Rückzug) und Sporen-Pilz (Bogen-Sporen mit DoT-Wolke)</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>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>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>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>
|
<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>
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,35 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
interface Theme {
|
||||||
|
floorLight: number;
|
||||||
|
floorDark: number;
|
||||||
|
wall: number;
|
||||||
|
fog: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8 Stufen: Start + eine pro besiegten Boss (Golem, Krake, Minotaurus,
|
||||||
|
// Spinnenkönigin, Frostriese, Erz-Nekromant, Drache)
|
||||||
|
const THEMES: Theme[] = [
|
||||||
|
{ floorLight: 0x668866, floorDark: 0x557755, wall: 0x776677, fog: 0x0d1117 }, // Start (Grün)
|
||||||
|
{ floorLight: 0x8a7a55, floorDark: 0x7a6a48, wall: 0x776055, fog: 0x141210 }, // Golem (Erdig)
|
||||||
|
{ floorLight: 0x3a4a66, floorDark: 0x2f3d55, wall: 0x44506a, fog: 0x0a1220 }, // Krake (Tinte)
|
||||||
|
{ floorLight: 0x8a4a3a, floorDark: 0x6e3a2c, wall: 0x7a4438, fog: 0x180d0b }, // Minotaurus (Asche)
|
||||||
|
{ floorLight: 0x4a7a4a, floorDark: 0x3a643a, wall: 0x4a6a4a, fog: 0x0a160d }, // Spinnenkönigin (Gift)
|
||||||
|
{ floorLight: 0x88bbcc, floorDark: 0x6a9ab0, wall: 0x7aa0b5, fog: 0x0d1a24 }, // Frostriese (Eis)
|
||||||
|
{ floorLight: 0x6a4a8a, floorDark: 0x543a70, wall: 0x5a3f74, fog: 0x140a20 }, // Erz-Nekromant (Lila)
|
||||||
|
{ floorLight: 0xaa6a33, floorDark: 0x8a5428, wall: 0x8f5c34, fog: 0x1e0f06 }, // Drache (Glut)
|
||||||
|
];
|
||||||
|
|
||||||
|
const LERP_SPEED = 2;
|
||||||
|
|
||||||
export class Arena extends THREE.Group {
|
export class Arena extends THREE.Group {
|
||||||
|
private lightMats: THREE.MeshStandardMaterial[] = [];
|
||||||
|
private darkMats: THREE.MeshStandardMaterial[] = [];
|
||||||
|
private wallMats: THREE.MeshToonMaterial[] = [];
|
||||||
|
private targets:
|
||||||
|
| { floorLight: THREE.Color; floorDark: THREE.Color; wall: THREE.Color }
|
||||||
|
| null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -23,6 +52,7 @@ export class Arena extends THREE.Group {
|
|||||||
tile.position.set(x, -0.15, z);
|
tile.position.set(x, -0.15, z);
|
||||||
tile.receiveShadow = true;
|
tile.receiveShadow = true;
|
||||||
this.add(tile);
|
this.add(tile);
|
||||||
|
(isDark ? this.darkMats : this.lightMats).push(tileMat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +68,12 @@ export class Arena extends THREE.Group {
|
|||||||
this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
|
this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
|
||||||
this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
|
this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
|
||||||
}
|
}
|
||||||
|
for (const wall of this.children) {
|
||||||
|
const mesh = wall as THREE.Mesh;
|
||||||
|
if (mesh.material instanceof THREE.MeshToonMaterial) {
|
||||||
|
this.wallMats.push(mesh.material);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) {
|
private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) {
|
||||||
@@ -47,4 +83,30 @@ export class Arena extends THREE.Group {
|
|||||||
wall.receiveShadow = true;
|
wall.receiveShadow = true;
|
||||||
this.add(wall);
|
this.add(wall);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setzt die Ziel-Farben für eine Theme-Stufe und gibt die Nebel-Farbe zurück
|
||||||
|
applyTheme(stage: number): THREE.Color {
|
||||||
|
const t = THEMES[Math.min(Math.max(stage, 0), THEMES.length - 1)];
|
||||||
|
this.targets = {
|
||||||
|
floorLight: new THREE.Color(t.floorLight),
|
||||||
|
floorDark: new THREE.Color(t.floorDark),
|
||||||
|
wall: new THREE.Color(t.wall),
|
||||||
|
};
|
||||||
|
return new THREE.Color(t.fog);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lerpt die aktuellen Farben weich in Richtung Ziel-Theme
|
||||||
|
update(dt: number) {
|
||||||
|
if (!this.targets) return;
|
||||||
|
const t = Math.min(1, dt * LERP_SPEED);
|
||||||
|
for (const mat of this.lightMats) {
|
||||||
|
mat.color.lerp(this.targets.floorLight, t);
|
||||||
|
}
|
||||||
|
for (const mat of this.darkMats) {
|
||||||
|
mat.color.lerp(this.targets.floorDark, t);
|
||||||
|
}
|
||||||
|
for (const mat of this.wallMats) {
|
||||||
|
mat.color.lerp(this.targets.wall, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -4,7 +4,12 @@ import { Enemy } from './Enemy';
|
|||||||
|
|
||||||
export class Brute extends Enemy {
|
export class Brute extends Enemy {
|
||||||
protected model: THREE.Group;
|
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 stepTime = 0;
|
||||||
|
private walkAmp = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -21,7 +26,11 @@ export class Brute extends Enemy {
|
|||||||
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
|
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
|
||||||
const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 });
|
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);
|
const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat);
|
||||||
torso.position.y = 0.95;
|
torso.position.y = 0.95;
|
||||||
torso.castShadow = true;
|
torso.castShadow = true;
|
||||||
@@ -61,6 +70,26 @@ export class Brute extends Enemy {
|
|||||||
this.registerFadeMesh(eye);
|
this.registerFadeMesh(eye);
|
||||||
this.model.add(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() {
|
playAnim() {
|
||||||
@@ -81,6 +110,7 @@ export class Brute extends Enemy {
|
|||||||
.subVectors(game.player.body.position, this.body.position);
|
.subVectors(game.player.body.position, this.body.position);
|
||||||
toPlayer.y = 0;
|
toPlayer.y = 0;
|
||||||
const dist = toPlayer.length();
|
const dist = toPlayer.length();
|
||||||
|
let moved = false;
|
||||||
if (dist > 0.1) {
|
if (dist > 0.1) {
|
||||||
const dir = toPlayer.normalize();
|
const dir = toPlayer.normalize();
|
||||||
this.body.lookAt(
|
this.body.lookAt(
|
||||||
@@ -96,7 +126,16 @@ export class Brute extends Enemy {
|
|||||||
this.body.position.x += moveDir.x * dt;
|
this.body.position.x += moveDir.x * dt;
|
||||||
this.body.position.z += moveDir.z * dt;
|
this.body.position.z += moveDir.z * dt;
|
||||||
game.clampToArena(this.body.position);
|
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) {
|
protected onDeath(_game: Game) {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { Weapon } from './Weapon';
|
import { Weapon } from './Weapon';
|
||||||
import { Player } from './Player';
|
import { Player } from './Player';
|
||||||
import { lightningDamage, lightningJumps, lightningCooldown } from './Skills';
|
import { lightningDamage, lightningJumps, lightningJumpRange, lightningCooldown } from './Skills';
|
||||||
|
|
||||||
const CHAIN_FIRST_RANGE = 14;
|
const CHAIN_FIRST_RANGE = 14;
|
||||||
const CHAIN_JUMP_RANGE = 8;
|
|
||||||
const FLASH_TIME = 0.35;
|
const FLASH_TIME = 0.35;
|
||||||
|
|
||||||
export class ChainLightning extends Weapon {
|
export class ChainLightning extends Weapon {
|
||||||
@@ -25,6 +24,7 @@ export class ChainLightning extends Weapon {
|
|||||||
const game = player.game;
|
const game = player.game;
|
||||||
const damage = lightningDamage(level);
|
const damage = lightningDamage(level);
|
||||||
const maxJumps = lightningJumps(level);
|
const maxJumps = lightningJumps(level);
|
||||||
|
const jumpRange = lightningJumpRange(level);
|
||||||
|
|
||||||
// Chain through enemies, always the closest un-hit one
|
// Chain through enemies, always the closest un-hit one
|
||||||
const start = player.body.position.clone();
|
const start = player.body.position.clone();
|
||||||
@@ -35,7 +35,7 @@ export class ChainLightning extends Weapon {
|
|||||||
|
|
||||||
for (let i = 0; i < maxJumps; i++) {
|
for (let i = 0; i < maxJumps; i++) {
|
||||||
let best: import('./Enemy').Enemy | null = null;
|
let best: import('./Enemy').Enemy | null = null;
|
||||||
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : CHAIN_JUMP_RANGE;
|
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : jumpRange;
|
||||||
for (const enemy of game.enemies) {
|
for (const enemy of game.enemies) {
|
||||||
if (enemy.dead || hit.has(enemy)) continue;
|
if (enemy.dead || hit.has(enemy)) continue;
|
||||||
const dist = enemy.body.position.distanceTo(from);
|
const dist = enemy.body.position.distanceTo(from);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+397
-29
@@ -11,8 +11,21 @@ import { Brute } from './Brute';
|
|||||||
import { Golem } from './Golem';
|
import { Golem } from './Golem';
|
||||||
import { Krake } from './Krake';
|
import { Krake } from './Krake';
|
||||||
import { Minotaurus } from './Minotaurus';
|
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 { Crab } from './Crab';
|
||||||
import { Turret } from './Turret';
|
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 { EnemyProjectile } from './EnemyProjectile';
|
||||||
import { Sword } from './Sword';
|
import { Sword } from './Sword';
|
||||||
import { Staff } from './Staff';
|
import { Staff } from './Staff';
|
||||||
@@ -78,11 +91,46 @@ export class Game {
|
|||||||
spawns = 0;
|
spawns = 0;
|
||||||
killed = 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)
|
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
|
||||||
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
||||||
[10, { name: 'Golem', spawn: () => new Golem() }],
|
[10, { name: 'Golem', spawn: () => new Golem() }],
|
||||||
[20, { name: 'Krake', spawn: () => new Krake() }],
|
[20, { name: 'Krake', spawn: () => new Krake() }],
|
||||||
[30, { name: 'Minotaurus', spawn: () => new Minotaurus() }],
|
[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 bossFightActive = false;
|
||||||
private shakeTime = 0;
|
private shakeTime = 0;
|
||||||
@@ -219,6 +267,7 @@ export class Game {
|
|||||||
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
|
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
|
||||||
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
|
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
|
||||||
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
|
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
|
||||||
|
this.uiMazeArrow = document.getElementById('maze-arrow')!;
|
||||||
this.uiGameOver = document.getElementById('game-over')!;
|
this.uiGameOver = document.getElementById('game-over')!;
|
||||||
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
|
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
|
||||||
this.uiGameOverStats = document.getElementById('game-over-stats')!;
|
this.uiGameOverStats = document.getElementById('game-over-stats')!;
|
||||||
@@ -309,7 +358,8 @@ export class Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.code === 'Space' && this.state === 'playing'
|
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();
|
this.player.jump();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -433,8 +483,8 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupArena() {
|
private setupArena() {
|
||||||
const arena = new Arena();
|
this.arena = new Arena();
|
||||||
this.scene.add(arena);
|
this.scene.add(this.arena);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupPlayer() {
|
private setupPlayer() {
|
||||||
@@ -576,6 +626,15 @@ export class Game {
|
|||||||
btn.addEventListener('click', () => this.startBossFight(wave));
|
btn.addEventListener('click', () => this.startBossFight(wave));
|
||||||
this.uiBossModeButtons.appendChild(btn);
|
this.uiBossModeButtons.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
for (const wave of Game.MAZE_WAVES) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'menu-button';
|
||||||
|
btn.textContent = `Labyrinth (Welle ${wave})`;
|
||||||
|
btn.style.fontSize = '14px';
|
||||||
|
btn.style.padding = '8px 20px';
|
||||||
|
btn.addEventListener('click', () => this.startMazeFight(wave));
|
||||||
|
this.uiBossModeButtons.appendChild(btn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private showBossMode() {
|
private showBossMode() {
|
||||||
@@ -611,8 +670,18 @@ export class Game {
|
|||||||
this.beginRun();
|
this.beginRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
private grantRandomSkills(wave: number) {
|
startMazeFight(wave: number) {
|
||||||
const picks = Math.round(wave * 0.7);
|
if (this.state === 'playing' || this.state === 'levelup') return;
|
||||||
|
this.seed = Math.floor(Math.random() * 1000000);
|
||||||
|
this.resetWorld();
|
||||||
|
this.spawns = wave;
|
||||||
|
this.grantRandomSkills(wave, 0.25);
|
||||||
|
this.startMazeWave();
|
||||||
|
this.beginRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
private grantRandomSkills(wave: number, factor = 0.4) {
|
||||||
|
const picks = Math.round(wave * factor);
|
||||||
for (let i = 0; i < picks; i++) {
|
for (let i = 0; i < picks; i++) {
|
||||||
const offers = this.skillSystem.rollOffers();
|
const offers = this.skillSystem.rollOffers();
|
||||||
if (offers.length === 0) break;
|
if (offers.length === 0) break;
|
||||||
@@ -624,6 +693,157 @@ export class Game {
|
|||||||
this.syncSkillVisibility();
|
this.syncSkillVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Labyrinth-Zwischenboss ---
|
||||||
|
|
||||||
|
private isMazeWave(wave: number): boolean {
|
||||||
|
return Game.MAZE_WAVES.includes(wave);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMazeWave() {
|
||||||
|
this.disposeMaze();
|
||||||
|
this.maze = new Maze(this.scene);
|
||||||
|
this.mazeActive = true;
|
||||||
|
this.mazeLevel = Math.max(0, Math.floor((this.spawns - 5) / 10));
|
||||||
|
|
||||||
|
// Spieler zur Startecke
|
||||||
|
const start = this.maze.startPosition();
|
||||||
|
this.player.body.position.set(start.x, 0.5, start.z);
|
||||||
|
this.player.velocity.set(0, 0, 0);
|
||||||
|
|
||||||
|
// Ab Level 2 (Welle 25) muss der Gnom 2x gefunden werden
|
||||||
|
this.mazeGoalsLeft = this.mazeLevel >= 2 ? 2 : 1;
|
||||||
|
this.mazeTimer = Math.max(
|
||||||
|
Game.MAZE_MIN_TIME,
|
||||||
|
Game.MAZE_BASE_TIME - Game.MAZE_TIME_STEP * this.mazeLevel
|
||||||
|
);
|
||||||
|
this.mazeTotal = this.mazeTimer;
|
||||||
|
|
||||||
|
this.spawnMazeGnome();
|
||||||
|
this.spawnMazeGuards();
|
||||||
|
this.updateMazeArrow();
|
||||||
|
playSound('spawn', 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnMazeGnome() {
|
||||||
|
if (!this.maze) return;
|
||||||
|
const gnome = new Gnome(150 + 100 * this.mazeLevel, 60 + 30 * this.mazeLevel);
|
||||||
|
const goal = this.maze.goalPosition(this.mazeGoalsLeft - 1);
|
||||||
|
this.mazeGnome = gnome;
|
||||||
|
this.spawnEnemyInstance(gnome, goal.x, goal.z, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnMazeGuards() {
|
||||||
|
if (!this.maze) return;
|
||||||
|
const count = this.mazeLevel >= 2 ? 4 : this.mazeLevel >= 1 ? 2 : 0;
|
||||||
|
if (count <= 0) return;
|
||||||
|
const spots = this.maze.guardPositions(count);
|
||||||
|
for (const pos of spots) {
|
||||||
|
const guard = new MazeGuard();
|
||||||
|
this.mazeGuards.push(guard);
|
||||||
|
this.spawnEnemyInstance(guard, pos.x, pos.z, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMaze(dt: number) {
|
||||||
|
if (!this.mazeActive || !this.maze) return;
|
||||||
|
this.mazeTimer -= dt;
|
||||||
|
this.updateMazeArrow();
|
||||||
|
|
||||||
|
if (this.mazeTimer <= 0) {
|
||||||
|
// Fehlschlag: neues Labyrinth, zurück zur Startecke
|
||||||
|
playSound('explosion', 0.5);
|
||||||
|
this.spawnDamageText('ZEIT ABGELAUFEN!', this.player.body.position, '#ff6644');
|
||||||
|
this.triggerCameraShake(0.3, 0.3);
|
||||||
|
this.startMazeWave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gnom besiegt?
|
||||||
|
if (this.mazeGnome && this.mazeGnome.dead) {
|
||||||
|
this.mazeGoalsLeft--;
|
||||||
|
this.mazeGnome = null;
|
||||||
|
if (this.mazeGoalsLeft > 0) {
|
||||||
|
this.spawnMazeGnome();
|
||||||
|
} else {
|
||||||
|
this.endMazeWave(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private endMazeWave(success: boolean) {
|
||||||
|
this.mazeActive = false;
|
||||||
|
this.disposeMaze();
|
||||||
|
if (success) {
|
||||||
|
playSound('explosion', 0.7);
|
||||||
|
this.triggerCameraShake(0.3, 0.3);
|
||||||
|
}
|
||||||
|
this.spawnTimer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeMaze() {
|
||||||
|
if (this.maze) {
|
||||||
|
this.maze.dispose();
|
||||||
|
this.maze = null;
|
||||||
|
}
|
||||||
|
this.mazeActive = false;
|
||||||
|
// Lebenden Gnom (Fehlschlag/Neustart) entfernen; tote bleiben als Leichen
|
||||||
|
if (this.mazeGnome && !this.mazeGnome.dead) {
|
||||||
|
this.mazeGnome.dispose();
|
||||||
|
this.scene.remove(this.mazeGnome.body);
|
||||||
|
const idx = this.enemies.indexOf(this.mazeGnome);
|
||||||
|
if (idx >= 0) this.enemies.splice(idx, 1);
|
||||||
|
}
|
||||||
|
this.mazeGnome = null;
|
||||||
|
for (const guard of this.mazeGuards) {
|
||||||
|
if (!guard.dead) {
|
||||||
|
guard.dispose();
|
||||||
|
this.scene.remove(guard.body);
|
||||||
|
const idx = this.enemies.indexOf(guard);
|
||||||
|
if (idx >= 0) this.enemies.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.mazeGuards = [];
|
||||||
|
this.hideMazeArrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pfeil am Bildschirmrand zeigt zum aktuellen Ziel-Gnom
|
||||||
|
private updateMazeArrow() {
|
||||||
|
const el = this.uiMazeArrow;
|
||||||
|
if (!this.mazeActive || !this.mazeGnome || !this.maze) {
|
||||||
|
this.hideMazeArrow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const goal = this.mazeGnome.body.position;
|
||||||
|
const v = goal.clone().project(this.camera);
|
||||||
|
const onScreen = v.x > -1 && v.x < 1 && v.y > -1 && v.y < 1 && v.z < 1;
|
||||||
|
if (onScreen) {
|
||||||
|
this.hideMazeArrow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clx = THREE.MathUtils.clamp(v.x, -0.8, 0.8);
|
||||||
|
const cly = THREE.MathUtils.clamp(v.y, -0.8, 0.8);
|
||||||
|
const cx = window.innerWidth / 2;
|
||||||
|
const cy = window.innerHeight / 2;
|
||||||
|
// NDC-y zeigt nach oben, Bildschirm-y nach unten -> y flippen
|
||||||
|
const ax = clx * cx;
|
||||||
|
const ay = -cly * cy;
|
||||||
|
const gx = v.x * cx;
|
||||||
|
const gy = -v.y * cy;
|
||||||
|
el.style.left = `${cx + ax}px`;
|
||||||
|
el.style.top = `${cy + ay}px`;
|
||||||
|
el.style.transform = `translate(-50%, -50%) rotate(${Math.atan2(gy - ay, gx - ax)}rad)`;
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.visibility = 'visible';
|
||||||
|
el.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideMazeArrow() {
|
||||||
|
const el = this.uiMazeArrow;
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
el.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
private blurFocus() {
|
private blurFocus() {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
if (el instanceof HTMLElement) el.blur();
|
if (el instanceof HTMLElement) el.blur();
|
||||||
@@ -756,6 +976,19 @@ export class Game {
|
|||||||
this.spawns = 0;
|
this.spawns = 0;
|
||||||
this.killed = 0;
|
this.killed = 0;
|
||||||
this.bossFightActive = false;
|
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.shakeTime = 0;
|
||||||
this.pendingPicks = 0;
|
this.pendingPicks = 0;
|
||||||
this.hideLevelUp();
|
this.hideLevelUp();
|
||||||
@@ -877,10 +1110,11 @@ export class Game {
|
|||||||
this.syncSkillVisibility();
|
this.syncSkillVisibility();
|
||||||
this.blurFocus();
|
this.blurFocus();
|
||||||
|
|
||||||
if (this.pendingPicks > 0) {
|
// Genau 1 Skill-Punkt pro Level: Pick verbrauchen und nur weiter aufmachen,
|
||||||
this.pendingPicks--;
|
// wenn noch Picks übrig sind (Multi-Level durch XP-Überschuss) oder
|
||||||
this.openLevelUp();
|
// Rest-XP bereits die nächste Stufe erreicht hat.
|
||||||
} else if (this.skillSystem.hasPendingLevelUp()) {
|
this.pendingPicks = Math.max(0, this.pendingPicks - 1);
|
||||||
|
if (this.pendingPicks > 0 || this.skillSystem.hasPendingLevelUp()) {
|
||||||
this.openLevelUp();
|
this.openLevelUp();
|
||||||
} else {
|
} else {
|
||||||
this.hideLevelUp();
|
this.hideLevelUp();
|
||||||
@@ -922,8 +1156,13 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private spawnEnemy() {
|
private spawnEnemy() {
|
||||||
const x = (Math.random() - 0.5) * 80;
|
// Immer im Sichtbereich spawnen (Ring um den Spieler), damit auch
|
||||||
const z = (Math.random() - 0.5) * 80;
|
// 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);
|
const weights = this.enemyWeights(this.spawns);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -948,24 +1187,52 @@ export class Game {
|
|||||||
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
|
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
|
||||||
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
|
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
|
||||||
case 'turret': this.spawnEnemyInstance(new Turret(), 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> {
|
private enemyWeights(wave: number): Map<string, number> {
|
||||||
const w = new Map<string, number>();
|
const w = new Map<string, number>();
|
||||||
if (wave >= 7) {
|
// Neue Gegner erscheinen NUR nach den Boss-Wellen (10/20/30/40/50/60/70).
|
||||||
w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10)
|
// Vor dem ersten Boss (1-10) gibt es nur Spinnen und Frösche.
|
||||||
.set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5);
|
if (wave >= 71) {
|
||||||
} else if (wave >= 6) {
|
w.set('spider', 15).set('frog', 10).set('ghost', 10).set('slime', 8)
|
||||||
w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10)
|
.set('bat', 7).set('archer', 6).set('crab', 7).set('turret', 7)
|
||||||
.set('bat', 10).set('turret', 10);
|
.set('bomber', 6).set('bee', 5).set('brute', 4).set('shaman', 5)
|
||||||
} else if (wave >= 5) {
|
.set('spore', 5).set('wisp', 4).set('turtle', 4);
|
||||||
w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10)
|
} else if (wave >= 61) {
|
||||||
.set('bat', 10);
|
w.set('spider', 16).set('frog', 11).set('ghost', 11).set('slime', 8)
|
||||||
} else if (wave >= 4) {
|
.set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8)
|
||||||
w.set('spider', 55).set('ghost', 25).set('frog', 20);
|
.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) {
|
} else if (wave >= 3) {
|
||||||
w.set('spider', 75).set('ghost', 25);
|
w.set('spider', 60).set('frog', 40);
|
||||||
} else {
|
} else {
|
||||||
w.set('spider', 100);
|
w.set('spider', 100);
|
||||||
}
|
}
|
||||||
@@ -980,13 +1247,33 @@ export class Game {
|
|||||||
const entry = this.bossWaves.get(wave);
|
const entry = this.bossWaves.get(wave);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const enemy = entry.spawn();
|
const enemy = entry.spawn();
|
||||||
const x = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
let x = 0;
|
||||||
const z = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
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);
|
this.spawnEnemyInstance(enemy, x, z, 0.15);
|
||||||
playSound('spawn', 0.8);
|
playSound('spawn', 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
|
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
|
||||||
|
// Gegner werden erst NACH den Boss-Wellen stärker: pro besiegten Boss
|
||||||
|
// (jede 10. Welle) +20% HP, davor Basis-HP. Bosse bleiben unskaliert.
|
||||||
|
if (!enemy.isBoss) {
|
||||||
|
const bossGates = Math.floor(Math.max(0, this.spawns - 1) / 10);
|
||||||
|
if (bossGates > 0) {
|
||||||
|
const scale = 1 + 0.2 * bossGates;
|
||||||
|
enemy.health = Math.round(enemy.health * scale);
|
||||||
|
enemy.maxHealth = enemy.health;
|
||||||
|
}
|
||||||
|
}
|
||||||
enemy.body.position.set(x, y, z);
|
enemy.body.position.set(x, y, z);
|
||||||
this.scene.add(enemy.body);
|
this.scene.add(enemy.body);
|
||||||
this.enemies.push(enemy);
|
this.enemies.push(enemy);
|
||||||
@@ -1053,6 +1340,9 @@ export class Game {
|
|||||||
if (p.stunTime > 0) {
|
if (p.stunTime > 0) {
|
||||||
// Gestunnt (Stampf-Schockwelle): keine Eingaben
|
// Gestunnt (Stampf-Schockwelle): keine Eingaben
|
||||||
p.velocity.set(0, 0, 0);
|
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) {
|
} else if (p.pullTime > 0) {
|
||||||
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
|
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
|
||||||
p.pullTime -= dt;
|
p.pullTime -= dt;
|
||||||
@@ -1083,7 +1373,7 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Jump / gravity
|
// 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.jumpVelocity -= 20 * dt;
|
||||||
p.body.position.y += p.jumpVelocity * dt;
|
p.body.position.y += p.jumpVelocity * dt;
|
||||||
if (p.body.position.y <= 0.5) {
|
if (p.body.position.y <= 0.5) {
|
||||||
@@ -1098,6 +1388,11 @@ export class Game {
|
|||||||
p.body.position.z += p.velocity.z * dt;
|
p.body.position.z += p.velocity.z * dt;
|
||||||
this.clampToArena(p.body.position);
|
this.clampToArena(p.body.position);
|
||||||
|
|
||||||
|
// Labyrinth-Wände blockieren den Spieler
|
||||||
|
if (this.mazeActive && this.maze) {
|
||||||
|
this.maze.collidePlayer(p.body.position, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
// Look at ground point
|
// Look at ground point
|
||||||
p.body.lookAt(
|
p.body.lookAt(
|
||||||
this.groundPoint.x,
|
this.groundPoint.x,
|
||||||
@@ -1111,6 +1406,48 @@ export class Game {
|
|||||||
pos.z = Math.max(-47, Math.min(47, pos.z));
|
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) {
|
private updateEnemies(dt: number) {
|
||||||
for (const enemy of this.enemies) {
|
for (const enemy of this.enemies) {
|
||||||
if (enemy.dead || this.state !== 'playing') {
|
if (enemy.dead || this.state !== 'playing') {
|
||||||
@@ -1235,7 +1572,10 @@ export class Game {
|
|||||||
this.uiGasCD.style.width = `${gasPct * 100}%`;
|
this.uiGasCD.style.width = `${gasPct * 100}%`;
|
||||||
|
|
||||||
const wavePct = Math.min(1, this.spawnTimer / SPAWN_TIME);
|
const wavePct = Math.min(1, this.spawnTimer / SPAWN_TIME);
|
||||||
if (this.bossFightActive) {
|
if (this.mazeActive) {
|
||||||
|
this.uiWaveBarFill.style.width = `${Math.min(1, this.mazeTimer / this.mazeTotal) * 100}%`;
|
||||||
|
this.uiWaveBarLabel.textContent = `Labyrinth: ${Math.ceil(this.mazeTimer)}s`;
|
||||||
|
} else if (this.bossFightActive) {
|
||||||
this.uiWaveBarFill.style.width = '100%';
|
this.uiWaveBarFill.style.width = '100%';
|
||||||
this.uiWaveBarLabel.textContent = 'BOSS!';
|
this.uiWaveBarLabel.textContent = 'BOSS!';
|
||||||
} else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) {
|
} else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) {
|
||||||
@@ -1253,6 +1593,8 @@ export class Game {
|
|||||||
this.uiLowHpVignette.classList.toggle(
|
this.uiLowHpVignette.classList.toggle(
|
||||||
'active', healthPct < 0.3 && healthPct > 0
|
'active', healthPct < 0.3 && healthPct > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.updateMazeArrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerInkVignette() {
|
triggerInkVignette() {
|
||||||
@@ -1357,9 +1699,17 @@ export class Game {
|
|||||||
this.updateGroundPoint();
|
this.updateGroundPoint();
|
||||||
this.updatePlayerMovement(dt);
|
this.updatePlayerMovement(dt);
|
||||||
this.updateEnemies(dt);
|
this.updateEnemies(dt);
|
||||||
|
this.updateOffscreenReposition(dt);
|
||||||
this.updateFireballs(dt);
|
this.updateFireballs(dt);
|
||||||
this.updateHitzones();
|
this.updateHitzones();
|
||||||
|
|
||||||
|
// Arena-Theme weich einfärben (Boden, Wände, Nebel, Licht)
|
||||||
|
this.arena.update(dt);
|
||||||
|
const themeT = Math.min(1, dt * 1.5);
|
||||||
|
(this.scene.fog as THREE.Fog).color.lerp(this.themeFogTarget, themeT);
|
||||||
|
(this.scene.background as THREE.Color).lerp(this.themeFogTarget, themeT);
|
||||||
|
this.playerLight.color.lerp(this.themeLightTarget, themeT);
|
||||||
|
|
||||||
// Update slash effects
|
// Update slash effects
|
||||||
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
|
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
|
||||||
if (!this.slashEffects[i].update(dt)) {
|
if (!this.slashEffects[i].update(dt)) {
|
||||||
@@ -1388,13 +1738,25 @@ export class Game {
|
|||||||
|
|
||||||
// Spawn enemies in waves
|
// Spawn enemies in waves
|
||||||
if (this.state === 'playing') {
|
if (this.state === 'playing') {
|
||||||
const arenaCleared = this.enemies.every(e => e.dead);
|
if (this.mazeActive) {
|
||||||
if (this.bossFightActive) {
|
// Labyrinth-Zwischenboss: ersetzt die Welle komplett
|
||||||
|
this.updateMaze(dt);
|
||||||
|
} else {
|
||||||
|
const arenaCleared = this.enemies.every(e => e.dead);
|
||||||
|
if (this.bossFightActive) {
|
||||||
// Boss besiegt -> naechste Welle startet
|
// Boss besiegt -> naechste Welle startet
|
||||||
if (arenaCleared) {
|
if (arenaCleared) {
|
||||||
this.bossFightActive = false;
|
this.bossFightActive = false;
|
||||||
this.spawns++;
|
this.spawns++;
|
||||||
this.spawnTimer = 0;
|
this.spawnTimer = 0;
|
||||||
|
// Arena verändert sich nach jedem besiegten Boss
|
||||||
|
if (this.arenaStage < 7) {
|
||||||
|
this.arenaStage++;
|
||||||
|
this.themeFogTarget.copy(this.arena.applyTheme(this.arenaStage));
|
||||||
|
this.themeLightTarget.setHex(Game.THEME_LIGHTS[this.arenaStage]);
|
||||||
|
this.triggerCameraShake(0.4, 0.4);
|
||||||
|
playSound('explosion', 0.6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (this.spawnTimer >= SPAWN_TIME) {
|
} else if (this.spawnTimer >= SPAWN_TIME) {
|
||||||
if (this.isBossWave(this.spawns + 1)) {
|
if (this.isBossWave(this.spawns + 1)) {
|
||||||
@@ -1405,6 +1767,11 @@ export class Game {
|
|||||||
this.bossFightActive = true;
|
this.bossFightActive = true;
|
||||||
this.spawnTimer = 0;
|
this.spawnTimer = 0;
|
||||||
}
|
}
|
||||||
|
} else if (this.isMazeWave(this.spawns + 1)) {
|
||||||
|
// Labyrinth-Zwischenboss startet (ersetzt die Welle)
|
||||||
|
this.spawns++;
|
||||||
|
this.startMazeWave();
|
||||||
|
this.spawnTimer = 0;
|
||||||
} else {
|
} else {
|
||||||
this.spawns++;
|
this.spawns++;
|
||||||
for (let i = 0; i < this.spawns; i++) {
|
for (let i = 0; i < this.spawns; i++) {
|
||||||
@@ -1416,6 +1783,7 @@ export class Game {
|
|||||||
this.spawnTimer += dt;
|
this.spawnTimer += dt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check close encounters (enemy touching player)
|
// Check close encounters (enemy touching player)
|
||||||
if (this.state === 'playing') {
|
if (this.state === 'playing') {
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ export class Ghost extends Enemy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Chase the player (stays hittable and dangerous even while invisible)
|
// Chase the player (stays hittable and dangerous even while invisible)
|
||||||
|
this.chase(dt, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getrennt für Subklassen (z. B. MazeGuard: erst aktiv, wenn der Spieler nah ist)
|
||||||
|
protected chase(dt: number, game: Game) {
|
||||||
const toPlayer = new THREE.Vector3()
|
const toPlayer = new THREE.Vector3()
|
||||||
.subVectors(game.player.body.position, this.body.position);
|
.subVectors(game.player.body.position, this.body.position);
|
||||||
toPlayer.y = 0;
|
toPlayer.y = 0;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
|
||||||
|
// Gnom: das Ziel im Labyrinth. Stationär, nur mit Sichtlinie (durch die
|
||||||
|
// Wände hindurch) verletzbar -> man muss wirklich zu ihm hingehen.
|
||||||
|
export class Gnome extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private skinMat!: THREE.MeshToonMaterial;
|
||||||
|
private hatMat!: THREE.MeshToonMaterial;
|
||||||
|
private beardMat!: THREE.MeshToonMaterial;
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private hitFlash = 0;
|
||||||
|
|
||||||
|
constructor(hp: number, xp: number) {
|
||||||
|
super();
|
||||||
|
this.health = hp;
|
||||||
|
this.maxHealth = hp;
|
||||||
|
this.isBoss = true;
|
||||||
|
this.displayName = 'Gnom';
|
||||||
|
this.speed = 0;
|
||||||
|
this.xp = xp;
|
||||||
|
this.contactDps = 0;
|
||||||
|
this.contactRadius = 0;
|
||||||
|
this.guaranteedDrop = true;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
this.skinMat = new THREE.MeshToonMaterial({ color: 0xe8c8a0 });
|
||||||
|
this.hatMat = new THREE.MeshToonMaterial({ color: 0x44aa44 });
|
||||||
|
this.beardMat = new THREE.MeshToonMaterial({ color: 0xf8f0e0 });
|
||||||
|
|
||||||
|
// Körper
|
||||||
|
const body = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), this.skinMat);
|
||||||
|
body.scale.set(1, 1.15, 1);
|
||||||
|
body.position.y = 0.5;
|
||||||
|
body.castShadow = true;
|
||||||
|
this.registerFadeMesh(body);
|
||||||
|
this.model.add(body);
|
||||||
|
|
||||||
|
// Mantel
|
||||||
|
const coat = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.45, 8, 1, true), this.hatMat);
|
||||||
|
coat.position.y = 0.28;
|
||||||
|
coat.rotation.x = Math.PI;
|
||||||
|
this.registerFadeMesh(coat);
|
||||||
|
this.model.add(coat);
|
||||||
|
|
||||||
|
// Kopf
|
||||||
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.24, 12, 10), this.skinMat);
|
||||||
|
head.position.y = 0.95;
|
||||||
|
head.castShadow = true;
|
||||||
|
this.registerFadeMesh(head);
|
||||||
|
this.model.add(head);
|
||||||
|
|
||||||
|
// Spitzer Hut
|
||||||
|
const hat = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.55, 8), this.hatMat);
|
||||||
|
hat.position.set(0, 1.3, 0.05);
|
||||||
|
hat.rotation.x = 0.12;
|
||||||
|
this.registerFadeMesh(hat);
|
||||||
|
this.model.add(hat);
|
||||||
|
|
||||||
|
// Bart (+Z)
|
||||||
|
const beard = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), this.beardMat);
|
||||||
|
beard.scale.set(1, 1.2, 0.7);
|
||||||
|
beard.position.set(0, 0.72, 0.2);
|
||||||
|
this.registerFadeMesh(beard);
|
||||||
|
this.model.add(beard);
|
||||||
|
|
||||||
|
// Augen (+Z)
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x223322 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.04, 6, 5), eyeMat);
|
||||||
|
eye.position.set(side * 0.09, 1.0, 0.22);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 1.0, shrink: 0.8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
|
||||||
|
// Hit-Feedback
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
this.skinMat.color.set(0xffffff);
|
||||||
|
this.hatMat.color.set(0xffffff);
|
||||||
|
} else {
|
||||||
|
this.skinMat.color.set(0xe8c8a0);
|
||||||
|
this.hatMat.color.set(0x44aa44);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wippen + ab und zu aufhüpfen
|
||||||
|
this.body.position.y = 0.1 + Math.abs(Math.sin(this.time * 2.2)) * 0.08;
|
||||||
|
this.model.rotation.y = Math.sin(this.time * 1.5) * 0.15;
|
||||||
|
|
||||||
|
// Zum Spieler drehen
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, this.body.position);
|
||||||
|
toPlayer.y = 0;
|
||||||
|
if (toPlayer.length() > 0.1) {
|
||||||
|
this.body.lookAt(
|
||||||
|
this.body.position.x + toPlayer.x,
|
||||||
|
this.body.position.y,
|
||||||
|
this.body.position.z + toPlayer.z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override takeDamage(
|
||||||
|
damage: number,
|
||||||
|
game: Game,
|
||||||
|
withKnockback = true,
|
||||||
|
sourcePos?: THREE.Vector3
|
||||||
|
) {
|
||||||
|
if (this.dead) return;
|
||||||
|
// Nur mit Sichtlinie verletzbar (kein Feuerball durch die Wand)
|
||||||
|
const src = sourcePos ?? game.player.body.position;
|
||||||
|
if (game.maze && !game.maze.hasLineOfSight(src, this.body.position)) {
|
||||||
|
game.spawnDamageText('WAND!', this.body.position, '#8899aa');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.takeDamage(damage, game, withKnockback, sourcePos);
|
||||||
|
this.hitFlash = HIT_FLASH_TIME;
|
||||||
|
playSound('damage', 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-2
@@ -15,15 +15,152 @@ export class Golem extends Brute {
|
|||||||
this.contactRadius = 3.4;
|
this.contactRadius = 3.4;
|
||||||
this.guaranteedDrop = true;
|
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);
|
this.model.scale.setScalar(2.1);
|
||||||
|
|
||||||
// Crown
|
// Krone (Boss-Markierung)
|
||||||
const crownMat = new THREE.MeshToonMaterial({ color: 0xffcc33 });
|
const crownMat = new THREE.MeshToonMaterial({ color: 0xffcc33 });
|
||||||
const crown = new THREE.Mesh(
|
const crown = new THREE.Mesh(
|
||||||
new THREE.ConeGeometry(0.3, 0.4, 8, 1, true),
|
new THREE.ConeGeometry(0.3, 0.4, 8, 1, true),
|
||||||
crownMat
|
crownMat
|
||||||
);
|
);
|
||||||
crown.position.y = 2.25;
|
crown.position.y = 2.55;
|
||||||
crown.castShadow = true;
|
crown.castShadow = true;
|
||||||
this.registerFadeMesh(crown);
|
this.registerFadeMesh(crown);
|
||||||
this.model.add(crown);
|
this.model.add(crown);
|
||||||
|
|||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const GRID = 11;
|
||||||
|
const CELL = 92 / GRID;
|
||||||
|
const HALF = 46;
|
||||||
|
const WALL_THICK = 1;
|
||||||
|
const WALL_HEIGHT = 2.5;
|
||||||
|
|
||||||
|
interface WallBox {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labyrinth-Zwischenboss: randomisierter Prim auf einem 11x11-Grid.
|
||||||
|
// Erzeugt ein "perfektes" Labyrinth (jede Zelle erreichbar, genau ein Pfad
|
||||||
|
// zwischen zwei beliebigen Zellen) mit kurzen, verzweigten Lösungswegen.
|
||||||
|
export class Maze {
|
||||||
|
private scene: THREE.Scene;
|
||||||
|
private wallMeshes: THREE.Mesh[] = [];
|
||||||
|
private wallBoxes: WallBox[] = [];
|
||||||
|
private openH: boolean[][] = []; // Passage (i,j)-(i+1,j)
|
||||||
|
private openV: boolean[][] = []; // Passage (i,j)-(i,j+1)
|
||||||
|
|
||||||
|
constructor(scene: THREE.Scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.generate();
|
||||||
|
this.buildWalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start-Ecke = Zelle (0,0), Ziel = Zelle (10,10)
|
||||||
|
startPosition(): THREE.Vector3 {
|
||||||
|
return this.cellCenter(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// goalIndex 0 = Ziel-Ecke (max,max), 1 = Neben-Ecke (min,max) für 2x-Finden
|
||||||
|
goalPosition(goalIndex: number): THREE.Vector3 {
|
||||||
|
if (goalIndex === 1) return this.cellCenter(0, GRID - 1);
|
||||||
|
return this.cellCenter(GRID - 1, GRID - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cellCenter(i: number, j: number): THREE.Vector3 {
|
||||||
|
return new THREE.Vector3(-HALF + (i + 0.5) * CELL, 0, -HALF + (j + 0.5) * CELL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generate() {
|
||||||
|
for (let i = 0; i < GRID; i++) {
|
||||||
|
this.openH.push(new Array(GRID - 1).fill(false));
|
||||||
|
this.openV.push(new Array(GRID - 1).fill(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomized Prim: startet bei Zelle (0,0), wählt zufällige Frontier-Kante
|
||||||
|
const visited: boolean[][] = Array.from({ length: GRID }, () => new Array(GRID).fill(false));
|
||||||
|
const frontier: { i: number; j: number; dir: 'h' | 'v' }[] = [];
|
||||||
|
const addFrontier = (i: number, j: number) => {
|
||||||
|
if (i < GRID - 1 && !visited[i + 1][j]) frontier.push({ i, j, dir: 'h' });
|
||||||
|
if (i > 0 && !visited[i - 1][j]) frontier.push({ i: i - 1, j, dir: 'h' });
|
||||||
|
if (j < GRID - 1 && !visited[i][j + 1]) frontier.push({ i, j, dir: 'v' });
|
||||||
|
if (j > 0 && !visited[i][j - 1]) frontier.push({ i, j: j - 1, dir: 'v' });
|
||||||
|
};
|
||||||
|
|
||||||
|
visited[0][0] = true;
|
||||||
|
addFrontier(0, 0);
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
const idx = Math.floor(Math.random() * frontier.length);
|
||||||
|
const { i, j, dir } = frontier[idx];
|
||||||
|
frontier.splice(idx, 1);
|
||||||
|
const ci = dir === 'h' ? i + 1 : i;
|
||||||
|
const cj = dir === 'h' ? j : j + 1;
|
||||||
|
if (visited[ci][cj]) continue;
|
||||||
|
visited[ci][cj] = true;
|
||||||
|
if (dir === 'h') this.openH[i][j] = true;
|
||||||
|
else this.openV[i][j] = true;
|
||||||
|
addFrontier(ci, cj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWalls() {
|
||||||
|
const wallMat = new THREE.MeshToonMaterial({ color: 0x776677 });
|
||||||
|
|
||||||
|
// Außenwände: Labyrinth bis zur Begrenzung abdichten,
|
||||||
|
// damit man nicht außen herumlaufen kann
|
||||||
|
const outer = HALF + WALL_THICK / 2;
|
||||||
|
this.addWall(-outer, -HALF + WALL_THICK / 2, -outer, outer, WALL_THICK, 2 * outer, -HALF, 0, 0, wallMat);
|
||||||
|
this.addWall(HALF - WALL_THICK / 2, outer, -outer, outer, WALL_THICK, 2 * outer, HALF, 0, 0, wallMat);
|
||||||
|
this.addWall(-outer, outer, -outer, -HALF + WALL_THICK / 2, 2 * outer, WALL_THICK, 0, 0, -HALF, wallMat);
|
||||||
|
this.addWall(-outer, outer, HALF - WALL_THICK / 2, outer, 2 * outer, WALL_THICK, 0, 0, HALF, wallMat);
|
||||||
|
|
||||||
|
// Vertikale Gitterlinien (konstantes x): Wand blockiert Bewegung in x
|
||||||
|
for (let i = 1; i < GRID; i++) {
|
||||||
|
const bx = -HALF + i * CELL;
|
||||||
|
for (let j = 0; j < GRID; j++) {
|
||||||
|
if (this.openH[i - 1][j]) continue;
|
||||||
|
const zc = -HALF + (j + 0.5) * CELL;
|
||||||
|
this.addWall(bx - WALL_THICK / 2, bx + WALL_THICK / 2, zc - CELL / 2, zc + CELL / 2, WALL_THICK, CELL, bx, 0, zc, wallMat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontale Gitterlinien (konstantes z): Wand blockiert Bewegung in z
|
||||||
|
for (let j = 1; j < GRID; j++) {
|
||||||
|
const bz = -HALF + j * CELL;
|
||||||
|
for (let i = 0; i < GRID; i++) {
|
||||||
|
if (this.openV[i][j - 1]) continue;
|
||||||
|
const xc = -HALF + (i + 0.5) * CELL;
|
||||||
|
this.addWall(xc - CELL / 2, xc + CELL / 2, bz - WALL_THICK / 2, bz + WALL_THICK / 2, CELL, WALL_THICK, xc, 0, bz, wallMat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addWall(
|
||||||
|
minX: number, maxX: number, minZ: number, maxZ: number,
|
||||||
|
sizeX: number, sizeZ: number,
|
||||||
|
px: number, py: number, pz: number,
|
||||||
|
mat: THREE.Material
|
||||||
|
) {
|
||||||
|
const geom = new THREE.BoxGeometry(sizeX, WALL_HEIGHT, sizeZ);
|
||||||
|
const mesh = new THREE.Mesh(geom, mat);
|
||||||
|
mesh.position.set(px, py + WALL_HEIGHT / 2, pz);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
this.scene.add(mesh);
|
||||||
|
this.wallMeshes.push(mesh);
|
||||||
|
this.wallBoxes.push({ minX, maxX, minZ, maxZ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circle-vs-AABB-Kollision: Spieler aus Wänden schieben (3 Durchgänge)
|
||||||
|
collidePlayer(pos: THREE.Vector3, radius: number) {
|
||||||
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
|
let moved = false;
|
||||||
|
for (const w of this.wallBoxes) {
|
||||||
|
const cx = Math.max(w.minX, Math.min(pos.x, w.maxX));
|
||||||
|
const cz = Math.max(w.minZ, Math.min(pos.z, w.maxZ));
|
||||||
|
const dx = pos.x - cx;
|
||||||
|
const dz = pos.z - cz;
|
||||||
|
const d2 = dx * dx + dz * dz;
|
||||||
|
if (d2 < radius * radius) {
|
||||||
|
if (d2 > 1e-6) {
|
||||||
|
const d = Math.sqrt(d2);
|
||||||
|
pos.x += (dx / d) * (radius - d);
|
||||||
|
pos.z += (dz / d) * (radius - d);
|
||||||
|
} else {
|
||||||
|
// Mittelpunkt in der Wand: kleinste Überlappung wählen
|
||||||
|
const ox = Math.min(pos.x - w.minX, w.maxX - pos.x);
|
||||||
|
const oz = Math.min(pos.z - w.minZ, w.maxZ - pos.z);
|
||||||
|
if (ox < oz) {
|
||||||
|
pos.x += (pos.x < (w.minX + w.maxX) / 2 ? -1 : 1) * (radius + ox);
|
||||||
|
} else {
|
||||||
|
pos.z += (pos.z < (w.minZ + w.maxZ) / 2 ? -1 : 1) * (radius + oz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!moved) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sichtlinie: Segment alle ~0.8 m abtasten, Wand dazwischen = kein Sichtkontakt
|
||||||
|
hasLineOfSight(a: THREE.Vector3, b: THREE.Vector3): boolean {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dz = b.z - a.z;
|
||||||
|
const dist = Math.hypot(dx, dz);
|
||||||
|
const steps = Math.max(1, Math.ceil(dist / 0.8));
|
||||||
|
for (let s = 1; s < steps; s++) {
|
||||||
|
const t = s / steps;
|
||||||
|
const px = a.x + dx * t;
|
||||||
|
const pz = a.z + dz * t;
|
||||||
|
for (const w of this.wallBoxes) {
|
||||||
|
if (px > w.minX - 0.3 && px < w.maxX + 0.3 &&
|
||||||
|
pz > w.minZ - 0.3 && pz < w.maxZ + 0.3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wächter-Positionen: gleichmäßig verteilte Zellen entlang des Lösungswegs
|
||||||
|
guardPositions(count: number): THREE.Vector3[] {
|
||||||
|
if (count <= 0) return [];
|
||||||
|
|
||||||
|
// BFS von (0,0) zu (10,10)
|
||||||
|
const prev: ([number, number] | null)[][] = Array.from(
|
||||||
|
{ length: GRID },
|
||||||
|
() => new Array<[number, number] | null>(GRID).fill(null)
|
||||||
|
);
|
||||||
|
const queue: [number, number][] = [[0, 0]];
|
||||||
|
prev[0][0] = [0, 0];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const [i, j] = queue.shift()!;
|
||||||
|
if (i === GRID - 1 && j === GRID - 1) break;
|
||||||
|
const neigh: [number, number][] = [];
|
||||||
|
if (i < GRID - 1 && this.openH[i][j]) neigh.push([i + 1, j]);
|
||||||
|
if (i > 0 && this.openH[i - 1][j]) neigh.push([i - 1, j]);
|
||||||
|
if (j < GRID - 1 && this.openV[i][j]) neigh.push([i, j + 1]);
|
||||||
|
if (j > 0 && this.openV[i][j - 1]) neigh.push([i, j - 1]);
|
||||||
|
for (const [ni, nj] of neigh) {
|
||||||
|
if (prev[ni][nj]) continue;
|
||||||
|
prev[ni][nj] = [i, j];
|
||||||
|
queue.push([ni, nj]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pfad rekonstruieren
|
||||||
|
const path: [number, number][] = [];
|
||||||
|
let cur: [number, number] | null = [GRID - 1, GRID - 1];
|
||||||
|
while (cur) {
|
||||||
|
path.push(cur);
|
||||||
|
const p: [number, number] | null = prev[cur[0]][cur[1]];
|
||||||
|
if (!p || (p[0] === cur[0] && p[1] === cur[1])) break;
|
||||||
|
cur = p;
|
||||||
|
}
|
||||||
|
path.reverse();
|
||||||
|
if (path.length < 3) return [];
|
||||||
|
|
||||||
|
// count Zellen gleichmäßig auf dem Weg verteilen (ohne Start/Ziel)
|
||||||
|
const res: THREE.Vector3[] = [];
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (let k = 1; k <= count; k++) {
|
||||||
|
const idx = Math.min(path.length - 2, Math.max(1, Math.round((k * path.length) / (count + 1))));
|
||||||
|
const [i, j] = path[idx];
|
||||||
|
const key = `${i},${j}`;
|
||||||
|
if (used.has(key)) continue;
|
||||||
|
used.add(key);
|
||||||
|
res.push(this.cellCenter(i, j));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const mesh of this.wallMeshes) {
|
||||||
|
this.scene.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
(mesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
this.wallMeshes = [];
|
||||||
|
this.wallBoxes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Game } from './Game';
|
||||||
|
import { Ghost } from './Ghost';
|
||||||
|
|
||||||
|
const ACTIVATE_RADIUS = 8;
|
||||||
|
|
||||||
|
// Labyrinth-Wächter: steht still am Wegpunkt und greift erst an,
|
||||||
|
// wenn der Spieler in die Nähe kommt.
|
||||||
|
export class MazeGuard extends Ghost {
|
||||||
|
private activated = false;
|
||||||
|
|
||||||
|
protected chase(dt: number, game: Game) {
|
||||||
|
if (!this.activated) {
|
||||||
|
const dx = game.player.body.position.x - this.body.position.x;
|
||||||
|
const dz = game.player.body.position.z - this.body.position.z;
|
||||||
|
if (Math.hypot(dx, dz) < ACTIVATE_RADIUS) {
|
||||||
|
this.activated = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.chase(dt, game);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ export class Player {
|
|||||||
pullTime = 0;
|
pullTime = 0;
|
||||||
pullDir = new THREE.Vector3();
|
pullDir = new THREE.Vector3();
|
||||||
stunTime = 0;
|
stunTime = 0;
|
||||||
|
rootTime = 0;
|
||||||
|
rootCooldown = 0;
|
||||||
stats: PlayerStats = {
|
stats: PlayerStats = {
|
||||||
maxHealth: 100,
|
maxHealth: 100,
|
||||||
regen: 0,
|
regen: 0,
|
||||||
@@ -239,6 +241,8 @@ export class Player {
|
|||||||
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
|
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
|
||||||
if (this.slowTimer > 0) this.slowTimer -= dt;
|
if (this.slowTimer > 0) this.slowTimer -= dt;
|
||||||
if (this.stunTime > 0) this.stunTime -= 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
|
// Stun-Sterne animieren
|
||||||
this.stunStars.visible = this.stunTime > 0;
|
this.stunStars.visible = this.stunTime > 0;
|
||||||
@@ -360,6 +364,8 @@ export class Player {
|
|||||||
this.slowTimer = 0;
|
this.slowTimer = 0;
|
||||||
this.pullTime = 0;
|
this.pullTime = 0;
|
||||||
this.stunTime = 0;
|
this.stunTime = 0;
|
||||||
|
this.rootTime = 0;
|
||||||
|
this.rootCooldown = 0;
|
||||||
this.body.position.set(0, 0.5, 0);
|
this.body.position.set(0, 0.5, 0);
|
||||||
this.velocity.set(0, 0, 0);
|
this.velocity.set(0, 0, 0);
|
||||||
this.jumpVelocity = 0;
|
this.jumpVelocity = 0;
|
||||||
|
|||||||
@@ -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
@@ -22,7 +22,7 @@ export const SKILLS: SkillDef[] = [
|
|||||||
type: 'active',
|
type: 'active',
|
||||||
maxLevel: 5,
|
maxLevel: 5,
|
||||||
describe: (l) => l === 1
|
describe: (l) => l === 1
|
||||||
? 'Schießt einen Feuerball (100 DMG, 5s CD)'
|
? 'Schießt einen Feuerball (85 DMG, 5.5s CD)'
|
||||||
: `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`,
|
: `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@ export const SKILLS: SkillDef[] = [
|
|||||||
name: 'Kettenblitz',
|
name: 'Kettenblitz',
|
||||||
type: 'active',
|
type: 'active',
|
||||||
maxLevel: 5,
|
maxLevel: 5,
|
||||||
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD)`,
|
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD, ${lightningJumpRange(l)} m Sprungweite)`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'swordRange',
|
id: 'swordRange',
|
||||||
@@ -132,23 +132,26 @@ export function getSkill(id: string): SkillDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fireballDamage(level: number): number {
|
export function fireballDamage(level: number): number {
|
||||||
return [100, 150, 200, 250, 300][Math.min(level, 5) - 1] ?? 100;
|
return [85, 125, 165, 205, 245][Math.min(level, 5) - 1] ?? 85;
|
||||||
}
|
}
|
||||||
export function fireballCooldown(level: number): number {
|
export function fireballCooldown(level: number): number {
|
||||||
return [5, 4.5, 4, 3.5, 3][Math.min(level, 5) - 1] ?? 5;
|
return [5.5, 5, 4.5, 4, 3.5][Math.min(level, 5) - 1] ?? 5.5;
|
||||||
}
|
}
|
||||||
export function fireballRadius(level: number): number {
|
export function fireballRadius(level: number): number {
|
||||||
return [8, 8, 10, 10, 12][Math.min(level, 5) - 1] ?? 8;
|
return [7, 7, 9, 9, 11][Math.min(level, 5) - 1] ?? 7;
|
||||||
}
|
}
|
||||||
export function teleportCooldown(level: number): number {
|
export function teleportCooldown(level: number): number {
|
||||||
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
|
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
|
||||||
}
|
}
|
||||||
export function lightningDamage(level: number): number {
|
export function lightningDamage(level: number): number {
|
||||||
return [40, 60, 80, 100, 120][Math.min(level, 5) - 1] ?? 40;
|
return [55, 75, 95, 115, 135][Math.min(level, 5) - 1] ?? 55;
|
||||||
}
|
}
|
||||||
export function lightningJumps(level: number): number {
|
export function lightningJumps(level: number): number {
|
||||||
return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2;
|
return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2;
|
||||||
}
|
}
|
||||||
|
export function lightningJumpRange(level: number): number {
|
||||||
|
return [9, 10, 11, 12, 13][Math.min(level, 5) - 1] ?? 9;
|
||||||
|
}
|
||||||
export function lightningCooldown(level: number): number {
|
export function lightningCooldown(level: number): number {
|
||||||
return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8;
|
return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8;
|
||||||
}
|
}
|
||||||
@@ -174,7 +177,7 @@ export function boomerangCount(level: number): number {
|
|||||||
return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1;
|
return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1;
|
||||||
}
|
}
|
||||||
export function trapDamage(level: number): number {
|
export function trapDamage(level: number): number {
|
||||||
return [25, 35, 45, 55, 70][Math.min(level, 5) - 1] ?? 25;
|
return [35, 45, 55, 65, 80][Math.min(level, 5) - 1] ?? 35;
|
||||||
}
|
}
|
||||||
export function trapHold(level: number): number {
|
export function trapHold(level: number): number {
|
||||||
return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5;
|
return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5;
|
||||||
@@ -186,7 +189,7 @@ export function trapCooldown(level: number): number {
|
|||||||
return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8;
|
return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8;
|
||||||
}
|
}
|
||||||
export function gasDamage(level: number): number {
|
export function gasDamage(level: number): number {
|
||||||
return [8, 12, 16, 20, 25][Math.min(level, 5) - 1] ?? 8;
|
return [12, 16, 20, 24, 30][Math.min(level, 5) - 1] ?? 12;
|
||||||
}
|
}
|
||||||
export function gasRadius(level: number): number {
|
export function gasRadius(level: number): number {
|
||||||
return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5;
|
return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5;
|
||||||
@@ -226,7 +229,7 @@ export class SkillSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
xpNeeded(): number {
|
xpNeeded(): number {
|
||||||
return 20 + this.level * 10;
|
return 30 + this.level * 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
xpProgress(): number {
|
xpProgress(): number {
|
||||||
|
|||||||
+27
-15
@@ -16,29 +16,41 @@ export async function initSpiderModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, 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 {
|
export class Spider extends Enemy {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
|
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
|
||||||
|
|
||||||
if (spiderGLTF) {
|
const model = buildSpiderModel(0.5, 0xffffff);
|
||||||
const model = cloneSkeleton(spiderGLTF.scene);
|
if (model) {
|
||||||
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
|
|
||||||
model.scale.set(0.5, 0.5, 0.5);
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.body.add(model);
|
this.body.add(model);
|
||||||
|
|
||||||
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
|
if (getSpiderAnimations().length > 0 && ANIMATIONS_ENABLED) {
|
||||||
this.mixer = new THREE.AnimationMixer(model);
|
this.mixer = new THREE.AnimationMixer(model);
|
||||||
for (const clip of spiderGLTF.animations) {
|
for (const clip of getSpiderAnimations()) {
|
||||||
const action = this.mixer.clipAction(clip);
|
const action = this.mixer.clipAction(clip);
|
||||||
this.clips.set(clip.name, action);
|
this.clips.set(clip.name, action);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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=<id> Modell · &still=1 statische Pose</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/viewer.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,12 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
assetsDir: 'assets',
|
assetsDir: 'assets',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: 'index.html',
|
||||||
|
viewer: 'viewer.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user