Compare commits
6
Commits
151b07cb4e
...
8065104d8a
| 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).
|
||||
- UI-Sprache: Deutsch (Skill-Namen, Level-up-Karten); Code-Kommentare bisher gemischt, Git-Messages englisch.
|
||||
|
||||
## Modell-Viewer (visuelle Modell-Checks)
|
||||
|
||||
- `web/viewer.html` = eigener Vite-Entry (auch im Build): rendert ein Gegner-/Boss-Modell
|
||||
isoliert auf Grid-Bühne mit hellem Licht, Turntable-Kamera (Auto-Fit per Box3) und
|
||||
Mini-UI zum Umschalten. Modelle laufen gegen ein Stub-`Game` (Stub-Spieler als grüner
|
||||
Blob 8 m entfernt als Blick-/Angriffsziel).
|
||||
- URL-Params: `?m=<id>` (ids wie `dragon`, `krake`, `spider`, … siehe Registry in
|
||||
`viewer.ts`), `&still=1` = statische Pose (nur `updateAnimation`, kein `update`).
|
||||
- Automatisierte Screenshots: `scripts/model_shot.sh <id> [shots=4] [intervall=1.5] [still]` —
|
||||
startet eigenen Dev-Server auf Port 3100, öffnet Viewer-Fenster (Titel
|
||||
„Wackelpeter Modell-Viewer"), minimiert die Konsole per KWin, macht `shots`
|
||||
Turntable-Screenshots nach `/tmp/modelshot_*_crop.png`, blendet die Konsole wieder
|
||||
ein, schließt das Viewer-Fenster und stoppt den 3100er-Server. Pfade kommen auf stdout.
|
||||
- Workflow für Modell-Überarbeitung: Code editieren → `scripts/model_shot.sh <id>` →
|
||||
Crops lesen → iterieren. Keine Cheats, Klicks oder Bosskämpfe nötig.
|
||||
|
||||
## Architektur (web/src)
|
||||
|
||||
| Datei | Inhalt |
|
||||
@@ -34,9 +50,10 @@ npm run dev # Dev-Server mit HMR
|
||||
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
|
||||
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
|
||||
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
|
||||
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` | Bosse (Welle 10 / 20 / 30), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
|
||||
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` / `SpiderQueen.ts` / `FrostGiant.ts` / `Necromancer.ts` / `Dragon.ts` | Bosse (Welle 10 / 20 / 30 / 40 / 50 / 60 / 70), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Spinnenkönigin: stationär, Netze (`WebPatch.ts`: fesselt via `player.rootTime`), Spinngeschwister (Mini-Spiders), Säuresalven. Frostriese: Eispanzer (70% Schadensreduktion, `armorHp`, bricht + Stagger, regeneriert nach Timer), Eisberst-Ring (nacheinander explodierende Marken), Eiszapfen-Regen, Stampf (überspringbar). Erz-Nekromant: schwebend, Teleport mit Glow-Ring-Telegraf + Ankunftssalve, Beschwörungen mit wachsendem Boden-Glow. Drache: fliegt und ist nur im Lande-Zustand verletzbar (`takeDamage`-Override blockt im Flug), Sturzangriff mit wachsendem Schatten-Telegraf, Feuerspeer (Telegraf-Bogen, überspringbar), Feuerbälle im Flug, Enrage mit Doppel-Sturz. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
|
||||
| `Maze.ts` / `MazeGuard.ts` / `Gnome.ts` | Labyrinth-Zwischenboss (Welle 5/15/25/…, `Game.MAZE_WAVES`): randomisierter Prim-Generator (11×11, `collidePlayer`-Kollision, `hasLineOfSight`), Wächter-Geister aktivieren sich erst bei Spielernähe (`MazeGuard.chase`), Gnom als Ziel (`Gnome.ts`, `isBoss`, nur mit Sichtlinie verletzbar, ab Level 2 doppeltes Ziel). Spieler-Kollision nur während `mazeActive`; `#maze-arrow`-Overlay zeigt den Weg; Boss-Modus-Buttons „Labyrinth (Welle X)" |
|
||||
| `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf |
|
||||
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` |
|
||||
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret, Shaman, Archer, Bomber, Wisp, Turtle, SporeMushroom) | Prozedurale Gegner-Modelle; je `update(dt, game)`. Schamane heilt Gegner im Radius (Prioritätsziel), Bogenschütze hält Distanz + Pfeil mit Boden-Telegraf, Bomber explodiert bei Nähe (AoE), Irrlicht schießt Homing-Sporen (Slow), Schildkröte blockt vorn komplett + Panzer-Rückzug (unverwundbar), Sporen-Pilz wirft Bogen-Sporen mit DoT-Wolke |
|
||||
| `SoundManager.ts` | Vorab geladene .ogg-Buffer (`playSound(name, volume)`); Sounds: damage, spawn, fireball, explosion, teleport, gameover, sword_hit1, spiderwalking |
|
||||
| `MeshLoader.ts` | `loadGLB(name)` → `web/public/models/*.glb`; Texturen in `web/public/textures/` |
|
||||
|
||||
@@ -50,7 +67,10 @@ npm run dev # Dev-Server mit HMR
|
||||
- **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen").
|
||||
- **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen.
|
||||
- **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf).
|
||||
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung) und `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf). Alles wird in `Player.reset()` zurückgesetzt.
|
||||
- **Balance**: XP-Kurve `30 + 15·Level` (steiler, Skills maxen langsamer); Level-Ups geben genau **1** Skill-Punkt (Multi-Level nur bei XP-Überschuss, `pendingPicks`); Gegner-HP skaliert erst NACH den Bossen (jede 10. Welle = 1 Boss-Gate → +20% HP, nur Nicht-Bosse, in `spawnEnemyInstance`); neue Gegner erscheinen in `enemyWeights` nur nach Boss-Wellen (vor Golem nur Spinnen/Frösche, dann nach Golem/Krake/Minotaurus/Spinnenkönigin/Frostriese/Nekromant/Drache); Boss-Modus-Grants `wave·0.4` Skills. Arena-Theme: `Arena.applyTheme(stage)` (8 Stufen, Boden/Wände/Nebel/Licht) wird in `Game` nach jedem Boss-Sieg (`arenaStage++`) getriggert, weiches Lerpen in `arena.update(dt)`.
|
||||
- **Gegner-Spawn im Sichtbereich**: `spawnEnemy`/`spawnBossForWave` spawnen im Ring 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).
|
||||
- **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.
|
||||
|
||||
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;
|
||||
font-family: monospace;
|
||||
}
|
||||
#maze-arrow {
|
||||
position: absolute;
|
||||
width: 40px; height: 40px;
|
||||
color: #ffcc44; font-size: 30px; line-height: 40px; text-align: center;
|
||||
text-shadow: 0 0 8px #ff8800, 0 0 2px #000;
|
||||
font-family: monospace;
|
||||
pointer-events: none; user-select: none;
|
||||
opacity: 0; visibility: hidden;
|
||||
}
|
||||
#game-over {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
|
||||
@@ -286,6 +295,7 @@
|
||||
<div id="damage-vignette"></div>
|
||||
<div id="low-hp-vignette"></div>
|
||||
<div id="ink-vignette"></div>
|
||||
<div id="maze-arrow">▲</div>
|
||||
<div id="boss-bar">
|
||||
<div id="boss-bar-label">BOSS</div>
|
||||
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
|
||||
@@ -400,6 +410,16 @@
|
||||
<div id="changelog-panel">
|
||||
<h2>Changelog</h2>
|
||||
<div id="changelog-list">
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Labyrinth-Zwischenboss (Welle 5, 15, 25, …): generiertes Labyrinth, Gnom als Ziel, Zeitlimit, Wächter-Geister ab Level 2, Gnom 2x finden ab Welle 25, Pfeil zeigt den Weg, Boss-Modus-Buttons</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Level-Up-Bug behoben: genau 1 Skill-Punkt pro Level (vorher 2); Gegner werden erst nach den Bossen stärker, neue Monster erscheinen erst nach jedem Boss</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Balance: steilere XP-Kurve, weniger Boss-Modus-Skills, Feuerball abgeschwächt (Kettenblitz/Falle/Gaswolke gepuffert), Gegner-HP skaliert mit der Welle</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Arena verändert sich nach jedem Boss-Sieg (Boden, Wände, Nebel und Licht färben sich um)</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Neue Gegner werden nach den Bossen nach und nach freigeschaltet statt ab Welle 4-7</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Sechs neue Gegner: Schamane (heilt), Bogenschütze (Telegraf-Pfeile), Bomber (Explosion), Irrlicht (Slow-Sporen), Schildkröte (Frontpanzer + Rückzug) und Sporen-Pilz (Bogen-Sporen mit DoT-Wolke)</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Frostriese (Welle 50): Eispanzer-Rüstung, Eisberst-Ringe und Eiszapfen-Regen</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Spinnenkönigin (Welle 40): Netze fesseln den Spieler, Spinngeschwister und Säuresalven</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Minotaurus-Boss mit Frontpanzerung, Dash-Stun und überspringbarem Axthieb</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Boss-Treffer-Feedback mit Aufblitzen und schwebenden Schadenszahlen</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Krake-Boss mit Tinte, Zug-Markierungen und Sweep-Tentakeln plus Boss-Modus</span></div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
interface Theme {
|
||||
floorLight: number;
|
||||
floorDark: number;
|
||||
wall: number;
|
||||
fog: number;
|
||||
}
|
||||
|
||||
// 8 Stufen: Start + eine pro besiegten Boss (Golem, Krake, Minotaurus,
|
||||
// Spinnenkönigin, Frostriese, Erz-Nekromant, Drache)
|
||||
const THEMES: Theme[] = [
|
||||
{ floorLight: 0x668866, floorDark: 0x557755, wall: 0x776677, fog: 0x0d1117 }, // Start (Grün)
|
||||
{ floorLight: 0x8a7a55, floorDark: 0x7a6a48, wall: 0x776055, fog: 0x141210 }, // Golem (Erdig)
|
||||
{ floorLight: 0x3a4a66, floorDark: 0x2f3d55, wall: 0x44506a, fog: 0x0a1220 }, // Krake (Tinte)
|
||||
{ floorLight: 0x8a4a3a, floorDark: 0x6e3a2c, wall: 0x7a4438, fog: 0x180d0b }, // Minotaurus (Asche)
|
||||
{ floorLight: 0x4a7a4a, floorDark: 0x3a643a, wall: 0x4a6a4a, fog: 0x0a160d }, // Spinnenkönigin (Gift)
|
||||
{ floorLight: 0x88bbcc, floorDark: 0x6a9ab0, wall: 0x7aa0b5, fog: 0x0d1a24 }, // Frostriese (Eis)
|
||||
{ floorLight: 0x6a4a8a, floorDark: 0x543a70, wall: 0x5a3f74, fog: 0x140a20 }, // Erz-Nekromant (Lila)
|
||||
{ floorLight: 0xaa6a33, floorDark: 0x8a5428, wall: 0x8f5c34, fog: 0x1e0f06 }, // Drache (Glut)
|
||||
];
|
||||
|
||||
const LERP_SPEED = 2;
|
||||
|
||||
export class Arena extends THREE.Group {
|
||||
private lightMats: THREE.MeshStandardMaterial[] = [];
|
||||
private darkMats: THREE.MeshStandardMaterial[] = [];
|
||||
private wallMats: THREE.MeshToonMaterial[] = [];
|
||||
private targets:
|
||||
| { floorLight: THREE.Color; floorDark: THREE.Color; wall: THREE.Color }
|
||||
| null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -23,6 +52,7 @@ export class Arena extends THREE.Group {
|
||||
tile.position.set(x, -0.15, z);
|
||||
tile.receiveShadow = true;
|
||||
this.add(tile);
|
||||
(isDark ? this.darkMats : this.lightMats).push(tileMat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +68,12 @@ export class Arena extends THREE.Group {
|
||||
this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
|
||||
this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
|
||||
}
|
||||
for (const wall of this.children) {
|
||||
const mesh = wall as THREE.Mesh;
|
||||
if (mesh.material instanceof THREE.MeshToonMaterial) {
|
||||
this.wallMats.push(mesh.material);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) {
|
||||
@@ -47,4 +83,30 @@ export class Arena extends THREE.Group {
|
||||
wall.receiveShadow = true;
|
||||
this.add(wall);
|
||||
}
|
||||
|
||||
// Setzt die Ziel-Farben für eine Theme-Stufe und gibt die Nebel-Farbe zurück
|
||||
applyTheme(stage: number): THREE.Color {
|
||||
const t = THEMES[Math.min(Math.max(stage, 0), THEMES.length - 1)];
|
||||
this.targets = {
|
||||
floorLight: new THREE.Color(t.floorLight),
|
||||
floorDark: new THREE.Color(t.floorDark),
|
||||
wall: new THREE.Color(t.wall),
|
||||
};
|
||||
return new THREE.Color(t.fog);
|
||||
}
|
||||
|
||||
// Lerpt die aktuellen Farben weich in Richtung Ziel-Theme
|
||||
update(dt: number) {
|
||||
if (!this.targets) return;
|
||||
const t = Math.min(1, dt * LERP_SPEED);
|
||||
for (const mat of this.lightMats) {
|
||||
mat.color.lerp(this.targets.floorLight, t);
|
||||
}
|
||||
for (const mat of this.darkMats) {
|
||||
mat.color.lerp(this.targets.floorDark, t);
|
||||
}
|
||||
for (const mat of this.wallMats) {
|
||||
mat.color.lerp(this.targets.wall, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
protected model: THREE.Group;
|
||||
protected legL!: THREE.Group;
|
||||
protected legR!: THREE.Group;
|
||||
protected armL: THREE.Group | null = null;
|
||||
protected armR: THREE.Group | null = null;
|
||||
private stepTime = 0;
|
||||
private walkAmp = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -21,7 +26,11 @@ export class Brute extends Enemy {
|
||||
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
|
||||
const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 });
|
||||
|
||||
// Torso
|
||||
// Becken + Torso
|
||||
const pelvis = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.35, 0.55), darkMat);
|
||||
pelvis.position.y = 0.62;
|
||||
this.registerFadeMesh(pelvis);
|
||||
this.model.add(pelvis);
|
||||
const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat);
|
||||
torso.position.y = 0.95;
|
||||
torso.castShadow = true;
|
||||
@@ -61,6 +70,26 @@ export class Brute extends Enemy {
|
||||
this.registerFadeMesh(eye);
|
||||
this.model.add(eye);
|
||||
}
|
||||
|
||||
// Beine (Gruppen mit Hüft-Pivot für die Laufanimation)
|
||||
for (const side of [-1, 1]) {
|
||||
const leg = new THREE.Group();
|
||||
leg.position.set(side * 0.35, 0.6, 0);
|
||||
this.model.add(leg);
|
||||
const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.35, 6), rockMat);
|
||||
thigh.position.y = -0.17;
|
||||
this.registerFadeMesh(thigh);
|
||||
leg.add(thigh);
|
||||
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.12, 0.4), darkMat);
|
||||
foot.position.set(0, -0.5, 0.06);
|
||||
this.registerFadeMesh(foot);
|
||||
leg.add(foot);
|
||||
if (side < 0) {
|
||||
this.legL = leg;
|
||||
} else {
|
||||
this.legR = leg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playAnim() {
|
||||
@@ -81,6 +110,7 @@ export class Brute extends Enemy {
|
||||
.subVectors(game.player.body.position, this.body.position);
|
||||
toPlayer.y = 0;
|
||||
const dist = toPlayer.length();
|
||||
let moved = false;
|
||||
if (dist > 0.1) {
|
||||
const dir = toPlayer.normalize();
|
||||
this.body.lookAt(
|
||||
@@ -96,7 +126,16 @@ export class Brute extends Enemy {
|
||||
this.body.position.x += moveDir.x * dt;
|
||||
this.body.position.z += moveDir.z * dt;
|
||||
game.clampToArena(this.body.position);
|
||||
moved = true;
|
||||
}
|
||||
|
||||
// Laufanimation: Beine gegengleich im Takt der Schritte, Arme leicht mit
|
||||
this.walkAmp += ((moved ? 0.5 : 0) - this.walkAmp) * Math.min(1, dt * 8);
|
||||
const swing = Math.sin(this.stepTime * 3) * this.walkAmp;
|
||||
this.legL.rotation.x = swing;
|
||||
this.legR.rotation.x = -swing;
|
||||
if (this.armL) this.armL.rotation.x = -swing * 0.4;
|
||||
if (this.armR) this.armR.rotation.x = swing * 0.4;
|
||||
}
|
||||
|
||||
protected onDeath(_game: Game) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as THREE from 'three';
|
||||
import { Weapon } from './Weapon';
|
||||
import { Player } from './Player';
|
||||
import { lightningDamage, lightningJumps, lightningCooldown } from './Skills';
|
||||
import { lightningDamage, lightningJumps, lightningJumpRange, lightningCooldown } from './Skills';
|
||||
|
||||
const CHAIN_FIRST_RANGE = 14;
|
||||
const CHAIN_JUMP_RANGE = 8;
|
||||
const FLASH_TIME = 0.35;
|
||||
|
||||
export class ChainLightning extends Weapon {
|
||||
@@ -25,6 +24,7 @@ export class ChainLightning extends Weapon {
|
||||
const game = player.game;
|
||||
const damage = lightningDamage(level);
|
||||
const maxJumps = lightningJumps(level);
|
||||
const jumpRange = lightningJumpRange(level);
|
||||
|
||||
// Chain through enemies, always the closest un-hit one
|
||||
const start = player.body.position.clone();
|
||||
@@ -35,7 +35,7 @@ export class ChainLightning extends Weapon {
|
||||
|
||||
for (let i = 0; i < maxJumps; i++) {
|
||||
let best: import('./Enemy').Enemy | null = null;
|
||||
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : CHAIN_JUMP_RANGE;
|
||||
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : jumpRange;
|
||||
for (const enemy of game.enemies) {
|
||||
if (enemy.dead || hit.has(enemy)) continue;
|
||||
const dist = enemy.body.position.distanceTo(from);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+395
-27
@@ -11,8 +11,21 @@ import { Brute } from './Brute';
|
||||
import { Golem } from './Golem';
|
||||
import { Krake } from './Krake';
|
||||
import { Minotaurus } from './Minotaurus';
|
||||
import { SpiderQueen } from './SpiderQueen';
|
||||
import { FrostGiant } from './FrostGiant';
|
||||
import { Necromancer } from './Necromancer';
|
||||
import { Dragon } from './Dragon';
|
||||
import { Maze } from './Maze';
|
||||
import { MazeGuard } from './MazeGuard';
|
||||
import { Gnome } from './Gnome';
|
||||
import { Crab } from './Crab';
|
||||
import { Turret } from './Turret';
|
||||
import { Shaman } from './Shaman';
|
||||
import { Archer } from './Archer';
|
||||
import { Bomber } from './Bomber';
|
||||
import { Wisp } from './Wisp';
|
||||
import { Turtle } from './Turtle';
|
||||
import { SporeMushroom } from './SporeMushroom';
|
||||
import { EnemyProjectile } from './EnemyProjectile';
|
||||
import { Sword } from './Sword';
|
||||
import { Staff } from './Staff';
|
||||
@@ -78,11 +91,46 @@ export class Game {
|
||||
spawns = 0;
|
||||
killed = 0;
|
||||
|
||||
// Stationäre Gegner, die zu lange außerhalb des Bildschirms sind, werden
|
||||
// wieder in die Nähe des Spielers teleportiert (Off-Screen-Reset).
|
||||
private offscreenTimers = new Map<Enemy, number>();
|
||||
private static readonly OFFSCREEN_REPOSITION_TIME = 8;
|
||||
private static readonly SPAWN_VIEW_MIN = 10;
|
||||
private static readonly SPAWN_VIEW_MAX = 24;
|
||||
|
||||
// Arena-Theme: eine Stufe pro besiegten Boss
|
||||
private arena!: Arena;
|
||||
private arenaStage = 0;
|
||||
private themeFogTarget = new THREE.Color(0x0d1117);
|
||||
private themeLightTarget = new THREE.Color(0xffeebb);
|
||||
private static readonly THEME_LIGHTS = [
|
||||
0xffeebb, 0xffddaa, 0x99ccff, 0xffbb99, 0xbbffcc, 0xbbddff, 0xddbbff, 0xffcc88,
|
||||
];
|
||||
|
||||
// Labyrinth-Zwischenboss (Welle 5, 15, 25, ...): ersetzt die Welle komplett
|
||||
maze: Maze | null = null;
|
||||
private mazeActive = false;
|
||||
private mazeTimer = 0;
|
||||
private mazeTotal = 1;
|
||||
private mazeLevel = 0;
|
||||
private mazeGoalsLeft = 0;
|
||||
private mazeGnome: Gnome | null = null;
|
||||
private mazeGuards: MazeGuard[] = [];
|
||||
private uiMazeArrow!: HTMLElement;
|
||||
private static readonly MAZE_WAVES = [5, 15, 25, 35, 45, 55, 65];
|
||||
private static readonly MAZE_BASE_TIME = 75;
|
||||
private static readonly MAZE_TIME_STEP = 5;
|
||||
private static readonly MAZE_MIN_TIME = 50;
|
||||
|
||||
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
|
||||
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
||||
[10, { name: 'Golem', spawn: () => new Golem() }],
|
||||
[20, { name: 'Krake', spawn: () => new Krake() }],
|
||||
[30, { name: 'Minotaurus', spawn: () => new Minotaurus() }],
|
||||
[40, { name: 'Spinnenkönigin', spawn: () => new SpiderQueen() }],
|
||||
[50, { name: 'Frostriese', spawn: () => new FrostGiant() }],
|
||||
[60, { name: 'Erz-Nekromant', spawn: () => new Necromancer() }],
|
||||
[70, { name: 'Drache', spawn: () => new Dragon() }],
|
||||
]);
|
||||
private bossFightActive = false;
|
||||
private shakeTime = 0;
|
||||
@@ -219,6 +267,7 @@ export class Game {
|
||||
this.uiLightningContainer = document.getElementById('lightning-cooldown')!;
|
||||
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
|
||||
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
|
||||
this.uiMazeArrow = document.getElementById('maze-arrow')!;
|
||||
this.uiGameOver = document.getElementById('game-over')!;
|
||||
this.uiGameOverPanel = document.getElementById('game-over-panel')!;
|
||||
this.uiGameOverStats = document.getElementById('game-over-stats')!;
|
||||
@@ -309,7 +358,8 @@ export class Game {
|
||||
return;
|
||||
}
|
||||
if (e.code === 'Space' && this.state === 'playing'
|
||||
&& this.player.pullTime <= 0 && this.player.stunTime <= 0) {
|
||||
&& this.player.pullTime <= 0 && this.player.stunTime <= 0
|
||||
&& this.player.rootTime <= 0) {
|
||||
this.player.jump();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -433,8 +483,8 @@ export class Game {
|
||||
}
|
||||
|
||||
private setupArena() {
|
||||
const arena = new Arena();
|
||||
this.scene.add(arena);
|
||||
this.arena = new Arena();
|
||||
this.scene.add(this.arena);
|
||||
}
|
||||
|
||||
private setupPlayer() {
|
||||
@@ -576,6 +626,15 @@ export class Game {
|
||||
btn.addEventListener('click', () => this.startBossFight(wave));
|
||||
this.uiBossModeButtons.appendChild(btn);
|
||||
}
|
||||
for (const wave of Game.MAZE_WAVES) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'menu-button';
|
||||
btn.textContent = `Labyrinth (Welle ${wave})`;
|
||||
btn.style.fontSize = '14px';
|
||||
btn.style.padding = '8px 20px';
|
||||
btn.addEventListener('click', () => this.startMazeFight(wave));
|
||||
this.uiBossModeButtons.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
private showBossMode() {
|
||||
@@ -611,8 +670,18 @@ export class Game {
|
||||
this.beginRun();
|
||||
}
|
||||
|
||||
private grantRandomSkills(wave: number) {
|
||||
const picks = Math.round(wave * 0.7);
|
||||
startMazeFight(wave: number) {
|
||||
if (this.state === 'playing' || this.state === 'levelup') return;
|
||||
this.seed = Math.floor(Math.random() * 1000000);
|
||||
this.resetWorld();
|
||||
this.spawns = wave;
|
||||
this.grantRandomSkills(wave, 0.25);
|
||||
this.startMazeWave();
|
||||
this.beginRun();
|
||||
}
|
||||
|
||||
private grantRandomSkills(wave: number, factor = 0.4) {
|
||||
const picks = Math.round(wave * factor);
|
||||
for (let i = 0; i < picks; i++) {
|
||||
const offers = this.skillSystem.rollOffers();
|
||||
if (offers.length === 0) break;
|
||||
@@ -624,6 +693,157 @@ export class Game {
|
||||
this.syncSkillVisibility();
|
||||
}
|
||||
|
||||
// --- Labyrinth-Zwischenboss ---
|
||||
|
||||
private isMazeWave(wave: number): boolean {
|
||||
return Game.MAZE_WAVES.includes(wave);
|
||||
}
|
||||
|
||||
private startMazeWave() {
|
||||
this.disposeMaze();
|
||||
this.maze = new Maze(this.scene);
|
||||
this.mazeActive = true;
|
||||
this.mazeLevel = Math.max(0, Math.floor((this.spawns - 5) / 10));
|
||||
|
||||
// Spieler zur Startecke
|
||||
const start = this.maze.startPosition();
|
||||
this.player.body.position.set(start.x, 0.5, start.z);
|
||||
this.player.velocity.set(0, 0, 0);
|
||||
|
||||
// Ab Level 2 (Welle 25) muss der Gnom 2x gefunden werden
|
||||
this.mazeGoalsLeft = this.mazeLevel >= 2 ? 2 : 1;
|
||||
this.mazeTimer = Math.max(
|
||||
Game.MAZE_MIN_TIME,
|
||||
Game.MAZE_BASE_TIME - Game.MAZE_TIME_STEP * this.mazeLevel
|
||||
);
|
||||
this.mazeTotal = this.mazeTimer;
|
||||
|
||||
this.spawnMazeGnome();
|
||||
this.spawnMazeGuards();
|
||||
this.updateMazeArrow();
|
||||
playSound('spawn', 0.8);
|
||||
}
|
||||
|
||||
private spawnMazeGnome() {
|
||||
if (!this.maze) return;
|
||||
const gnome = new Gnome(150 + 100 * this.mazeLevel, 60 + 30 * this.mazeLevel);
|
||||
const goal = this.maze.goalPosition(this.mazeGoalsLeft - 1);
|
||||
this.mazeGnome = gnome;
|
||||
this.spawnEnemyInstance(gnome, goal.x, goal.z, 0.1);
|
||||
}
|
||||
|
||||
private spawnMazeGuards() {
|
||||
if (!this.maze) return;
|
||||
const count = this.mazeLevel >= 2 ? 4 : this.mazeLevel >= 1 ? 2 : 0;
|
||||
if (count <= 0) return;
|
||||
const spots = this.maze.guardPositions(count);
|
||||
for (const pos of spots) {
|
||||
const guard = new MazeGuard();
|
||||
this.mazeGuards.push(guard);
|
||||
this.spawnEnemyInstance(guard, pos.x, pos.z, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
private updateMaze(dt: number) {
|
||||
if (!this.mazeActive || !this.maze) return;
|
||||
this.mazeTimer -= dt;
|
||||
this.updateMazeArrow();
|
||||
|
||||
if (this.mazeTimer <= 0) {
|
||||
// Fehlschlag: neues Labyrinth, zurück zur Startecke
|
||||
playSound('explosion', 0.5);
|
||||
this.spawnDamageText('ZEIT ABGELAUFEN!', this.player.body.position, '#ff6644');
|
||||
this.triggerCameraShake(0.3, 0.3);
|
||||
this.startMazeWave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Gnom besiegt?
|
||||
if (this.mazeGnome && this.mazeGnome.dead) {
|
||||
this.mazeGoalsLeft--;
|
||||
this.mazeGnome = null;
|
||||
if (this.mazeGoalsLeft > 0) {
|
||||
this.spawnMazeGnome();
|
||||
} else {
|
||||
this.endMazeWave(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private endMazeWave(success: boolean) {
|
||||
this.mazeActive = false;
|
||||
this.disposeMaze();
|
||||
if (success) {
|
||||
playSound('explosion', 0.7);
|
||||
this.triggerCameraShake(0.3, 0.3);
|
||||
}
|
||||
this.spawnTimer = 0;
|
||||
}
|
||||
|
||||
private disposeMaze() {
|
||||
if (this.maze) {
|
||||
this.maze.dispose();
|
||||
this.maze = null;
|
||||
}
|
||||
this.mazeActive = false;
|
||||
// Lebenden Gnom (Fehlschlag/Neustart) entfernen; tote bleiben als Leichen
|
||||
if (this.mazeGnome && !this.mazeGnome.dead) {
|
||||
this.mazeGnome.dispose();
|
||||
this.scene.remove(this.mazeGnome.body);
|
||||
const idx = this.enemies.indexOf(this.mazeGnome);
|
||||
if (idx >= 0) this.enemies.splice(idx, 1);
|
||||
}
|
||||
this.mazeGnome = null;
|
||||
for (const guard of this.mazeGuards) {
|
||||
if (!guard.dead) {
|
||||
guard.dispose();
|
||||
this.scene.remove(guard.body);
|
||||
const idx = this.enemies.indexOf(guard);
|
||||
if (idx >= 0) this.enemies.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
this.mazeGuards = [];
|
||||
this.hideMazeArrow();
|
||||
}
|
||||
|
||||
// Pfeil am Bildschirmrand zeigt zum aktuellen Ziel-Gnom
|
||||
private updateMazeArrow() {
|
||||
const el = this.uiMazeArrow;
|
||||
if (!this.mazeActive || !this.mazeGnome || !this.maze) {
|
||||
this.hideMazeArrow();
|
||||
return;
|
||||
}
|
||||
const goal = this.mazeGnome.body.position;
|
||||
const v = goal.clone().project(this.camera);
|
||||
const onScreen = v.x > -1 && v.x < 1 && v.y > -1 && v.y < 1 && v.z < 1;
|
||||
if (onScreen) {
|
||||
this.hideMazeArrow();
|
||||
return;
|
||||
}
|
||||
const clx = THREE.MathUtils.clamp(v.x, -0.8, 0.8);
|
||||
const cly = THREE.MathUtils.clamp(v.y, -0.8, 0.8);
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
// NDC-y zeigt nach oben, Bildschirm-y nach unten -> y flippen
|
||||
const ax = clx * cx;
|
||||
const ay = -cly * cy;
|
||||
const gx = v.x * cx;
|
||||
const gy = -v.y * cy;
|
||||
el.style.left = `${cx + ax}px`;
|
||||
el.style.top = `${cy + ay}px`;
|
||||
el.style.transform = `translate(-50%, -50%) rotate(${Math.atan2(gy - ay, gx - ax)}rad)`;
|
||||
el.style.opacity = '1';
|
||||
el.style.visibility = 'visible';
|
||||
el.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
private hideMazeArrow() {
|
||||
const el = this.uiMazeArrow;
|
||||
el.style.opacity = '0';
|
||||
el.style.visibility = 'hidden';
|
||||
el.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
private blurFocus() {
|
||||
const el = document.activeElement;
|
||||
if (el instanceof HTMLElement) el.blur();
|
||||
@@ -756,6 +976,19 @@ export class Game {
|
||||
this.spawns = 0;
|
||||
this.killed = 0;
|
||||
this.bossFightActive = false;
|
||||
this.offscreenTimers.clear();
|
||||
|
||||
// Arena-Theme zurücksetzen
|
||||
this.arenaStage = 0;
|
||||
this.themeFogTarget.setHex(0x0d1117);
|
||||
this.themeLightTarget.setHex(0xffeebb);
|
||||
this.arena.applyTheme(0);
|
||||
(this.scene.fog as THREE.Fog).color.copy(this.themeFogTarget);
|
||||
(this.scene.background as THREE.Color).copy(this.themeFogTarget);
|
||||
this.playerLight.color.copy(this.themeLightTarget);
|
||||
|
||||
// Labyrinth aufräumen
|
||||
this.disposeMaze();
|
||||
this.shakeTime = 0;
|
||||
this.pendingPicks = 0;
|
||||
this.hideLevelUp();
|
||||
@@ -877,10 +1110,11 @@ export class Game {
|
||||
this.syncSkillVisibility();
|
||||
this.blurFocus();
|
||||
|
||||
if (this.pendingPicks > 0) {
|
||||
this.pendingPicks--;
|
||||
this.openLevelUp();
|
||||
} else if (this.skillSystem.hasPendingLevelUp()) {
|
||||
// Genau 1 Skill-Punkt pro Level: Pick verbrauchen und nur weiter aufmachen,
|
||||
// wenn noch Picks übrig sind (Multi-Level durch XP-Überschuss) oder
|
||||
// Rest-XP bereits die nächste Stufe erreicht hat.
|
||||
this.pendingPicks = Math.max(0, this.pendingPicks - 1);
|
||||
if (this.pendingPicks > 0 || this.skillSystem.hasPendingLevelUp()) {
|
||||
this.openLevelUp();
|
||||
} else {
|
||||
this.hideLevelUp();
|
||||
@@ -922,8 +1156,13 @@ export class Game {
|
||||
}
|
||||
|
||||
private spawnEnemy() {
|
||||
const x = (Math.random() - 0.5) * 80;
|
||||
const z = (Math.random() - 0.5) * 80;
|
||||
// Immer im Sichtbereich spawnen (Ring um den Spieler), damit auch
|
||||
// stationäre Gegner nicht auf der Arena gesucht werden müssen.
|
||||
const p = this.player.body.position;
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
|
||||
const x = Math.max(-46, Math.min(46, p.x + Math.cos(a) * r));
|
||||
const z = Math.max(-46, Math.min(46, p.z + Math.sin(a) * r));
|
||||
|
||||
const weights = this.enemyWeights(this.spawns);
|
||||
let total = 0;
|
||||
@@ -948,24 +1187,52 @@ export class Game {
|
||||
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
|
||||
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
|
||||
case 'turret': this.spawnEnemyInstance(new Turret(), x, z, 0.1); break;
|
||||
case 'shaman': this.spawnEnemyInstance(new Shaman(), x, z, 0.1); break;
|
||||
case 'archer': this.spawnEnemyInstance(new Archer(), x, z, 0.1); break;
|
||||
case 'bomber': this.spawnEnemyInstance(new Bomber(), x, z, 0.1); break;
|
||||
case 'wisp': this.spawnEnemyInstance(new Wisp(), x, z, 1.0); break;
|
||||
case 'turtle': this.spawnEnemyInstance(new Turtle(), x, z, 0.1); break;
|
||||
case 'spore': this.spawnEnemyInstance(new SporeMushroom(), x, z, 0.1); break;
|
||||
}
|
||||
}
|
||||
|
||||
private enemyWeights(wave: number): Map<string, number> {
|
||||
const w = new Map<string, number>();
|
||||
if (wave >= 7) {
|
||||
w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10)
|
||||
.set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5);
|
||||
} else if (wave >= 6) {
|
||||
w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10)
|
||||
.set('bat', 10).set('turret', 10);
|
||||
} else if (wave >= 5) {
|
||||
w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10)
|
||||
.set('bat', 10);
|
||||
} else if (wave >= 4) {
|
||||
w.set('spider', 55).set('ghost', 25).set('frog', 20);
|
||||
// Neue Gegner erscheinen NUR nach den Boss-Wellen (10/20/30/40/50/60/70).
|
||||
// Vor dem ersten Boss (1-10) gibt es nur Spinnen und Frösche.
|
||||
if (wave >= 71) {
|
||||
w.set('spider', 15).set('frog', 10).set('ghost', 10).set('slime', 8)
|
||||
.set('bat', 7).set('archer', 6).set('crab', 7).set('turret', 7)
|
||||
.set('bomber', 6).set('bee', 5).set('brute', 4).set('shaman', 5)
|
||||
.set('spore', 5).set('wisp', 4).set('turtle', 4);
|
||||
} else if (wave >= 61) {
|
||||
w.set('spider', 16).set('frog', 11).set('ghost', 11).set('slime', 8)
|
||||
.set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8)
|
||||
.set('bomber', 7).set('bee', 5).set('brute', 4).set('shaman', 5)
|
||||
.set('spore', 5).set('wisp', 5);
|
||||
} else if (wave >= 51) {
|
||||
w.set('spider', 17).set('frog', 12).set('ghost', 12).set('slime', 8)
|
||||
.set('bat', 8).set('archer', 7).set('crab', 8).set('turret', 8)
|
||||
.set('bomber', 7).set('bee', 5).set('brute', 4).set('shaman', 5)
|
||||
.set('spore', 5);
|
||||
} else if (wave >= 41) {
|
||||
w.set('spider', 18).set('frog', 13).set('ghost', 13).set('slime', 8)
|
||||
.set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8)
|
||||
.set('bomber', 8).set('bee', 5).set('brute', 5).set('shaman', 5)
|
||||
.set('spore', 5);
|
||||
} else if (wave >= 31) {
|
||||
w.set('spider', 20).set('frog', 14).set('ghost', 14).set('slime', 8)
|
||||
.set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8)
|
||||
.set('bomber', 8).set('bee', 5).set('brute', 5);
|
||||
} else if (wave >= 21) {
|
||||
w.set('spider', 22).set('frog', 15).set('ghost', 15).set('slime', 8)
|
||||
.set('bat', 8).set('archer', 8).set('crab', 8).set('turret', 8)
|
||||
.set('bomber', 8);
|
||||
} else if (wave >= 11) {
|
||||
w.set('spider', 28).set('frog', 18).set('ghost', 18)
|
||||
.set('bat', 9);
|
||||
} else if (wave >= 3) {
|
||||
w.set('spider', 75).set('ghost', 25);
|
||||
w.set('spider', 60).set('frog', 40);
|
||||
} else {
|
||||
w.set('spider', 100);
|
||||
}
|
||||
@@ -980,13 +1247,33 @@ export class Game {
|
||||
const entry = this.bossWaves.get(wave);
|
||||
if (!entry) return;
|
||||
const enemy = entry.spawn();
|
||||
const x = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
||||
const z = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
||||
let x = 0;
|
||||
let z = 0;
|
||||
if (enemy.spawnAtCenter) {
|
||||
// Stationäre Bosse (Krake, Spinnenkönigin) spawnen in der Arena-Mitte
|
||||
} else {
|
||||
// Alle anderen Bosse im Sichtbereich spawnen
|
||||
const p = this.player.body.position;
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
|
||||
x = Math.max(-46, Math.min(46, p.x + Math.cos(a) * r));
|
||||
z = Math.max(-46, Math.min(46, p.z + Math.sin(a) * r));
|
||||
}
|
||||
this.spawnEnemyInstance(enemy, x, z, 0.15);
|
||||
playSound('spawn', 0.8);
|
||||
}
|
||||
|
||||
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
|
||||
// Gegner werden erst NACH den Boss-Wellen stärker: pro besiegten Boss
|
||||
// (jede 10. Welle) +20% HP, davor Basis-HP. Bosse bleiben unskaliert.
|
||||
if (!enemy.isBoss) {
|
||||
const bossGates = Math.floor(Math.max(0, this.spawns - 1) / 10);
|
||||
if (bossGates > 0) {
|
||||
const scale = 1 + 0.2 * bossGates;
|
||||
enemy.health = Math.round(enemy.health * scale);
|
||||
enemy.maxHealth = enemy.health;
|
||||
}
|
||||
}
|
||||
enemy.body.position.set(x, y, z);
|
||||
this.scene.add(enemy.body);
|
||||
this.enemies.push(enemy);
|
||||
@@ -1053,6 +1340,9 @@ export class Game {
|
||||
if (p.stunTime > 0) {
|
||||
// Gestunnt (Stampf-Schockwelle): keine Eingaben
|
||||
p.velocity.set(0, 0, 0);
|
||||
} else if (p.rootTime > 0) {
|
||||
// Netz-Fessel (Spinnenkönigin): keine Bewegung, Angriffe bleiben möglich
|
||||
p.velocity.set(0, 0, 0);
|
||||
} else if (p.pullTime > 0) {
|
||||
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
|
||||
p.pullTime -= dt;
|
||||
@@ -1083,7 +1373,7 @@ export class Game {
|
||||
}
|
||||
|
||||
// Jump / gravity
|
||||
if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0) {
|
||||
if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0 && p.rootTime <= 0) {
|
||||
p.jumpVelocity -= 20 * dt;
|
||||
p.body.position.y += p.jumpVelocity * dt;
|
||||
if (p.body.position.y <= 0.5) {
|
||||
@@ -1098,6 +1388,11 @@ export class Game {
|
||||
p.body.position.z += p.velocity.z * dt;
|
||||
this.clampToArena(p.body.position);
|
||||
|
||||
// Labyrinth-Wände blockieren den Spieler
|
||||
if (this.mazeActive && this.maze) {
|
||||
this.maze.collidePlayer(p.body.position, 0.7);
|
||||
}
|
||||
|
||||
// Look at ground point
|
||||
p.body.lookAt(
|
||||
this.groundPoint.x,
|
||||
@@ -1111,6 +1406,48 @@ export class Game {
|
||||
pos.z = Math.max(-47, Math.min(47, pos.z));
|
||||
}
|
||||
|
||||
// Stationäre Gegner (Turret, Sporen-Pilz, Krake, Spinnenkönigin), die zu
|
||||
// lange außerhalb des Bildschirms sind, werden wieder in Sicht teleportiert.
|
||||
private updateOffscreenReposition(dt: number) {
|
||||
if (this.mazeActive) return;
|
||||
for (const enemy of this.enemies) {
|
||||
if (enemy.dead) {
|
||||
this.offscreenTimers.delete(enemy);
|
||||
continue;
|
||||
}
|
||||
if (!enemy.immovable) continue;
|
||||
if (this.isOnScreen(enemy.body.position)) {
|
||||
this.offscreenTimers.delete(enemy);
|
||||
} else {
|
||||
const t = (this.offscreenTimers.get(enemy) ?? 0) + dt;
|
||||
if (t >= Game.OFFSCREEN_REPOSITION_TIME) {
|
||||
this.offscreenTimers.delete(enemy);
|
||||
this.repositionToView(enemy);
|
||||
} else {
|
||||
this.offscreenTimers.set(enemy, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NDC-Projektion mit Toleranz: |x|,|y| < 1.15 gilt als im Bild
|
||||
private isOnScreen(pos: THREE.Vector3): boolean {
|
||||
const v = pos.clone().project(this.camera);
|
||||
return v.x > -1.15 && v.x < 1.15 && v.y > -1.15 && v.y < 1.15 && v.z < 1;
|
||||
}
|
||||
|
||||
private repositionToView(enemy: Enemy) {
|
||||
const p = this.player.body.position;
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
|
||||
enemy.body.position.set(
|
||||
Math.max(-46, Math.min(46, p.x + Math.cos(a) * r)),
|
||||
enemy.body.position.y,
|
||||
Math.max(-46, Math.min(46, p.z + Math.sin(a) * r))
|
||||
);
|
||||
playSound('spawn', 0.5);
|
||||
}
|
||||
|
||||
private updateEnemies(dt: number) {
|
||||
for (const enemy of this.enemies) {
|
||||
if (enemy.dead || this.state !== 'playing') {
|
||||
@@ -1235,7 +1572,10 @@ export class Game {
|
||||
this.uiGasCD.style.width = `${gasPct * 100}%`;
|
||||
|
||||
const wavePct = Math.min(1, this.spawnTimer / SPAWN_TIME);
|
||||
if (this.bossFightActive) {
|
||||
if (this.mazeActive) {
|
||||
this.uiWaveBarFill.style.width = `${Math.min(1, this.mazeTimer / this.mazeTotal) * 100}%`;
|
||||
this.uiWaveBarLabel.textContent = `Labyrinth: ${Math.ceil(this.mazeTimer)}s`;
|
||||
} else if (this.bossFightActive) {
|
||||
this.uiWaveBarFill.style.width = '100%';
|
||||
this.uiWaveBarLabel.textContent = 'BOSS!';
|
||||
} else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) {
|
||||
@@ -1253,6 +1593,8 @@ export class Game {
|
||||
this.uiLowHpVignette.classList.toggle(
|
||||
'active', healthPct < 0.3 && healthPct > 0
|
||||
);
|
||||
|
||||
this.updateMazeArrow();
|
||||
}
|
||||
|
||||
triggerInkVignette() {
|
||||
@@ -1357,9 +1699,17 @@ export class Game {
|
||||
this.updateGroundPoint();
|
||||
this.updatePlayerMovement(dt);
|
||||
this.updateEnemies(dt);
|
||||
this.updateOffscreenReposition(dt);
|
||||
this.updateFireballs(dt);
|
||||
this.updateHitzones();
|
||||
|
||||
// Arena-Theme weich einfärben (Boden, Wände, Nebel, Licht)
|
||||
this.arena.update(dt);
|
||||
const themeT = Math.min(1, dt * 1.5);
|
||||
(this.scene.fog as THREE.Fog).color.lerp(this.themeFogTarget, themeT);
|
||||
(this.scene.background as THREE.Color).lerp(this.themeFogTarget, themeT);
|
||||
this.playerLight.color.lerp(this.themeLightTarget, themeT);
|
||||
|
||||
// Update slash effects
|
||||
for (let i = this.slashEffects.length - 1; i >= 0; i--) {
|
||||
if (!this.slashEffects[i].update(dt)) {
|
||||
@@ -1388,6 +1738,10 @@ export class Game {
|
||||
|
||||
// Spawn enemies in waves
|
||||
if (this.state === 'playing') {
|
||||
if (this.mazeActive) {
|
||||
// Labyrinth-Zwischenboss: ersetzt die Welle komplett
|
||||
this.updateMaze(dt);
|
||||
} else {
|
||||
const arenaCleared = this.enemies.every(e => e.dead);
|
||||
if (this.bossFightActive) {
|
||||
// Boss besiegt -> naechste Welle startet
|
||||
@@ -1395,6 +1749,14 @@ export class Game {
|
||||
this.bossFightActive = false;
|
||||
this.spawns++;
|
||||
this.spawnTimer = 0;
|
||||
// Arena verändert sich nach jedem besiegten Boss
|
||||
if (this.arenaStage < 7) {
|
||||
this.arenaStage++;
|
||||
this.themeFogTarget.copy(this.arena.applyTheme(this.arenaStage));
|
||||
this.themeLightTarget.setHex(Game.THEME_LIGHTS[this.arenaStage]);
|
||||
this.triggerCameraShake(0.4, 0.4);
|
||||
playSound('explosion', 0.6);
|
||||
}
|
||||
}
|
||||
} else if (this.spawnTimer >= SPAWN_TIME) {
|
||||
if (this.isBossWave(this.spawns + 1)) {
|
||||
@@ -1405,6 +1767,11 @@ export class Game {
|
||||
this.bossFightActive = true;
|
||||
this.spawnTimer = 0;
|
||||
}
|
||||
} else if (this.isMazeWave(this.spawns + 1)) {
|
||||
// Labyrinth-Zwischenboss startet (ersetzt die Welle)
|
||||
this.spawns++;
|
||||
this.startMazeWave();
|
||||
this.spawnTimer = 0;
|
||||
} else {
|
||||
this.spawns++;
|
||||
for (let i = 0; i < this.spawns; i++) {
|
||||
@@ -1416,6 +1783,7 @@ export class Game {
|
||||
this.spawnTimer += dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check close encounters (enemy touching player)
|
||||
if (this.state === 'playing') {
|
||||
|
||||
@@ -198,6 +198,11 @@ export class Ghost extends Enemy {
|
||||
}
|
||||
|
||||
// Chase the player (stays hittable and dangerous even while invisible)
|
||||
this.chase(dt, game);
|
||||
}
|
||||
|
||||
// Getrennt für Subklassen (z. B. MazeGuard: erst aktiv, wenn der Spieler nah ist)
|
||||
protected chase(dt: number, game: Game) {
|
||||
const toPlayer = new THREE.Vector3()
|
||||
.subVectors(game.player.body.position, this.body.position);
|
||||
toPlayer.y = 0;
|
||||
|
||||
@@ -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.guaranteedDrop = true;
|
||||
|
||||
// Brute-Standardmodell verwerfen und als Fels-Golem neu aufbauen
|
||||
this.model.clear();
|
||||
|
||||
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
|
||||
const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 });
|
||||
const mossMat = new THREE.MeshToonMaterial({ color: 0x4a8a4a });
|
||||
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 });
|
||||
// Facettierte Fels-Optik: flache Normalen auf der unindizierten Geometrie
|
||||
const rockGeom = (r: number) => {
|
||||
const g = new THREE.DodecahedronGeometry(r);
|
||||
g.computeVertexNormals();
|
||||
return g;
|
||||
};
|
||||
|
||||
// Becken + Rumpf (facettierte Felsbrocken)
|
||||
const pelvis = new THREE.Mesh(rockGeom(0.42), darkMat);
|
||||
pelvis.scale.set(1.2, 0.8, 0.9);
|
||||
pelvis.position.y = 0.75;
|
||||
this.registerFadeMesh(pelvis);
|
||||
this.model.add(pelvis);
|
||||
const torso = new THREE.Mesh(rockGeom(0.72), rockMat);
|
||||
torso.scale.set(1.25, 1.05, 0.95);
|
||||
torso.position.y = 1.4;
|
||||
torso.castShadow = true;
|
||||
this.registerFadeMesh(torso);
|
||||
this.model.add(torso);
|
||||
|
||||
// Glühender Kern in der Brust (+Z)
|
||||
const core = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.36, 0.08), eyeMat);
|
||||
core.position.set(0, 1.45, 0.66);
|
||||
this.model.add(core);
|
||||
|
||||
// Kopf mit Stirnplatte, Augen und Kiefer (+Z)
|
||||
const head = new THREE.Mesh(rockGeom(0.38), rockMat);
|
||||
head.position.y = 2.15;
|
||||
head.castShadow = true;
|
||||
this.registerFadeMesh(head);
|
||||
this.model.add(head);
|
||||
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.1, 0.12), darkMat);
|
||||
brow.position.set(0, 2.3, 0.3);
|
||||
this.registerFadeMesh(brow);
|
||||
this.model.add(brow);
|
||||
for (const side of [-1, 1]) {
|
||||
const eye = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.09, 0.05), eyeMat);
|
||||
eye.position.set(side * 0.15, 2.15, 0.33);
|
||||
this.model.add(eye);
|
||||
}
|
||||
const jaw = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.12, 0.2), darkMat);
|
||||
jaw.position.set(0, 1.93, 0.25);
|
||||
this.registerFadeMesh(jaw);
|
||||
this.model.add(jaw);
|
||||
|
||||
// Felsstacheln auf dem Rücken
|
||||
for (const side of [-1, 1]) {
|
||||
const spike = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.45, 5), darkMat);
|
||||
spike.position.set(side * 0.3, 1.75, -0.6);
|
||||
spike.rotation.x = 0.6;
|
||||
this.registerFadeMesh(spike);
|
||||
this.model.add(spike);
|
||||
}
|
||||
|
||||
// Moosflecken
|
||||
const mossSpots: [number, number, number, number][] = [
|
||||
[-0.9, 2.15, 0.1, 0.12],
|
||||
[0.5, 1.95, 0.3, 0.1],
|
||||
[0.3, 2.4, -0.1, 0.09],
|
||||
];
|
||||
for (const [x, y, z, r] of mossSpots) {
|
||||
const moss = new THREE.Mesh(rockGeom(r), mossMat);
|
||||
moss.position.set(x, y, z);
|
||||
this.registerFadeMesh(moss);
|
||||
this.model.add(moss);
|
||||
}
|
||||
|
||||
// Schultern + gegliederte Arme (Schulter-Pivot für Laufanimation)
|
||||
for (const side of [-1, 1]) {
|
||||
const shoulder = new THREE.Mesh(rockGeom(0.48), rockMat);
|
||||
shoulder.position.set(side * 0.95, 1.75, 0);
|
||||
shoulder.castShadow = true;
|
||||
this.registerFadeMesh(shoulder);
|
||||
this.model.add(shoulder);
|
||||
|
||||
const arm = new THREE.Group();
|
||||
arm.position.set(side * 0.95, 1.7, 0);
|
||||
this.model.add(arm);
|
||||
const upperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.55, 6), rockMat);
|
||||
upperArm.position.y = -0.3;
|
||||
this.registerFadeMesh(upperArm);
|
||||
arm.add(upperArm);
|
||||
const elbow = new THREE.Mesh(rockGeom(0.18), darkMat);
|
||||
elbow.position.y = -0.6;
|
||||
this.registerFadeMesh(elbow);
|
||||
arm.add(elbow);
|
||||
const foreArm = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.5, 6), darkMat);
|
||||
foreArm.position.y = -0.85;
|
||||
this.registerFadeMesh(foreArm);
|
||||
arm.add(foreArm);
|
||||
const fist = new THREE.Mesh(rockGeom(0.24), rockMat);
|
||||
fist.scale.set(1, 0.8, 1.1);
|
||||
fist.position.y = -1.15;
|
||||
this.registerFadeMesh(fist);
|
||||
arm.add(fist);
|
||||
if (side < 0) {
|
||||
this.armL = arm;
|
||||
} else {
|
||||
this.armR = arm;
|
||||
}
|
||||
}
|
||||
|
||||
// Beine (Hüft-Pivot für Laufanimation)
|
||||
for (const side of [-1, 1]) {
|
||||
const leg = new THREE.Group();
|
||||
leg.position.set(side * 0.4, 0.75, 0);
|
||||
this.model.add(leg);
|
||||
const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.4, 6), rockMat);
|
||||
thigh.position.y = -0.18;
|
||||
this.registerFadeMesh(thigh);
|
||||
leg.add(thigh);
|
||||
const knee = new THREE.Mesh(rockGeom(0.16), darkMat);
|
||||
knee.position.y = -0.38;
|
||||
this.registerFadeMesh(knee);
|
||||
leg.add(knee);
|
||||
const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.35, 6), darkMat);
|
||||
shin.position.y = -0.52;
|
||||
this.registerFadeMesh(shin);
|
||||
leg.add(shin);
|
||||
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.16, 0.5), rockMat);
|
||||
foot.position.set(0, -0.67, 0.08);
|
||||
this.registerFadeMesh(foot);
|
||||
leg.add(foot);
|
||||
if (side < 0) {
|
||||
this.legL = leg;
|
||||
} else {
|
||||
this.legR = leg;
|
||||
}
|
||||
}
|
||||
|
||||
this.model.scale.setScalar(2.1);
|
||||
|
||||
// Crown
|
||||
// Krone (Boss-Markierung)
|
||||
const crownMat = new THREE.MeshToonMaterial({ color: 0xffcc33 });
|
||||
const crown = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.3, 0.4, 8, 1, true),
|
||||
crownMat
|
||||
);
|
||||
crown.position.y = 2.25;
|
||||
crown.position.y = 2.55;
|
||||
crown.castShadow = true;
|
||||
this.registerFadeMesh(crown);
|
||||
this.model.add(crown);
|
||||
|
||||
+240
@@ -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;
|
||||
pullDir = new THREE.Vector3();
|
||||
stunTime = 0;
|
||||
rootTime = 0;
|
||||
rootCooldown = 0;
|
||||
stats: PlayerStats = {
|
||||
maxHealth: 100,
|
||||
regen: 0,
|
||||
@@ -239,6 +241,8 @@ export class Player {
|
||||
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
|
||||
if (this.slowTimer > 0) this.slowTimer -= dt;
|
||||
if (this.stunTime > 0) this.stunTime -= dt;
|
||||
if (this.rootTime > 0) this.rootTime -= dt;
|
||||
if (this.rootCooldown > 0) this.rootCooldown -= dt;
|
||||
|
||||
// Stun-Sterne animieren
|
||||
this.stunStars.visible = this.stunTime > 0;
|
||||
@@ -360,6 +364,8 @@ export class Player {
|
||||
this.slowTimer = 0;
|
||||
this.pullTime = 0;
|
||||
this.stunTime = 0;
|
||||
this.rootTime = 0;
|
||||
this.rootCooldown = 0;
|
||||
this.body.position.set(0, 0.5, 0);
|
||||
this.velocity.set(0, 0, 0);
|
||||
this.jumpVelocity = 0;
|
||||
|
||||
@@ -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',
|
||||
maxLevel: 5,
|
||||
describe: (l) => l === 1
|
||||
? 'Schießt einen Feuerball (100 DMG, 5s CD)'
|
||||
? 'Schießt einen Feuerball (85 DMG, 5.5s CD)'
|
||||
: `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`,
|
||||
},
|
||||
{
|
||||
@@ -37,7 +37,7 @@ export const SKILLS: SkillDef[] = [
|
||||
name: 'Kettenblitz',
|
||||
type: 'active',
|
||||
maxLevel: 5,
|
||||
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD)`,
|
||||
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD, ${lightningJumpRange(l)} m Sprungweite)`,
|
||||
},
|
||||
{
|
||||
id: 'swordRange',
|
||||
@@ -132,23 +132,26 @@ export function getSkill(id: string): SkillDef {
|
||||
}
|
||||
|
||||
export function fireballDamage(level: number): number {
|
||||
return [100, 150, 200, 250, 300][Math.min(level, 5) - 1] ?? 100;
|
||||
return [85, 125, 165, 205, 245][Math.min(level, 5) - 1] ?? 85;
|
||||
}
|
||||
export function fireballCooldown(level: number): number {
|
||||
return [5, 4.5, 4, 3.5, 3][Math.min(level, 5) - 1] ?? 5;
|
||||
return [5.5, 5, 4.5, 4, 3.5][Math.min(level, 5) - 1] ?? 5.5;
|
||||
}
|
||||
export function fireballRadius(level: number): number {
|
||||
return [8, 8, 10, 10, 12][Math.min(level, 5) - 1] ?? 8;
|
||||
return [7, 7, 9, 9, 11][Math.min(level, 5) - 1] ?? 7;
|
||||
}
|
||||
export function teleportCooldown(level: number): number {
|
||||
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
|
||||
}
|
||||
export function lightningDamage(level: number): number {
|
||||
return [40, 60, 80, 100, 120][Math.min(level, 5) - 1] ?? 40;
|
||||
return [55, 75, 95, 115, 135][Math.min(level, 5) - 1] ?? 55;
|
||||
}
|
||||
export function lightningJumps(level: number): number {
|
||||
return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2;
|
||||
}
|
||||
export function lightningJumpRange(level: number): number {
|
||||
return [9, 10, 11, 12, 13][Math.min(level, 5) - 1] ?? 9;
|
||||
}
|
||||
export function lightningCooldown(level: number): number {
|
||||
return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8;
|
||||
}
|
||||
@@ -174,7 +177,7 @@ export function boomerangCount(level: number): number {
|
||||
return [1, 1, 2, 2, 3][Math.min(level, 5) - 1] ?? 1;
|
||||
}
|
||||
export function trapDamage(level: number): number {
|
||||
return [25, 35, 45, 55, 70][Math.min(level, 5) - 1] ?? 25;
|
||||
return [35, 45, 55, 65, 80][Math.min(level, 5) - 1] ?? 35;
|
||||
}
|
||||
export function trapHold(level: number): number {
|
||||
return [1.5, 1.75, 2, 2.25, 2.5][Math.min(level, 5) - 1] ?? 1.5;
|
||||
@@ -186,7 +189,7 @@ export function trapCooldown(level: number): number {
|
||||
return [8, 7, 6.5, 6, 5][Math.min(level, 5) - 1] ?? 8;
|
||||
}
|
||||
export function gasDamage(level: number): number {
|
||||
return [8, 12, 16, 20, 25][Math.min(level, 5) - 1] ?? 8;
|
||||
return [12, 16, 20, 24, 30][Math.min(level, 5) - 1] ?? 12;
|
||||
}
|
||||
export function gasRadius(level: number): number {
|
||||
return [3.5, 4, 4.5, 5, 5.5][Math.min(level, 5) - 1] ?? 3.5;
|
||||
@@ -226,7 +229,7 @@ export class SkillSystem {
|
||||
}
|
||||
|
||||
xpNeeded(): number {
|
||||
return 20 + this.level * 10;
|
||||
return 30 + this.level * 15;
|
||||
}
|
||||
|
||||
xpProgress(): number {
|
||||
|
||||
+24
-12
@@ -16,29 +16,41 @@ export async function initSpiderModel() {
|
||||
}
|
||||
}
|
||||
|
||||
export class Spider extends Enemy {
|
||||
constructor() {
|
||||
super();
|
||||
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
|
||||
|
||||
if (spiderGLTF) {
|
||||
const model = cloneSkeleton(spiderGLTF.scene);
|
||||
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
|
||||
model.scale.set(0.5, 0.5, 0.5);
|
||||
// Wiederverwendbarer Spinnen-Modell-Builder (für Spinne UND Spinnenkönigin).
|
||||
// tint färbt die Textur mit ein (0xffffff = Original).
|
||||
export function buildSpiderModel(scale: number, tint: number): THREE.Group | null {
|
||||
if (!spiderGLTF) return null;
|
||||
const model = cloneSkeleton(spiderGLTF.scene) as THREE.Group;
|
||||
model.scale.set(scale, scale, scale);
|
||||
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
|
||||
spiderTexture.flipY = false;
|
||||
spiderTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
|
||||
child.material = new THREE.MeshToonMaterial({ map: spiderTexture, color: tint });
|
||||
}
|
||||
});
|
||||
return model;
|
||||
}
|
||||
|
||||
// Animationen des Spinnen-Modells (leer, wenn nicht geladen)
|
||||
export function getSpiderAnimations(): THREE.AnimationClip[] {
|
||||
return spiderGLTF ? spiderGLTF.animations : [];
|
||||
}
|
||||
|
||||
export class Spider extends Enemy {
|
||||
constructor() {
|
||||
super();
|
||||
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
|
||||
|
||||
const model = buildSpiderModel(0.5, 0xffffff);
|
||||
if (model) {
|
||||
this.body.add(model);
|
||||
|
||||
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
|
||||
if (getSpiderAnimations().length > 0 && ANIMATIONS_ENABLED) {
|
||||
this.mixer = new THREE.AnimationMixer(model);
|
||||
for (const clip of spiderGLTF.animations) {
|
||||
for (const clip of getSpiderAnimations()) {
|
||||
const action = this.mixer.clipAction(clip);
|
||||
this.clips.set(clip.name, action);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: 'index.html',
|
||||
viewer: 'viewer.html',
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
|
||||
Reference in New Issue
Block a user