Compare commits
18
Commits
6f207ba1a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8065104d8a | ||
|
|
b05835df74 | ||
|
|
d1ba3fc9c3 | ||
|
|
1f311d3208 | ||
|
|
e6a370f952 | ||
|
|
2bffefb02c | ||
|
|
151b07cb4e | ||
|
|
1f67aaa982 | ||
|
|
7d41f6db6c | ||
|
|
496c4af50e | ||
|
|
1a4cccda6e | ||
|
|
5ceb37fdb4 | ||
|
|
18d8654d9d | ||
|
|
110a8596a5 | ||
|
|
64d473de6d | ||
|
|
fd57ca9740 | ||
|
|
3fde0925c5 | ||
|
|
9e3e5c70e0 |
@@ -5,3 +5,72 @@
|
||||
- Git-Commits nur auf explizite Anforderung des Users. Niemals eigenständig committen.
|
||||
- Vor einem Commit: `git status`, `git diff` und `git log --oneline -10` prüfen; nur beabsichtigte Dateien stagen, keine Secrets.
|
||||
- Commit-Message kurz und im Stil des Repos (bisher englisch, imperative).
|
||||
|
||||
## Build & Verifikation
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npx tsc --noEmit # Typcheck (Pflicht nach Änderungen)
|
||||
npm run build # tsc + vite build → web/dist
|
||||
npm run dev # Dev-Server mit HMR
|
||||
```
|
||||
|
||||
- Stack: Three.js 0.170 + TypeScript + Vite. Keine weiteren Laufzeit-Dependencies (kein Postprocessing verfügbar — Screen-Effekte als CSS-Overlays).
|
||||
- `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 |
|
||||
|---|---|
|
||||
| `Game.ts` | Zentrale Klasse: State-Machine, Render-Loop (`animate()`), Input, UI-Verdrahtung, Spawning, Partikel-/Effekt-Arrays (bloodParticles, traps, gasClouds, slashEffects, projectiles), Hotbar |
|
||||
| `Player.ts` | Blob-Spieler: Movement, Sprung, `damage()`/`heal()` (Schild-Absorption), Stats (`applySkillSystem`), Hurt-Feedback (Flash + Blut via `game.onPlayerHurt`) |
|
||||
| `Enemy.ts` | Abstrakte Basis: `body` (THREE.Group), `takeDamage` (Sound + XP/Lifesteal via `game.onDamageDealt`), Fade-Death, Felder `knockback`, `rootTime` |
|
||||
| `Skills.ts` | Alle Skill-Definitionen (`SKILLS`-Array: id, name, type, maxLevel, describe) + Level-Wertetabellen (z. B. `auraRadius(level)`). `SkillSystem` = XP/Level/Angebote. `HOTBAR_SKILLS` = aktive Skills in Erwerbs-Reihenfolge |
|
||||
| `SkillIcons.ts` | SVG-Icons (64×64, Template-Strings) für die Level-up-Karten |
|
||||
| `Weapon.ts` | Basis für aktive Skills: `cooldown1-3` + `reduceCooldowns()` |
|
||||
| `Sword.ts` / `Staff.ts` | Linksklick-Schwert (Bogen-Trefferzone via `SwordTrail.ts`), Rechtsklick-Feuerball + Teleport |
|
||||
| `ChainLightning.ts` | Kettenblitz (Q-Slot) |
|
||||
| `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` / `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, 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/` |
|
||||
|
||||
## Wichtige Konventionen & Fallstricke
|
||||
|
||||
- **Blickrichtung = lokale +Z**: Three.js `Object3D.lookAt` richtet +Z zum Ziel aus. Augen/Gesichter von Gegnern und Spieler müssen auf **+Z** liegen (sonst laufen sie rückwärts — war schon mal ein Bug bei Frosch/Golem/Brute/Slime/Bat/Crab).
|
||||
- **UI-Overlays**: Alle Panels liegen absolut in `#ui-overlay` (`pointer-events: none`). Versteckte Panels brauchen **drei** Dinge: `opacity: 0` + `pointerEvents: none` + `visibility: hidden`. Achtung: `.menu-button { pointer-events: auto }` übersteuert `pointer-events: none` des Parents — ohne `visibility: hidden` sind unsichtbare Buttons klickbar (verursachte „stille Neustarts" mitten im Spiel). Neues Panel → immer `visibility` mitsetzen.
|
||||
- **State-Machine**: `menu | playing | levelup | gameover` + Flag `paused` (ESC). `animate()` rendert bei Pause nur UI. `showMenu()` hat einen Guard (nur aus menu/gameover/pause).
|
||||
- **Hotbar**: Aktive Skills (Kettenblitz, Teleport, Falle, Gaswolke) werden beim Erwerb auf Q, E, 1, 2, 3, 4 verteilt (`HOTBAR_KEY_CODES`). Dispatch über `triggerHotbarSkill()`. Cooldown-Labels zeigen Taste + Name (`syncSkillVisibility`).
|
||||
- **Skill-Arten**: Aktive = `Weapon`-Subklassen mit Cooldowns (UI-Balken in `index.html`, Update in `updateUI`). Passive = Stats in `Player.applySkillSystem` ODER Game-seitige Objekte (Aura/Boomerang), die in `syncSkillVisibility` erzeugt/zersstört und im Loop geupdated werden.
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
# ── Wackelpeter container build/deploy ──────────────────────────────────────
|
||||
# Uses Podman (docker-compose optional). Targets:
|
||||
# make build – Build image
|
||||
# make push – Push image to Gitea registry
|
||||
# make up/down – Start/stop via docker-compose
|
||||
# make run – Start via podman directly
|
||||
# make clean – Remove local image
|
||||
# ── Wackelpeter build/deploy (podman) ───────────────────────────────────────
|
||||
# Targets:
|
||||
# make – Build web frontend locally (npm run build)
|
||||
# make web – Same as `make`
|
||||
# make dev – Start Vite dev server
|
||||
# make build – Build container image (podman)
|
||||
# make push – Build & push image to registry (podman)
|
||||
# make up/down – Start/stop via docker-compose
|
||||
# make run – Start via podman directly
|
||||
# make clean – Remove local image
|
||||
|
||||
REGISTRY ?= gitea.korbel.network
|
||||
IMAGE ?= $(REGISTRY)/nico/wackelpeter
|
||||
TAG ?= latest
|
||||
COMPOSE ?= docker-compose
|
||||
|
||||
.PHONY: build push up down run clean login
|
||||
.PHONY: web dev build push up down run clean login
|
||||
|
||||
web:
|
||||
npm --prefix web install
|
||||
npm --prefix web run build
|
||||
|
||||
dev:
|
||||
npm --prefix web run dev
|
||||
|
||||
build:
|
||||
podman build -t $(IMAGE):$(TAG) .
|
||||
|
||||
@@ -50,14 +50,37 @@ npm run dev
|
||||
- Three.js + TypeScript + Vite
|
||||
- Steuerung: WASD bewegen, Space springen, Maus zielen,
|
||||
Linksklick Schwert, Rechtsklick Feuerball (per Skill),
|
||||
Q Kettenblitz (per Skill), E Teleport (per Skill), H Hitboxen (Debug)
|
||||
- Roguelike-Skillsystem: Kills geben XP, bei Level-Up pausiert das Spiel
|
||||
und bietet 3 Skill-Karten (neue Skills oder Upgrades). Seed (Startmenü)
|
||||
steuert die Angebote, "Nochmal spielen" behält den Seed bei.
|
||||
Q/E/1–4 Skills (Hotbar, siehe unten), ESC Pause, H Hitboxen (Debug)
|
||||
|
||||
### Skillsystem
|
||||
|
||||
Kills geben XP, bei Level-Up pausiert das Spiel und bietet 3 Skill-Karten
|
||||
(neue Skills oder Upgrades). Seed (Startmenü) steuert die Angebote,
|
||||
„Nochmal spielen" behält den Seed bei.
|
||||
|
||||
| Skill | Art | Effekt |
|
||||
|---|---|---|
|
||||
| Feuerball | Aktiv (Rechtsklick) | Explodierender Feuerball |
|
||||
| Teleport | Aktiv (Hotbar) | Teleport zum Zielpunkt |
|
||||
| Kettenblitz | Aktiv (Hotbar) | Blitz springt auf mehrere Gegner |
|
||||
| Falle | Aktiv (Hotbar) | Hält 1 Gegner fest und fügt Schaden zu |
|
||||
| Gaswolke | Aktiv (Hotbar) | Bleibt am Boden, Schaden über Zeit |
|
||||
| Aura | Passiv | Bodenkreis um den Spieler, Schaden + Wegstoß |
|
||||
| Bumerang | Passiv | Kreist um den Spieler (ab Stufe 3 mehrere) |
|
||||
| Reichweite / Schärfe | Passiv | Schwert-Range/-Schaden |
|
||||
| Regeneration / HP-Absorption | Passiv | Heilung |
|
||||
| Schild | Passiv | Absorbiert Schaden, lädt nach 8 s neu |
|
||||
| Vitalität / Dornen / Flinke Füße | Passiv | Max-HP / Kontakt-DPS / Tempo |
|
||||
|
||||
- **Hotbar**: Aktive Skills werden in Erwerbs-Reihenfolge auf Q, E, 1–4
|
||||
verteilt; Cooldown-Anzeigen zeigen Taste + Name, Level-up-Karten die
|
||||
künftige Taste.
|
||||
- **Schadens-Feedback**: Blutpartikel, roter Vignette-Flash, Blob blitzt rot
|
||||
auf, ab <30 % HP pulsierende rote Vignette.
|
||||
|
||||
## Verzeichnisse
|
||||
|
||||
- `Original/` — Original-Build (Blobtest.exe, JME3-Libs)
|
||||
- `extracted/` — Aus dem Original extrahierte Assets
|
||||
- `decompiled/` — Decompilierter Java-Source
|
||||
- `web/` — Browser-Version
|
||||
- `web/` — Browser-Version (Code-Struktur: siehe `AGENTS.md`)
|
||||
|
||||
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
|
||||
+173
-9
@@ -15,6 +15,56 @@
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; font-family: monospace; z-index: 10;
|
||||
}
|
||||
#damage-vignette {
|
||||
position: absolute; inset: 0; opacity: 0; pointer-events: none;
|
||||
background: radial-gradient(ellipse at center,
|
||||
rgba(255,0,0,0) 40%, rgba(255,0,0,0.35) 100%);
|
||||
box-shadow: inset 0 0 120px 40px rgba(255,0,0,0.5);
|
||||
}
|
||||
#damage-vignette.hurt { animation: hurt-flash 0.45s ease-out; }
|
||||
@keyframes hurt-flash {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
#low-hp-vignette {
|
||||
position: absolute; inset: 0; opacity: 0; pointer-events: none;
|
||||
background: radial-gradient(ellipse at center,
|
||||
rgba(255,0,0,0.05) 0%, rgba(255,0,0,0.15) 55%, rgba(220,0,0,0.7) 100%);
|
||||
box-shadow: inset 0 0 160px 60px rgba(220,0,0,0.55);
|
||||
}
|
||||
#low-hp-vignette.active { animation: low-hp-pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes low-hp-pulse {
|
||||
0%, 100% { opacity: 0.25; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
#ink-vignette {
|
||||
position: absolute; inset: 0; opacity: 0; pointer-events: none;
|
||||
background: radial-gradient(ellipse at center,
|
||||
rgba(120,40,180,0) 35%, rgba(120,40,180,0.4) 100%);
|
||||
box-shadow: inset 0 0 140px 50px rgba(100,30,160,0.55);
|
||||
}
|
||||
#ink-vignette.ink { animation: ink-flash 0.5s ease-out; }
|
||||
@keyframes ink-flash {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
#boss-bar {
|
||||
position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
|
||||
width: 420px; display: none; text-align: center; font-family: monospace;
|
||||
}
|
||||
#boss-bar-label {
|
||||
color: #ffcc00; font-size: 16px; text-shadow: 1px 1px 3px #000;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#boss-bar-bg {
|
||||
height: 18px; background: rgba(0,0,0,0.6);
|
||||
border: 2px solid #aa3366;
|
||||
}
|
||||
#boss-bar-fill {
|
||||
height: 100%; width: 100%;
|
||||
background: linear-gradient(#ff5577, #bb2244);
|
||||
transition: width 0.15s linear;
|
||||
}
|
||||
#health-bar-bg {
|
||||
position: absolute; top: 16px; left: 16px; width: 250px; height: 20px;
|
||||
background: rgba(0,0,0,0.6); border: 2px solid #555;
|
||||
@@ -54,6 +104,8 @@
|
||||
#sword-cooldown { bottom: 48px; }
|
||||
#teleport-cooldown { bottom: 24px; }
|
||||
#lightning-cooldown { bottom: 96px; }
|
||||
#trap-cooldown { bottom: 120px; }
|
||||
#gas-cooldown { bottom: 144px; }
|
||||
#wave-bar-container {
|
||||
position: absolute; top: 16px; right: 16px; width: 250px; height: 16px;
|
||||
}
|
||||
@@ -68,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;
|
||||
@@ -77,7 +138,8 @@
|
||||
#game-over-panel {
|
||||
position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
pointer-events: none; opacity: 0; transition: opacity 0.8s ease-in 1.5s;
|
||||
pointer-events: none; opacity: 0; visibility: hidden;
|
||||
transition: opacity 0.8s ease-in 1.5s;
|
||||
font-family: monospace;
|
||||
}
|
||||
#game-over-stats {
|
||||
@@ -113,6 +175,28 @@
|
||||
color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace;
|
||||
text-align: center;
|
||||
}
|
||||
#changelog-panel {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 14px; background: rgba(0,0,0,0.85);
|
||||
opacity: 0; pointer-events: none; visibility: hidden;
|
||||
transition: opacity 0.2s ease; font-family: monospace;
|
||||
}
|
||||
#changelog-panel h2 {
|
||||
color: #ffcc00; font-size: 34px; text-shadow: 3px 3px 6px #000;
|
||||
}
|
||||
#changelog-list {
|
||||
width: 660px; max-width: 92vw; max-height: 55vh; overflow-y: auto;
|
||||
background: rgba(20,20,30,0.9); border: 2px solid #888; padding: 14px 18px;
|
||||
color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.6;
|
||||
text-align: left;
|
||||
}
|
||||
.changelog-entry {
|
||||
display: flex; gap: 12px; padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.changelog-entry:last-child { border-bottom: none; }
|
||||
.changelog-date { color: #ffcc00; white-space: nowrap; }
|
||||
#seed-input {
|
||||
background: rgba(0,0,0,0.7); border: 2px solid #555; color: #ffcc00;
|
||||
font-family: monospace; font-size: 16px; padding: 6px 10px; width: 180px;
|
||||
@@ -158,7 +242,16 @@
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 24px; background: rgba(0,0,0,0.7); opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.3s ease; font-family: monospace;
|
||||
visibility: hidden; transition: opacity 0.3s ease; font-family: monospace;
|
||||
}
|
||||
#pause-overlay {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 18px; background: rgba(0,0,0,0.6); opacity: 0; pointer-events: none;
|
||||
visibility: hidden; transition: opacity 0.2s ease; font-family: monospace;
|
||||
}
|
||||
#pause-overlay h2 {
|
||||
color: #fff; font-size: 48px; text-shadow: 3px 3px 6px #000;
|
||||
}
|
||||
#level-up h2 {
|
||||
color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000;
|
||||
@@ -167,7 +260,7 @@
|
||||
display: flex; gap: 20px;
|
||||
}
|
||||
.offer-card {
|
||||
width: 230px; min-height: 140px; background: rgba(20,20,30,0.9);
|
||||
width: 230px; min-height: 220px; background: rgba(20,20,30,0.9);
|
||||
border: 2px solid #888; padding: 14px; cursor: pointer;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
transition: transform 0.1s ease, border-color 0.1s ease;
|
||||
@@ -177,8 +270,17 @@
|
||||
border-color: #ffcc00; background: rgba(60,50,20,0.95);
|
||||
transform: scale(1.04); box-shadow: 0 0 18px rgba(255,204,0,0.4);
|
||||
}
|
||||
.offer-icon {
|
||||
width: 76px; height: 76px; margin: 2px auto 0;
|
||||
filter: drop-shadow(0 3px 6px rgba(0,0,0,0.55));
|
||||
transition: filter 0.1s ease;
|
||||
}
|
||||
.offer-icon svg { width: 100%; height: 100%; display: block; }
|
||||
.offer-card:hover .offer-icon, .offer-card.selected .offer-icon {
|
||||
filter: drop-shadow(0 0 9px rgba(255,204,0,0.5));
|
||||
}
|
||||
.offer-title {
|
||||
color: #ffcc00; font-size: 18px; font-weight: bold;
|
||||
color: #ffcc00; font-size: 18px; font-weight: bold; text-align: center;
|
||||
}
|
||||
.offer-desc {
|
||||
color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.5;
|
||||
@@ -190,6 +292,14 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="ui-overlay">
|
||||
<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>
|
||||
</div>
|
||||
<div id="health-bar-bg"></div>
|
||||
<div id="health-bar"></div>
|
||||
<div id="health-text">100</div>
|
||||
@@ -226,6 +336,16 @@
|
||||
<div class="cooldown-fill"></div>
|
||||
<div class="cooldown-label">Chain Lightning</div>
|
||||
</div>
|
||||
<div id="trap-cooldown" class="cooldown-container" style="display: none">
|
||||
<div class="cooldown-bg"></div>
|
||||
<div class="cooldown-fill"></div>
|
||||
<div class="cooldown-label">Falle</div>
|
||||
</div>
|
||||
<div id="gas-cooldown" class="cooldown-container" style="display: none">
|
||||
<div class="cooldown-bg"></div>
|
||||
<div class="cooldown-fill"></div>
|
||||
<div class="cooldown-label">Gaswolke</div>
|
||||
</div>
|
||||
|
||||
<div id="wave-bar-container">
|
||||
<div id="wave-bar-bg"></div>
|
||||
@@ -241,19 +361,21 @@
|
||||
</div>
|
||||
|
||||
<div id="level-up">
|
||||
<h2>Level Up!</h2>
|
||||
<div id="offer-row">
|
||||
<h2>Level Up!</h2> <div id="offer-row">
|
||||
<div id="offer-0" class="offer-card">
|
||||
<div id="offer-icon-0" class="offer-icon"></div>
|
||||
<div id="offer-title-0" class="offer-title"></div>
|
||||
<div id="offer-desc-0" class="offer-desc"></div>
|
||||
<div class="offer-hint">[1] auswählen</div>
|
||||
</div>
|
||||
<div id="offer-1" class="offer-card">
|
||||
<div id="offer-icon-1" class="offer-icon"></div>
|
||||
<div id="offer-title-1" class="offer-title"></div>
|
||||
<div id="offer-desc-1" class="offer-desc"></div>
|
||||
<div class="offer-hint">[2] auswählen</div>
|
||||
</div>
|
||||
<div id="offer-2" class="offer-card">
|
||||
<div id="offer-icon-2" class="offer-icon"></div>
|
||||
<div id="offer-title-2" class="offer-title"></div>
|
||||
<div id="offer-desc-2" class="offer-desc"></div>
|
||||
<div class="offer-hint">[3] auswählen</div>
|
||||
@@ -262,19 +384,61 @@
|
||||
<button id="btn-confirm" class="menu-button" disabled>Bestätigen (Enter)</button>
|
||||
</div>
|
||||
|
||||
<div id="pause-overlay">
|
||||
<h2>Pausiert</h2>
|
||||
<button id="btn-resume" class="menu-button">Weiter (ESC)</button>
|
||||
<button id="btn-pause-menu" class="menu-button">Hauptmenü</button>
|
||||
</div>
|
||||
|
||||
<div id="main-menu">
|
||||
<h1>WACKELPETER</h1>
|
||||
<div class="subtitle">Ein Blob kämpft ums Überleben</div>
|
||||
<div class="menu-controls">
|
||||
WASD: Bewegen · Space: Springen · Maus: Zielen<br>
|
||||
Linksklick: Schwert · Rechtsklick: Feuerball (per Skill) · Q: Kettenblitz (per Skill) · E: Teleport (per Skill)
|
||||
WASD: Bewegen · Space: Springen · Maus: Zielen · ESC: Pause<br>
|
||||
Linksklick: Schwert · Rechtsklick: Feuerball (per Skill)<br>
|
||||
Q, E, 1–4: Skills (werden nach Erwerb belegt)
|
||||
</div>
|
||||
<input id="seed-input" type="text" placeholder="Seed (leer = zufällig)" />
|
||||
<div id="boss-mode" style="opacity:0; pointer-events:none; visibility:hidden; display:flex; flex-direction:column; align-items:center; gap:10px;">
|
||||
<div class="subtitle">BOSS-MODUS</div>
|
||||
<div id="boss-mode-buttons" style="display:flex; flex-direction:column; gap:8px; align-items:center;"></div>
|
||||
</div>
|
||||
<button id="btn-start" class="menu-button">Start</button>
|
||||
<button id="btn-changelog" class="menu-button">Changelog</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Golem-Boss in Welle 10, startet erst wenn die Arena geräumt ist</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Bumerang als dünne 120-Grad-Sichel neu designt</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Spieler-folgendes Licht, Dungeon-Umgebung abgedunkelt</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Aura-, Bumerang-, Fallen- und Gaswolken-Skills mit Hotbar und ESC-Pause</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Gegner-Blickrichtung korrigiert und Schadens-Feedback beim Spieler</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">18.08.</span><span>Rückenschild-Modell am Schild-Skill gekoppelt</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">18.08.</span><span>Skill-Icons auf den Level-up-Karten</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">17.08.</span><span>Skill-System, Level-Ups und neue Gegnertypen</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">17.08.</span><span>Hauptmenü, Geist-Gegner und Schwert-Schwung-Spur</span></div>
|
||||
<div class="changelog-entry"><span class="changelog-date">17.08.</span><span>AGENTS.md mit Git-Regeln</span></div>
|
||||
</div>
|
||||
<button id="btn-changelog-close" class="menu-button">Schließen</button>
|
||||
</div>
|
||||
|
||||
<div id="instructions">
|
||||
WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q: Lightning · E: Teleport
|
||||
WASD: Move · Space: Jump · Mouse: Aim · Left Click: Sword · Right Click: Fireball · Q/E/1-4: Skills · ESC: Pause
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 19 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import { auraRadius, auraDamage, auraPush } from './Skills';
|
||||
|
||||
const TICK = 0.5;
|
||||
|
||||
export class Aura {
|
||||
private group: THREE.Group;
|
||||
private fill: THREE.Mesh;
|
||||
private ring: THREE.Mesh;
|
||||
private tickTimer = 0;
|
||||
private pulse = 0;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
this.group = new THREE.Group();
|
||||
|
||||
this.fill = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(1, 48),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x66ccff,
|
||||
transparent: true,
|
||||
opacity: 0.12,
|
||||
depthWrite: false,
|
||||
})
|
||||
);
|
||||
this.fill.rotation.x = -Math.PI / 2;
|
||||
this.fill.position.y = 0.05;
|
||||
this.fill.renderOrder = 998;
|
||||
this.group.add(this.fill);
|
||||
|
||||
this.ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.93, 1, 48),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x88ddff,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
})
|
||||
);
|
||||
this.ring.rotation.x = -Math.PI / 2;
|
||||
this.ring.position.y = 0.06;
|
||||
this.ring.renderOrder = 999;
|
||||
this.group.add(this.ring);
|
||||
|
||||
scene.add(this.group);
|
||||
}
|
||||
|
||||
update(dt: number, game: Game) {
|
||||
const level = game.skillSystem.getLevel('aura');
|
||||
if (level <= 0) return;
|
||||
|
||||
const radius = auraRadius(level);
|
||||
this.group.position.x = game.player.body.position.x;
|
||||
this.group.position.z = game.player.body.position.z;
|
||||
this.group.scale.setScalar(radius);
|
||||
|
||||
if (this.pulse > 0) {
|
||||
this.pulse -= dt * 2.5;
|
||||
const p = Math.max(0, this.pulse);
|
||||
const s = 1 + p * 0.12;
|
||||
this.fill.scale.setScalar(s);
|
||||
this.ring.scale.setScalar(s);
|
||||
(this.ring.material as THREE.MeshBasicMaterial).opacity = 0.5 + p * 0.4;
|
||||
}
|
||||
|
||||
this.tickTimer -= dt;
|
||||
if (this.tickTimer > 0) return;
|
||||
this.tickTimer = TICK;
|
||||
|
||||
const damage = auraDamage(level);
|
||||
const push = auraPush(level);
|
||||
const center = game.player.body.position;
|
||||
for (const enemy of game.enemies) {
|
||||
if (enemy.dead) continue;
|
||||
const dist = enemy.body.position.distanceTo(center);
|
||||
if (dist > radius + 0.4) continue;
|
||||
enemy.takeDamage(damage, game, false, center);
|
||||
// Stationaere Gegner (Bosse, Tuerme) werden nur geschaedigt, nicht geschoben
|
||||
if (enemy.immovable) continue;
|
||||
const dir = new THREE.Vector3()
|
||||
.subVectors(enemy.body.position, center);
|
||||
dir.y = 0;
|
||||
if (dir.lengthSq() < 0.01) {
|
||||
dir.set(Math.random() - 0.5, 0, Math.random() - 0.5);
|
||||
}
|
||||
dir.normalize();
|
||||
enemy.body.position.addScaledVector(dir, push);
|
||||
game.clampToArena(enemy.body.position);
|
||||
}
|
||||
this.pulse = 1;
|
||||
}
|
||||
|
||||
dispose(scene: THREE.Scene) {
|
||||
scene.remove(this.group);
|
||||
this.fill.geometry.dispose();
|
||||
(this.fill.material as THREE.Material).dispose();
|
||||
this.ring.geometry.dispose();
|
||||
(this.ring.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -28,15 +28,15 @@ export class Bat extends Enemy {
|
||||
this.registerFadeMesh(bodyMesh);
|
||||
this.model.add(bodyMesh);
|
||||
|
||||
// Head with ears (face toward the player: -Z)
|
||||
// Head with ears (face toward the player: +Z)
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 10), furMat);
|
||||
head.position.set(0, 0.15, -0.3);
|
||||
head.position.set(0, 0.15, 0.3);
|
||||
this.registerFadeMesh(head);
|
||||
this.model.add(head);
|
||||
|
||||
for (const side of [-1, 1]) {
|
||||
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.18, 6), furMat);
|
||||
ear.position.set(side * 0.12, 0.32, -0.25);
|
||||
ear.position.set(side * 0.12, 0.32, 0.25);
|
||||
this.registerFadeMesh(ear);
|
||||
this.model.add(ear);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export class Bat extends Enemy {
|
||||
const eyeMat = new THREE.MeshToonMaterial({ color: 0xff2222 });
|
||||
for (const side of [-1, 1]) {
|
||||
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 8), eyeMat);
|
||||
eye.position.set(side * 0.07, 0.16, -0.42);
|
||||
eye.position.set(side * 0.07, 0.16, 0.42);
|
||||
this.registerFadeMesh(eye);
|
||||
this.model.add(eye);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import type { Enemy } from './Enemy';
|
||||
import { boomerangDamage, boomerangCount } from './Skills';
|
||||
|
||||
const ORBIT_RADIUS = 2.8;
|
||||
const ORBIT_HEIGHT = 0.9;
|
||||
const ORBIT_SPEED = 2.4;
|
||||
const SPIN_SPEED = 14;
|
||||
const HIT_CD = 0.6;
|
||||
const HIT_RADIUS = 0.9;
|
||||
|
||||
export class Boomerang {
|
||||
private meshes: THREE.Mesh[] = [];
|
||||
private angle = 0;
|
||||
private hitTimers = new Map<Enemy, number>();
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
const shape = new THREE.Shape();
|
||||
// Dünner Sichel-Bumerang: Bogen (~120° = 1/3 Kreis) mit schmaler Bahn
|
||||
const outer = 0.75;
|
||||
const inner = 0.47;
|
||||
const half = (60 * Math.PI) / 180;
|
||||
const capR = (outer - inner) / 2;
|
||||
const mid = (outer + inner) / 2;
|
||||
|
||||
shape.moveTo(outer * Math.cos(-half), outer * Math.sin(-half));
|
||||
shape.absarc(0, 0, outer, -half, half, false);
|
||||
shape.absarc(
|
||||
mid * Math.cos(half), mid * Math.sin(half),
|
||||
capR, half, half + Math.PI, false
|
||||
);
|
||||
shape.absarc(0, 0, inner, half, -half, true);
|
||||
shape.absarc(
|
||||
mid * Math.cos(-half), mid * Math.sin(-half),
|
||||
capR, -half + Math.PI, -half, false
|
||||
);
|
||||
shape.closePath();
|
||||
const geom = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: 0.06,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.02,
|
||||
bevelSize: 0.02,
|
||||
bevelSegments: 2,
|
||||
});
|
||||
geom.center();
|
||||
const mat = new THREE.MeshToonMaterial({ color: 0xc98a3d });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const mesh = new THREE.Mesh(geom, mat);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.castShadow = true;
|
||||
mesh.visible = false;
|
||||
scene.add(mesh);
|
||||
this.meshes.push(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
setLevel(level: number) {
|
||||
const count = level > 0 ? boomerangCount(level) : 0;
|
||||
for (let i = 0; i < this.meshes.length; i++) {
|
||||
this.meshes[i].visible = i < count;
|
||||
}
|
||||
}
|
||||
|
||||
update(dt: number, game: Game) {
|
||||
const level = game.skillSystem.getLevel('boomerang');
|
||||
if (level <= 0) return;
|
||||
const count = boomerangCount(level);
|
||||
const damage = boomerangDamage(level);
|
||||
|
||||
this.angle += ORBIT_SPEED * dt;
|
||||
const center = game.player.body.position;
|
||||
|
||||
for (const [enemy, t] of this.hitTimers) {
|
||||
this.hitTimers.set(enemy, t - dt);
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mesh = this.meshes[i];
|
||||
const a = this.angle + (i / count) * Math.PI * 2;
|
||||
mesh.position.set(
|
||||
center.x + Math.cos(a) * ORBIT_RADIUS,
|
||||
ORBIT_HEIGHT + Math.sin(this.angle * 2 + i) * 0.15,
|
||||
center.z + Math.sin(a) * ORBIT_RADIUS
|
||||
);
|
||||
mesh.rotation.z += SPIN_SPEED * dt;
|
||||
|
||||
for (const enemy of game.enemies) {
|
||||
if (enemy.dead) continue;
|
||||
const timer = this.hitTimers.get(enemy) ?? 0;
|
||||
if (timer > 0) continue;
|
||||
const dist = enemy.body.position.distanceTo(mesh.position);
|
||||
if (dist < HIT_RADIUS + 0.5) {
|
||||
this.hitTimers.set(enemy, HIT_CD);
|
||||
enemy.takeDamage(damage, game, true, mesh.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(scene: THREE.Scene) {
|
||||
for (const mesh of this.meshes) {
|
||||
scene.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
(mesh.material as THREE.Material).dispose();
|
||||
}
|
||||
this.meshes = [];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import { Brute } from './Brute';
|
||||
|
||||
export class Boss extends Brute {
|
||||
constructor() {
|
||||
super();
|
||||
this.health = 800;
|
||||
this.speed = 3;
|
||||
this.xp = 80;
|
||||
this.contactDps = 50;
|
||||
this.contactRadius = 3.4;
|
||||
this.guaranteedDrop = true;
|
||||
|
||||
this.model.scale.setScalar(2.1);
|
||||
|
||||
// Crown
|
||||
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.castShadow = true;
|
||||
this.registerFadeMesh(crown);
|
||||
this.model.add(crown);
|
||||
}
|
||||
|
||||
protected onDeath(_game: Game) {
|
||||
this.beginFadeDeath();
|
||||
}
|
||||
}
|
||||
+42
-3
@@ -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;
|
||||
@@ -53,14 +62,34 @@ export class Brute extends Enemy {
|
||||
this.registerFadeMesh(head);
|
||||
this.model.add(head);
|
||||
|
||||
// Glowing eyes (face toward the player: -Z)
|
||||
// Glowing eyes (face toward the player: +Z)
|
||||
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 });
|
||||
for (const side of [-1, 1]) {
|
||||
const eye = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.1, 0.05), eyeMat);
|
||||
eye.position.set(side * 0.16, 1.9, -0.32);
|
||||
eye.position.set(side * 0.16, 1.9, 0.32);
|
||||
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);
|
||||
|
||||
+6
-6
@@ -40,16 +40,16 @@ export class Crab extends Enemy {
|
||||
this.model.add(spot);
|
||||
}
|
||||
|
||||
// Eye stalks (on the armored front side, -Z)
|
||||
// Eye stalks (on the armored front side, +Z)
|
||||
const stalkMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
|
||||
const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
|
||||
for (const side of [-1, 1]) {
|
||||
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.22, 6), stalkMat);
|
||||
stalk.position.set(side * 0.22, 0.55, -0.42);
|
||||
stalk.position.set(side * 0.22, 0.55, 0.42);
|
||||
this.registerFadeMesh(stalk);
|
||||
this.model.add(stalk);
|
||||
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat);
|
||||
eye.position.set(side * 0.22, 0.68, -0.42);
|
||||
eye.position.set(side * 0.22, 0.68, 0.42);
|
||||
this.registerFadeMesh(eye);
|
||||
this.model.add(eye);
|
||||
}
|
||||
@@ -70,8 +70,8 @@ export class Crab extends Enemy {
|
||||
const clawGeom = new THREE.SphereGeometry(0.22, 12, 10);
|
||||
this.clawL = new THREE.Mesh(clawGeom, shellMat);
|
||||
this.clawR = new THREE.Mesh(clawGeom, shellMat);
|
||||
this.clawL.position.set(-0.45, 0.45, -0.5);
|
||||
this.clawR.position.set(0.45, 0.45, -0.5);
|
||||
this.clawL.position.set(-0.45, 0.45, 0.5);
|
||||
this.clawR.position.set(0.45, 0.45, 0.5);
|
||||
this.clawL.scale.set(1.2, 0.7, 1.4);
|
||||
this.clawR.scale.set(1.2, 0.7, 1.4);
|
||||
this.clawL.castShadow = true;
|
||||
@@ -144,7 +144,7 @@ export class Crab extends Enemy {
|
||||
) {
|
||||
// Armored from the front: reduce damage coming from the facing side
|
||||
if (sourcePos) {
|
||||
const front = new THREE.Vector3(0, 0, -1)
|
||||
const front = new THREE.Vector3(0, 0, 1)
|
||||
.applyQuaternion(this.body.quaternion);
|
||||
front.y = 0;
|
||||
front.normalize();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
const LIFE = 0.8;
|
||||
|
||||
export class DamageNumber {
|
||||
sprite: THREE.Sprite;
|
||||
private mat: THREE.SpriteMaterial;
|
||||
private life = LIFE;
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
text: string,
|
||||
pos: THREE.Vector3,
|
||||
color = '#ffffff'
|
||||
) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.font = 'bold 40px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.strokeText(text, 64, 32);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, 64, 32);
|
||||
|
||||
this.mat = new THREE.SpriteMaterial({
|
||||
map: new THREE.CanvasTexture(canvas),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.sprite = new THREE.Sprite(this.mat);
|
||||
this.sprite.position.copy(pos);
|
||||
this.sprite.position.y += 2.6;
|
||||
this.sprite.scale.set(2, 1, 1);
|
||||
scene.add(this.sprite);
|
||||
}
|
||||
|
||||
update(dt: number): boolean {
|
||||
this.life -= dt;
|
||||
this.sprite.position.y += dt * 1.6;
|
||||
this.mat.opacity = this.life < 0.3 ? this.life / 0.3 : 1;
|
||||
return this.life > 0;
|
||||
}
|
||||
|
||||
dispose(scene: THREE.Scene) {
|
||||
scene.remove(this.sprite);
|
||||
this.mat.map?.dispose();
|
||||
this.mat.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -7,12 +7,18 @@ export abstract class Enemy {
|
||||
dead = false;
|
||||
deathTimer = 0;
|
||||
knockback = 0;
|
||||
rootTime = 0;
|
||||
health = 100;
|
||||
maxHealth = 100;
|
||||
isBoss = false;
|
||||
displayName = '';
|
||||
speed = 6;
|
||||
xp = 10;
|
||||
contactDps = 25;
|
||||
contactRadius = 2;
|
||||
guaranteedDrop = false;
|
||||
spawnAtCenter = false;
|
||||
immovable = false;
|
||||
grace = 0;
|
||||
mixer: THREE.AnimationMixer | null = null;
|
||||
protected clips: Map<string, THREE.AnimationAction> = new Map();
|
||||
@@ -91,7 +97,7 @@ export abstract class Enemy {
|
||||
}
|
||||
|
||||
playSound('damage', 0.7);
|
||||
game.onDamageDealt(damage);
|
||||
game.onDamageDealt(damage, this);
|
||||
|
||||
if (this.health <= 0) {
|
||||
this.die(game);
|
||||
|
||||
@@ -51,7 +51,7 @@ export class EnemyProjectile {
|
||||
if (game.player.health > 0) {
|
||||
const dist = this.body.position.distanceTo(game.player.body.position);
|
||||
if (dist < HIT_RADIUS) {
|
||||
game.player.damage(this.damage);
|
||||
game.player.damage(this.damage, this.body.position);
|
||||
this.dead = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
|
||||
const TELEGRAPH_TIME = 0.9;
|
||||
const IMPACT_RADIUS = 1.8;
|
||||
const ROCK_DAMAGE = 30;
|
||||
const DUST_TIME = 0.4;
|
||||
|
||||
export class FallingRock {
|
||||
body: THREE.Group;
|
||||
private shadow: THREE.Mesh;
|
||||
private rock: THREE.Mesh;
|
||||
private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph';
|
||||
private timer = TELEGRAPH_TIME;
|
||||
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);
|
||||
|
||||
// Schatten-Telegraf (waechst kurz vor dem Aufprall)
|
||||
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);
|
||||
|
||||
// Fels von der Decke
|
||||
const rockMat = new THREE.MeshToonMaterial({ color: 0x777788 });
|
||||
this.rock = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.9, 0.9), rockMat);
|
||||
this.rock.position.y = 16;
|
||||
this.rock.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
|
||||
this.body.add(this.rock);
|
||||
|
||||
this.dustMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x888899,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
update(dt: number, game: Game): boolean {
|
||||
this.timer -= dt;
|
||||
|
||||
if (this.phase === 'telegraph') {
|
||||
const t = 1 - this.timer / TELEGRAPH_TIME;
|
||||
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.rock.position.y -= 26 * dt;
|
||||
this.rock.rotation.x += dt * 3;
|
||||
this.rock.rotation.z += dt * 2;
|
||||
if (this.rock.position.y <= 0.15) {
|
||||
this.rock.position.y = 0.15;
|
||||
this.rock.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) < IMPACT_RADIUS) {
|
||||
game.player.damage(ROCK_DAMAGE, this.body.position);
|
||||
}
|
||||
}
|
||||
|
||||
// Staubwolke
|
||||
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 = DUST_TIME;
|
||||
}
|
||||
} else {
|
||||
const t = 1 - this.timer / DUST_TIME;
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -52,18 +52,18 @@ export class Frog extends Enemy {
|
||||
// Belly
|
||||
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat);
|
||||
belly.scale.set(0.85, 0.5, 0.85);
|
||||
belly.position.set(0, 0.3, -0.42);
|
||||
belly.position.set(0, 0.3, 0.42);
|
||||
this.registerFadeMesh(belly);
|
||||
this.model.add(belly);
|
||||
|
||||
// Bulging eyes (face toward the player: -Z)
|
||||
// Bulging eyes (face toward the player: +Z)
|
||||
const eyeGeom = new THREE.SphereGeometry(0.17, 14, 12);
|
||||
const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8);
|
||||
const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 });
|
||||
this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat);
|
||||
this.eyeR = new THREE.Mesh(eyeGeom, this.whiteMat);
|
||||
this.eyeL.position.set(-0.24, 0.72, -0.28);
|
||||
this.eyeR.position.set(0.24, 0.72, -0.28);
|
||||
this.eyeL.position.set(-0.24, 0.72, 0.28);
|
||||
this.eyeR.position.set(0.24, 0.72, 0.28);
|
||||
this.eyeL.castShadow = true;
|
||||
this.eyeR.castShadow = true;
|
||||
this.registerFadeMesh(this.eyeL);
|
||||
@@ -72,7 +72,7 @@ export class Frog extends Enemy {
|
||||
|
||||
for (const eye of [this.eyeL, this.eyeR]) {
|
||||
const pupil = new THREE.Mesh(pupilGeom, pupilMat);
|
||||
pupil.position.set(0, 0.02, -0.12);
|
||||
pupil.position.set(0, 0.02, 0.12);
|
||||
this.registerFadeMesh(pupil);
|
||||
eye.add(pupil);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export class Frog extends Enemy {
|
||||
for (const side of [-1, 1]) {
|
||||
const thigh = new THREE.Mesh(legGeom, greenMat);
|
||||
thigh.scale.set(0.8, 1, 1.2);
|
||||
thigh.position.set(side * 0.42, 0.25, 0.35);
|
||||
thigh.position.set(side * 0.42, 0.25, -0.35);
|
||||
thigh.rotation.y = side * 0.5;
|
||||
thigh.castShadow = true;
|
||||
this.registerFadeMesh(thigh);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+970
-80
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import { gasDamage, gasRadius, gasDuration } from './Skills';
|
||||
|
||||
const TICK = 0.5;
|
||||
|
||||
export class GasCloud {
|
||||
body: THREE.Group;
|
||||
private level: number;
|
||||
private life: number;
|
||||
private duration: number;
|
||||
private radius: number;
|
||||
private tickTimer = 0;
|
||||
private puffs: THREE.Mesh[] = [];
|
||||
|
||||
constructor(position: THREE.Vector3, level: number) {
|
||||
this.level = level;
|
||||
this.radius = gasRadius(level);
|
||||
this.duration = gasDuration(level);
|
||||
this.life = this.duration;
|
||||
this.body = new THREE.Group();
|
||||
this.body.position.copy(position);
|
||||
this.body.position.y = 0;
|
||||
|
||||
const puffGeom = new THREE.SphereGeometry(1, 16, 12);
|
||||
const puffMat = new THREE.MeshToonMaterial({
|
||||
color: 0x66bb44,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const puff = new THREE.Mesh(puffGeom, puffMat);
|
||||
const a = (i / 5) * Math.PI * 2;
|
||||
puff.position.set(
|
||||
Math.cos(a) * this.radius * 0.35,
|
||||
0.6 + Math.random() * 0.5,
|
||||
Math.sin(a) * this.radius * 0.35
|
||||
);
|
||||
puff.scale.setScalar(this.radius * (0.3 + Math.random() * 0.2));
|
||||
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.3 * fade;
|
||||
puff.rotation.y += dt * 0.2;
|
||||
}
|
||||
|
||||
this.tickTimer -= dt;
|
||||
if (this.tickTimer <= 0) {
|
||||
this.tickTimer = TICK;
|
||||
const damage = gasDamage(this.level) * TICK;
|
||||
for (const enemy of game.enemies) {
|
||||
if (enemy.dead) continue;
|
||||
const dist = enemy.body.position.distanceTo(this.body.position);
|
||||
if (dist < this.radius) {
|
||||
enemy.takeDamage(damage, game, false, this.body.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.body.removeFromParent();
|
||||
for (const puff of this.puffs) {
|
||||
puff.geometry.dispose();
|
||||
(puff.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import { Brute } from './Brute';
|
||||
|
||||
export class Golem extends Brute {
|
||||
constructor() {
|
||||
super();
|
||||
this.health = 800;
|
||||
this.maxHealth = 800;
|
||||
this.isBoss = true;
|
||||
this.displayName = 'Golem';
|
||||
this.speed = 3;
|
||||
this.xp = 80;
|
||||
this.contactDps = 50;
|
||||
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);
|
||||
|
||||
// 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.55;
|
||||
crown.castShadow = true;
|
||||
this.registerFadeMesh(crown);
|
||||
this.model.add(crown);
|
||||
}
|
||||
|
||||
protected onDeath(_game: Game) {
|
||||
this.beginFadeDeath();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
|
||||
const SPEED = 8;
|
||||
const PLAYER_PROXIMITY = 1.4;
|
||||
|
||||
export class InkBlob {
|
||||
body: THREE.Group;
|
||||
target: THREE.Vector3;
|
||||
private vel: THREE.Vector3;
|
||||
private life = 6;
|
||||
dead = false;
|
||||
nearPlayer = 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(SPEED);
|
||||
}
|
||||
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xaa44ff,
|
||||
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: 0x3a1060 });
|
||||
const core = new THREE.Mesh(new THREE.SphereGeometry(0.28, 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.2 + Math.sin(this.life * 4) * 0.1;
|
||||
|
||||
if (game.player.health > 0 && !this.nearPlayer) {
|
||||
const dist = this.body.position.distanceTo(game.player.body.position);
|
||||
if (dist < PLAYER_PROXIMITY) {
|
||||
this.nearPlayer = true;
|
||||
game.triggerInkVignette();
|
||||
}
|
||||
}
|
||||
|
||||
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,78 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
|
||||
const TICK = 0.5;
|
||||
const TICK_DAMAGE = 4;
|
||||
const SLOW_REFRESH = 0.15;
|
||||
|
||||
export class InkPuddle {
|
||||
body: THREE.Group;
|
||||
private radius: number;
|
||||
private life: number;
|
||||
private tickTimer = 0;
|
||||
private puffs: THREE.Mesh[] = [];
|
||||
|
||||
constructor(position: THREE.Vector3, radius = 3.5, duration = 6) {
|
||||
this.radius = radius;
|
||||
this.life = duration;
|
||||
this.body = new THREE.Group();
|
||||
this.body.position.set(position.x, 0.1, position.z);
|
||||
|
||||
const puffGeom = new THREE.SphereGeometry(1, 12, 10);
|
||||
const puffMat = new THREE.MeshToonMaterial({
|
||||
color: 0x552277,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
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) * this.radius * 0.4,
|
||||
0.3 + Math.random() * 0.3,
|
||||
Math.sin(a) * this.radius * 0.4
|
||||
);
|
||||
puff.scale.setScalar(this.radius * (0.22 + 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.35 * 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 < this.radius) {
|
||||
game.player.slowTimer = SLOW_REFRESH;
|
||||
this.tickTimer -= dt;
|
||||
if (this.tickTimer <= 0) {
|
||||
this.tickTimer = TICK;
|
||||
game.player.damage(TICK_DAMAGE, this.body.position);
|
||||
game.triggerInkVignette();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.body.removeFromParent();
|
||||
for (const puff of this.puffs) {
|
||||
puff.geometry.dispose();
|
||||
(puff.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import { Enemy } from './Enemy';
|
||||
import { playSound } from './SoundManager';
|
||||
import { InkBlob } from './InkBlob';
|
||||
import { InkPuddle } from './InkPuddle';
|
||||
|
||||
const MAX_HEALTH = 3200;
|
||||
const INK_COOLDOWN = 4.5;
|
||||
const PULL_COOLDOWN = 7.5;
|
||||
const PULL_MARK_COUNT = 5;
|
||||
const PULL_MARK_RADIUS = 2.2;
|
||||
const PULL_MARK_DIST = 6.5;
|
||||
const PULL_CHARGE_TIME = 2.5;
|
||||
const PULL_DAMAGE = 25;
|
||||
const PULL_TIME = 0.6;
|
||||
const SLAM_TIME = 0.5;
|
||||
const HIT_FLASH_TIME = 0.14;
|
||||
const SWEEP_RADIUS = 11;
|
||||
const SWEEP_DURATION = 3.6;
|
||||
const SWEEP_HIT_WIDTH = 1.2;
|
||||
const SWEEP_DAMAGE = 25;
|
||||
const ENRAGE_RATIO = 0.3;
|
||||
const INK_RADIUS = 3.5;
|
||||
const INK_DURATION = 6;
|
||||
|
||||
export class Krake extends Enemy {
|
||||
spawnAtCenter = true;
|
||||
private model: THREE.Group;
|
||||
private game: Game | null = null;
|
||||
private waveTime = 0;
|
||||
private tentacles: THREE.Group[] = [];
|
||||
private inkTimer = 2;
|
||||
private pullTimer = 3.5;
|
||||
private sweepTimer = 5;
|
||||
private enraged = false;
|
||||
private effectsReady = false;
|
||||
|
||||
private slamState: 'none' | 'grab' | 'slam' = 'none';
|
||||
private slamChain!: THREE.Group;
|
||||
private slamTime = 0;
|
||||
private slamRetractFrom = 1;
|
||||
private pullState: 'idle' | 'charge' = 'idle';
|
||||
private pullChargeTime = 0;
|
||||
private markOffsetAngle = 0;
|
||||
private pullMarks: {
|
||||
group: THREE.Group;
|
||||
ringMat: THREE.MeshBasicMaterial;
|
||||
discMat: THREE.MeshBasicMaterial;
|
||||
}[] = [];
|
||||
|
||||
private sweepTents: { group: THREE.Group; angle: number; dir: number; progress: number }[] = [];
|
||||
private sweepActive = false;
|
||||
private sweepHit = false;
|
||||
|
||||
private inkBlobs: InkBlob[] = [];
|
||||
private puddles: InkPuddle[] = [];
|
||||
private skinMat!: THREE.MeshToonMaterial;
|
||||
private darkMat!: THREE.MeshToonMaterial;
|
||||
private hitFlash = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.health = MAX_HEALTH;
|
||||
this.maxHealth = MAX_HEALTH;
|
||||
this.isBoss = true;
|
||||
this.displayName = 'Krake';
|
||||
this.immovable = true;
|
||||
this.speed = 0;
|
||||
this.xp = 150;
|
||||
this.contactDps = 0;
|
||||
this.contactRadius = 0;
|
||||
this.guaranteedDrop = true;
|
||||
|
||||
this.model = new THREE.Group();
|
||||
this.fadeOutModel = this.model;
|
||||
this.body.add(this.model);
|
||||
|
||||
const skinMat = new THREE.MeshToonMaterial({ color: 0x8a4a9a });
|
||||
const darkMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a });
|
||||
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffee44 });
|
||||
this.skinMat = skinMat;
|
||||
this.darkMat = darkMat;
|
||||
|
||||
// Kopf
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(2.2, 24, 18), skinMat);
|
||||
head.scale.set(1, 0.85, 1);
|
||||
head.position.y = 3.1;
|
||||
head.castShadow = true;
|
||||
this.registerFadeMesh(head);
|
||||
this.model.add(head);
|
||||
|
||||
// Augen auf +Z (Blickrichtung)
|
||||
for (const side of [-1, 1]) {
|
||||
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), eyeMat);
|
||||
eye.position.set(side * 0.8, 3.45, 1.9);
|
||||
this.registerFadeMesh(eye);
|
||||
this.model.add(eye);
|
||||
}
|
||||
|
||||
// Tentakel um den Kopf herum
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const chain = new THREE.Group();
|
||||
const a = (i / 8) * Math.PI * 2;
|
||||
for (let j = 0; j < 7; j++) {
|
||||
const seg = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.42 - j * 0.04, 12, 10),
|
||||
j % 2 === 0 ? skinMat : darkMat
|
||||
);
|
||||
seg.position.set(j * 0.55, -j * 0.28, 0);
|
||||
this.registerFadeMesh(seg);
|
||||
chain.add(seg);
|
||||
}
|
||||
chain.position.set(Math.cos(a) * 1.7, 1.1, Math.sin(a) * 1.7);
|
||||
chain.rotation.y = -a;
|
||||
this.model.add(chain);
|
||||
this.tentacles.push(chain);
|
||||
}
|
||||
|
||||
// Weltraum-Effekte (Pull-Marken, Slam-Tentakel, Sweep-Tentakel)
|
||||
this.buildEffects();
|
||||
}
|
||||
|
||||
private buildEffects() {
|
||||
// 5 rote Boden-Marken (Arme) rund um den Oktopus
|
||||
for (let i = 0; i < PULL_MARK_COUNT; i++) {
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff3333,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const discMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff2222,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
});
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(PULL_MARK_RADIUS - 0.15, PULL_MARK_RADIUS + 0.15, 32),
|
||||
ringMat
|
||||
);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = 0.05;
|
||||
const disc = new THREE.Mesh(new THREE.CircleGeometry(PULL_MARK_RADIUS, 32), discMat);
|
||||
disc.rotation.x = -Math.PI / 2;
|
||||
disc.position.y = 0.03;
|
||||
const group = new THREE.Group();
|
||||
group.add(ring, disc);
|
||||
group.visible = false;
|
||||
this.pullMarks.push({ group, ringMat, discMat });
|
||||
}
|
||||
|
||||
this.slamChain = new THREE.Group();
|
||||
for (let j = 1; j <= 10; j++) {
|
||||
const seg = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.3, 8, 6),
|
||||
new THREE.MeshToonMaterial({ color: 0x7a3a8a })
|
||||
);
|
||||
seg.position.set(0, 0, j * 0.5);
|
||||
this.slamChain.add(seg);
|
||||
}
|
||||
this.slamChain.visible = false;
|
||||
|
||||
// Kreisende Sweep-Tentakel (dicker roter Arm mit Saugnaepfen, ueber den man springen kann)
|
||||
// Zwei Exemplare: Phase 2 nutzt beide in entgegengesetzte Richtungen
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const group = this.buildSweepTentacle();
|
||||
group.visible = false;
|
||||
this.sweepTents.push({ group, angle: 0, dir: i === 0 ? 1 : -1, progress: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
private buildSweepTentacle(): THREE.Group {
|
||||
const sweepMat = new THREE.MeshToonMaterial({ color: 0xdd4455 });
|
||||
const cupMat = new THREE.MeshToonMaterial({ color: 0xffb3a0 });
|
||||
const cupGeom = new THREE.SphereGeometry(1, 10, 6);
|
||||
const group = new THREE.Group();
|
||||
for (let j = 1; j <= 18; j++) {
|
||||
const r = Math.max(0.22, 0.42 - j * 0.012);
|
||||
const seg = new THREE.Mesh(new THREE.SphereGeometry(r, 12, 10), sweepMat);
|
||||
seg.position.set(j * 0.6, 0.3, 0);
|
||||
group.add(seg);
|
||||
|
||||
// Saugnapf: flache Scheibe, abwechselnd links/rechts
|
||||
const side = j % 2 === 0 ? 1 : -1;
|
||||
const cup = new THREE.Mesh(cupGeom, cupMat);
|
||||
cup.scale.set(r * 0.5, r * 0.18, r * 0.5);
|
||||
cup.position.set(j * 0.6, 0.3, side * (r + 0.02));
|
||||
group.add(cup);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
playAnim() {
|
||||
// Prozedurale Optik
|
||||
}
|
||||
|
||||
updateAnimation(dt: number) {
|
||||
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
|
||||
}
|
||||
|
||||
private ensureEffects(scene: THREE.Scene) {
|
||||
if (this.effectsReady) return;
|
||||
for (const mark of this.pullMarks) scene.add(mark.group);
|
||||
scene.add(this.slamChain);
|
||||
for (const tent of this.sweepTents) scene.add(tent.group);
|
||||
this.effectsReady = true;
|
||||
}
|
||||
|
||||
update(dt: number, game: Game) {
|
||||
this.game = game;
|
||||
this.ensureEffects(game.scene);
|
||||
this.waveTime += dt;
|
||||
|
||||
// Hit-Feedback: kurz weiss aufblitzen + leichtes Pulsieren
|
||||
if (this.hitFlash > 0) {
|
||||
this.hitFlash -= dt;
|
||||
this.skinMat.color.set(0xffffff);
|
||||
this.darkMat.color.set(0xffffff);
|
||||
this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05);
|
||||
} else {
|
||||
this.skinMat.color.set(this.enraged ? 0x8a2233 : 0x8a4a9a);
|
||||
this.darkMat.color.set(this.enraged ? 0x551520 : 0x5a2a6a);
|
||||
this.model.scale.setScalar(1);
|
||||
}
|
||||
|
||||
// Tentakel wippen
|
||||
for (let i = 0; i < this.tentacles.length; i++) {
|
||||
this.tentacles[i].rotation.z = Math.sin(this.waveTime * 2.2 + i * 0.9) * 0.25;
|
||||
}
|
||||
|
||||
// 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 50% HP
|
||||
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
|
||||
this.enraged = true;
|
||||
// Phase 2: Krake wird dunkelrot
|
||||
this.skinMat.color.set(0x8a2233);
|
||||
this.darkMat.color.set(0x551520);
|
||||
playSound('damage', 0.8);
|
||||
}
|
||||
const inkCd = this.enraged ? INK_COOLDOWN * 0.6 : INK_COOLDOWN;
|
||||
const pullCd = this.enraged ? PULL_COOLDOWN * 0.65 : PULL_COOLDOWN;
|
||||
|
||||
// Tinte spucken
|
||||
this.inkTimer -= dt;
|
||||
if (this.inkTimer <= 0) {
|
||||
this.inkTimer = inkCd;
|
||||
this.spitInk(game);
|
||||
}
|
||||
|
||||
// Tentakel-Griff
|
||||
if (this.pullState === 'idle') {
|
||||
this.pullTimer -= dt;
|
||||
if (this.pullTimer <= 0) {
|
||||
this.pullTimer = pullCd;
|
||||
this.startPullCharge(game);
|
||||
}
|
||||
} else {
|
||||
this.updatePullCharge(dt, game);
|
||||
}
|
||||
if (this.slamState === 'grab') {
|
||||
// Tentakel bleibt am Spieler haengen, solange er gezogen wird
|
||||
if (game.player.pullTime > 0) {
|
||||
const playerPos = game.player.body.position;
|
||||
this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z);
|
||||
this.slamChain.lookAt(playerPos.x, 1, playerPos.z);
|
||||
const dist = Math.max(1, Math.hypot(
|
||||
playerPos.x - this.body.position.x,
|
||||
playerPos.z - this.body.position.z
|
||||
));
|
||||
this.slamChain.scale.set(1, 1, Math.min(dist / 5, 2.5));
|
||||
} else {
|
||||
// Zug vorbei -> Tentakel zieht sich zurueck
|
||||
this.slamRetractFrom = this.slamChain.scale.z;
|
||||
this.slamState = 'slam';
|
||||
this.slamTime = SLAM_TIME;
|
||||
}
|
||||
} else if (this.slamState === 'slam') {
|
||||
this.updateSlam(dt);
|
||||
}
|
||||
|
||||
// Tentakel-Sweep
|
||||
this.updateSweep(dt, game);
|
||||
|
||||
// Tinten-Blobs aktualisieren
|
||||
for (let i = this.inkBlobs.length - 1; i >= 0; i--) {
|
||||
const blob = this.inkBlobs[i];
|
||||
if (!blob.update(dt, game)) {
|
||||
this.inkBlobs.splice(i, 1);
|
||||
const puddle = new InkPuddle(
|
||||
blob.body.position,
|
||||
this.enraged ? INK_RADIUS + 0.5 : INK_RADIUS,
|
||||
INK_DURATION
|
||||
);
|
||||
game.scene.add(puddle.body);
|
||||
this.puddles.push(puddle);
|
||||
blob.dispose();
|
||||
playSound('explosion', 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// Tintenpfützen aktualisieren
|
||||
for (let i = this.puddles.length - 1; i >= 0; i--) {
|
||||
const puddle = this.puddles[i];
|
||||
if (!puddle.update(dt, game)) {
|
||||
this.puddles.splice(i, 1);
|
||||
puddle.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private spitInk(game: Game) {
|
||||
const from = new THREE.Vector3(this.body.position.x, 3.0, this.body.position.z);
|
||||
const target = game.player.body.position.clone();
|
||||
target.y = 0;
|
||||
|
||||
if (this.enraged) {
|
||||
const t2 = target.clone().add(
|
||||
new THREE.Vector3((Math.random() - 0.5) * 8, 0, (Math.random() - 0.5) * 8)
|
||||
);
|
||||
const blob2 = new InkBlob(from, t2);
|
||||
game.scene.add(blob2.body);
|
||||
this.inkBlobs.push(blob2);
|
||||
}
|
||||
|
||||
const blob = new InkBlob(from, target);
|
||||
game.scene.add(blob.body);
|
||||
this.inkBlobs.push(blob);
|
||||
playSound('spawn', 0.5);
|
||||
}
|
||||
|
||||
private startPullCharge(game: Game) {
|
||||
this.pullState = 'charge';
|
||||
this.pullChargeTime = PULL_CHARGE_TIME;
|
||||
this.markOffsetAngle = Math.random() * Math.PI * 2;
|
||||
const cx = this.body.position.x;
|
||||
const cz = this.body.position.z;
|
||||
for (let i = 0; i < PULL_MARK_COUNT; i++) {
|
||||
const a = this.markOffsetAngle + (i / PULL_MARK_COUNT) * Math.PI * 2;
|
||||
const mark = this.pullMarks[i];
|
||||
mark.group.position.set(
|
||||
cx + Math.cos(a) * PULL_MARK_DIST,
|
||||
0.06,
|
||||
cz + Math.sin(a) * PULL_MARK_DIST
|
||||
);
|
||||
mark.group.visible = true;
|
||||
}
|
||||
playSound('spawn', 0.8);
|
||||
}
|
||||
|
||||
private updatePullCharge(dt: number, game: Game) {
|
||||
this.pullChargeTime -= dt;
|
||||
const pulse = Math.abs(Math.sin(this.waveTime * 9));
|
||||
for (const mark of this.pullMarks) {
|
||||
mark.ringMat.opacity = 0.3 + 0.5 * pulse;
|
||||
mark.discMat.opacity = 0.1 + 0.08 * pulse;
|
||||
}
|
||||
|
||||
if (this.pullChargeTime <= 0) {
|
||||
this.pullState = 'idle';
|
||||
const playerPos = game.player.body.position;
|
||||
|
||||
// Spieler in einer Marke? -> heranziehen
|
||||
let grabbed = false;
|
||||
for (const mark of this.pullMarks) {
|
||||
const dx = playerPos.x - mark.group.position.x;
|
||||
const dz = playerPos.z - mark.group.position.z;
|
||||
if (Math.hypot(dx, dz) < PULL_MARK_RADIUS) {
|
||||
grabbed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const mark of this.pullMarks) {
|
||||
mark.group.visible = false;
|
||||
mark.ringMat.opacity = 0;
|
||||
mark.discMat.opacity = 0;
|
||||
}
|
||||
|
||||
if (grabbed && playerPos.y <= 0.8) {
|
||||
// Slam-Tentakel greift den Spieler und bleibt haengen
|
||||
this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z);
|
||||
this.slamChain.lookAt(playerPos.x, 1, playerPos.z);
|
||||
this.slamChain.scale.set(1, 1, 1);
|
||||
this.slamChain.visible = true;
|
||||
this.slamState = 'grab';
|
||||
|
||||
game.player.damage(PULL_DAMAGE, this.body.position);
|
||||
const dir = new THREE.Vector3().subVectors(this.body.position, playerPos);
|
||||
dir.y = 0;
|
||||
if (dir.lengthSq() < 0.01) dir.set(0, 0, 1);
|
||||
dir.normalize();
|
||||
game.player.pullDir.copy(dir);
|
||||
game.player.pullTime = PULL_TIME;
|
||||
playSound('damage', 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateSlam(dt: number) {
|
||||
this.slamTime -= dt;
|
||||
const s = Math.max(0, this.slamTime / SLAM_TIME);
|
||||
this.slamChain.scale.set(1, 1, this.slamRetractFrom * s);
|
||||
|
||||
if (this.slamTime <= 0) {
|
||||
this.slamChain.visible = false;
|
||||
this.slamState = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
private updateSweep(dt: number, game: Game) {
|
||||
if (!this.sweepActive) {
|
||||
this.sweepTimer -= dt;
|
||||
const cd = this.enraged ? 3.2 : 4.5;
|
||||
if (this.sweepTimer <= 0) {
|
||||
this.sweepTimer = cd;
|
||||
this.sweepActive = true;
|
||||
this.sweepHit = false;
|
||||
const start = Math.random() * Math.PI * 2;
|
||||
for (let i = 0; i < this.sweepTents.length; i++) {
|
||||
const tent = this.sweepTents[i];
|
||||
tent.angle = start + i * Math.PI;
|
||||
tent.progress = 0;
|
||||
tent.group.position.set(this.body.position.x, 0.06, this.body.position.z);
|
||||
// Tentakel liegt auf lokaler +X; rotation.y = -Winkel zeigt auf (cos, sin)
|
||||
tent.group.rotation.y = -tent.angle;
|
||||
tent.group.visible = i === 0 || this.enraged;
|
||||
}
|
||||
playSound('spawn', 0.6);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rotSpeed = ((Math.PI * 2) / (this.enraged ? 3.0 : SWEEP_DURATION)) * dt;
|
||||
|
||||
for (const tent of this.sweepTents) {
|
||||
// Phase 2: zweiter Tentakel kreist gegensinnig
|
||||
if (tent.dir === -1 && !this.enraged) {
|
||||
tent.group.visible = false;
|
||||
continue;
|
||||
}
|
||||
tent.angle += tent.dir * rotSpeed;
|
||||
tent.progress += rotSpeed;
|
||||
tent.group.rotation.y = -tent.angle;
|
||||
if (!this.sweepHit) {
|
||||
const playerPos = game.player.body.position;
|
||||
const dx = playerPos.x - this.body.position.x;
|
||||
const dz = playerPos.z - this.body.position.z;
|
||||
const dist = Math.hypot(dx, dz);
|
||||
if (dist > 0.5 && dist < SWEEP_RADIUS + 0.5) {
|
||||
const dirX = Math.cos(tent.angle);
|
||||
const dirZ = Math.sin(tent.angle);
|
||||
// Abstand des Spielers zur Tentakel-Linie (radial vom Boss)
|
||||
const perp = Math.abs(dx * dirZ - dz * dirX);
|
||||
const along = dx * dirX + dz * dirZ;
|
||||
// Ueberspringbar: nur Treffer, solange der Spieler am Boden ist
|
||||
if (along > 0.3 && perp < SWEEP_HIT_WIDTH && playerPos.y <= 0.6) {
|
||||
this.sweepHit = true;
|
||||
game.player.damage(SWEEP_DAMAGE, this.body.position);
|
||||
playSound('damage', 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sweepTents.every(t => t.progress >= Math.PI * 2)) {
|
||||
this.sweepActive = false;
|
||||
for (const tent of this.sweepTents) {
|
||||
tent.group.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.inkBlobs) {
|
||||
blob.dispose();
|
||||
}
|
||||
this.inkBlobs = [];
|
||||
for (const puddle of this.puddles) {
|
||||
puddle.dispose();
|
||||
}
|
||||
this.puddles = [];
|
||||
if (this.game) {
|
||||
for (const mark of this.pullMarks) {
|
||||
this.game.scene.remove(mark.group);
|
||||
}
|
||||
this.game.scene.remove(this.slamChain);
|
||||
for (const tent of this.sweepTents) {
|
||||
this.game.scene.remove(tent.group);
|
||||
}
|
||||
}
|
||||
for (const mark of this.pullMarks) {
|
||||
for (const child of mark.group.children) {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.dispose();
|
||||
(child.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pullMarks = [];
|
||||
for (const tent of this.sweepTents) {
|
||||
for (const child of tent.group.children) {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.dispose();
|
||||
(child.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of this.slamChain.children) {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.dispose();
|
||||
(child.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,769 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import { Enemy } from './Enemy';
|
||||
import { playSound } from './SoundManager';
|
||||
import { FallingRock } from './FallingRock';
|
||||
|
||||
const MAX_HEALTH = 6000;
|
||||
const HIT_FLASH_TIME = 0.14;
|
||||
const MODEL_SCALE = 1.7;
|
||||
const IDEAL_DIST = 4.5;
|
||||
const MIN_DIST = 3.2;
|
||||
const MOVE_SPEED = 5;
|
||||
const TURN_SPEED_1 = 2.2;
|
||||
const TURN_SPEED_2 = 3.5;
|
||||
const DASH_COOLDOWN = 8;
|
||||
const DASH_COOLDOWN_2 = 4;
|
||||
const DASH_WINDUP = 0.6;
|
||||
const DASH_SPEED = 34;
|
||||
const DASH_MAX_DIST = 30;
|
||||
const DASH_DAMAGE = 35;
|
||||
const WALL_LIMIT = 46;
|
||||
const BOSS_STUN_TIME = 3.5;
|
||||
const SWING_COOLDOWN = 5;
|
||||
const SWING_COOLDOWN_2 = 3.5;
|
||||
const SWING_WINDUP = 0.45;
|
||||
const SWING_TIME = 0.3;
|
||||
const SWING_RANGE = 5.2;
|
||||
const SWING_ARC = (90 * Math.PI) / 180;
|
||||
const SWING_DAMAGE = 60;
|
||||
const STOMP_COOLDOWN = 10;
|
||||
const STOMP_COOLDOWN_2 = 8;
|
||||
const STOMP_JUMP_TIME = 1.0;
|
||||
const STOMP_JUMP_HEIGHT = 4;
|
||||
const STOMP_RADIUS = 11;
|
||||
const STOMP_DAMAGE = 15;
|
||||
const STOMP_STUN = 1.2;
|
||||
const ENRAGE_RATIO = 0.4;
|
||||
const ROCK_COUNT = 7;
|
||||
const ROCK_COUNT_2 = 10;
|
||||
|
||||
type State = 'idle' | 'dashwindup' | 'dash' | 'dashstop' | 'stunned' | 'swingwindup' | 'swing' | 'jump';
|
||||
|
||||
export class Minotaurus extends Enemy {
|
||||
private model: THREE.Group;
|
||||
private game: Game | null = null;
|
||||
private waveTime = 0;
|
||||
private state: State = 'idle';
|
||||
private stateTime = 0;
|
||||
private yaw = 0;
|
||||
private enraged = false;
|
||||
private hitFlash = 0;
|
||||
|
||||
private dashTimer = 3.5;
|
||||
private swingTimer = 2;
|
||||
private stompTimer = 6;
|
||||
private doubleDashLeft = 0;
|
||||
|
||||
private dashDir = new THREE.Vector3();
|
||||
private dashDist = 0;
|
||||
private dashHitPlayer = false;
|
||||
|
||||
private swingHit = false;
|
||||
|
||||
private jumpFrom = new THREE.Vector3();
|
||||
private jumpTo = new THREE.Vector3();
|
||||
|
||||
private axeGroup: THREE.Group;
|
||||
private eyeMat!: THREE.MeshBasicMaterial;
|
||||
private furMat!: THREE.MeshToonMaterial;
|
||||
private darkMat!: THREE.MeshToonMaterial;
|
||||
private starsGroup: THREE.Group;
|
||||
private telegraphArc!: THREE.Mesh;
|
||||
private swingArc!: THREE.Mesh;
|
||||
private dashLine!: THREE.Mesh;
|
||||
private shockRing!: THREE.Mesh;
|
||||
private shockRingMat!: THREE.MeshBasicMaterial;
|
||||
private legs: THREE.Group[] = [];
|
||||
private moving = false;
|
||||
|
||||
private rocks: FallingRock[] = [];
|
||||
private cracks: { group: THREE.Group; life: number }[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.health = MAX_HEALTH;
|
||||
this.maxHealth = MAX_HEALTH;
|
||||
this.isBoss = true;
|
||||
this.displayName = 'Minotaurus';
|
||||
this.speed = 0;
|
||||
this.xp = 200;
|
||||
this.contactDps = 0;
|
||||
this.contactRadius = 0;
|
||||
this.guaranteedDrop = true;
|
||||
|
||||
this.model = new THREE.Group();
|
||||
this.model.scale.setScalar(MODEL_SCALE);
|
||||
// Kein fadeOutModel: beim Tod schrumpft der ganze Body inkl. Effekten
|
||||
this.body.add(this.model);
|
||||
|
||||
this.furMat = new THREE.MeshToonMaterial({ color: 0x6b4a2f });
|
||||
this.darkMat = new THREE.MeshToonMaterial({ color: 0x4a3220 });
|
||||
const hornMat = new THREE.MeshToonMaterial({ color: 0xd8c8a8 });
|
||||
const axeMat = new THREE.MeshToonMaterial({ color: 0x9999aa });
|
||||
const metalMat = new THREE.MeshToonMaterial({ color: 0x8a8a99 });
|
||||
const darkMetalMat = new THREE.MeshToonMaterial({ color: 0x555566 });
|
||||
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xffcc33 });
|
||||
|
||||
// Beine (Gruppen mit Hueft-Gelenk, laufen animiert)
|
||||
for (const side of [-1, 1]) {
|
||||
const legGroup = new THREE.Group();
|
||||
legGroup.position.set(side * 0.45, 0.75, 0);
|
||||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.3, 0.8, 10), this.darkMat);
|
||||
leg.position.y = -0.4;
|
||||
leg.castShadow = true;
|
||||
this.registerFadeMesh(leg);
|
||||
legGroup.add(leg);
|
||||
const hoof = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.16, 0.42), metalMat);
|
||||
hoof.position.set(0, -0.78, 0.06);
|
||||
this.registerFadeMesh(hoof);
|
||||
legGroup.add(hoof);
|
||||
const guard = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.4, 0.1), metalMat);
|
||||
guard.position.set(0, -0.4, 0.2);
|
||||
this.registerFadeMesh(guard);
|
||||
legGroup.add(guard);
|
||||
this.model.add(legGroup);
|
||||
this.legs.push(legGroup);
|
||||
}
|
||||
|
||||
// Torso
|
||||
const torso = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.3, 1.0), this.furMat);
|
||||
torso.position.y = 1.15;
|
||||
torso.castShadow = true;
|
||||
this.registerFadeMesh(torso);
|
||||
this.model.add(torso);
|
||||
|
||||
// Vorder-Panzerung: Brustplatte
|
||||
const chest = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.8, 0.1), metalMat);
|
||||
chest.position.set(0, 1.18, 0.48);
|
||||
chest.castShadow = true;
|
||||
this.registerFadeMesh(chest);
|
||||
this.model.add(chest);
|
||||
|
||||
// Schultern (gepanzert vorn)
|
||||
for (const side of [-1, 1]) {
|
||||
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 10), this.furMat);
|
||||
shoulder.position.set(side * 0.85, 1.6, 0);
|
||||
shoulder.castShadow = true;
|
||||
this.registerFadeMesh(shoulder);
|
||||
this.model.add(shoulder);
|
||||
const pauldron = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.3, 0.4), metalMat);
|
||||
pauldron.position.set(side * 0.85, 1.68, 0.3);
|
||||
this.registerFadeMesh(pauldron);
|
||||
this.model.add(pauldron);
|
||||
}
|
||||
|
||||
// Brustpanzer-Gürtel
|
||||
const belt = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.16, 0.75), darkMetalMat);
|
||||
belt.position.set(0, 0.68, 0);
|
||||
this.registerFadeMesh(belt);
|
||||
this.model.add(belt);
|
||||
|
||||
// Linker Arm
|
||||
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.26, 0.9, 8), this.furMat);
|
||||
arm.position.set(-0.85, 0.85, 0);
|
||||
arm.castShadow = true;
|
||||
this.registerFadeMesh(arm);
|
||||
this.model.add(arm);
|
||||
|
||||
// Rechter Arm mit Axt (schwingt beim Angriff)
|
||||
this.axeGroup = new THREE.Group();
|
||||
this.axeGroup.position.set(0.85, 1.0, 0);
|
||||
this.model.add(this.axeGroup);
|
||||
const axeArm = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.26, 0.9, 8), this.furMat);
|
||||
axeArm.position.y = -0.15;
|
||||
axeArm.castShadow = true;
|
||||
this.registerFadeMesh(axeArm);
|
||||
this.axeGroup.add(axeArm);
|
||||
const handle = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.8, 8), this.darkMat);
|
||||
handle.rotation.x = Math.PI / 2;
|
||||
handle.position.z = 0.6;
|
||||
handle.castShadow = true;
|
||||
this.registerFadeMesh(handle);
|
||||
this.axeGroup.add(handle);
|
||||
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.7, 0.12), axeMat);
|
||||
blade.position.z = 1.35;
|
||||
blade.castShadow = true;
|
||||
this.registerFadeMesh(blade);
|
||||
this.axeGroup.add(blade);
|
||||
|
||||
// Kopf
|
||||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.6, 0.75), this.furMat);
|
||||
head.position.y = 2.05;
|
||||
head.castShadow = true;
|
||||
this.registerFadeMesh(head);
|
||||
this.model.add(head);
|
||||
|
||||
// Schnauze (+Z)
|
||||
const snout = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.24, 0.4), this.darkMat);
|
||||
snout.position.set(0, 1.92, 0.55);
|
||||
this.registerFadeMesh(snout);
|
||||
this.model.add(snout);
|
||||
|
||||
// Hörner
|
||||
for (const side of [-1, 1]) {
|
||||
const horn = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.55, 8), hornMat);
|
||||
horn.position.set(side * 0.28, 2.35, 0.1);
|
||||
horn.rotation.set(-1.1, 0, side * 0.45);
|
||||
horn.castShadow = true;
|
||||
this.registerFadeMesh(horn);
|
||||
this.model.add(horn);
|
||||
}
|
||||
|
||||
// Augen (+Z, gelb -> rot in Phase 2)
|
||||
for (const side of [-1, 1]) {
|
||||
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.eyeMat);
|
||||
eye.position.set(side * 0.2, 2.12, 0.4);
|
||||
this.registerFadeMesh(eye);
|
||||
this.model.add(eye);
|
||||
}
|
||||
|
||||
// Stirnpanzer vorn
|
||||
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.22, 0.08), metalMat);
|
||||
brow.position.set(0, 2.24, 0.38);
|
||||
this.registerFadeMesh(brow);
|
||||
this.model.add(brow);
|
||||
|
||||
// --- Effekte an den Body haengen (nicht skaliert, drehen aber mit) ---
|
||||
|
||||
// Stun-Sterne um den Kopf
|
||||
this.starsGroup = new THREE.Group();
|
||||
const starGeom = new THREE.OctahedronGeometry(0.14);
|
||||
const starMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = (i / 4) * Math.PI * 2;
|
||||
const star = new THREE.Mesh(starGeom, starMat);
|
||||
star.position.set(Math.cos(a) * 1.4, 3.7, Math.sin(a) * 1.4);
|
||||
this.starsGroup.add(star);
|
||||
}
|
||||
this.starsGroup.visible = false;
|
||||
this.body.add(this.starsGroup);
|
||||
|
||||
// Axt-Telegraf-Bogen (Trefferzone) auf dem Boden.
|
||||
// Achtung: RingGeometry-Winkel starten an lokaler +X -> um 90° versetzen,
|
||||
// damit der Halbkreis nach vorn (+Z, Blickrichtung) zeigt.
|
||||
const arcMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff4444,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const arcGeom = new THREE.RingGeometry(
|
||||
SWING_RANGE - 0.7, SWING_RANGE - 0.2, 48, 1,
|
||||
-SWING_ARC - Math.PI / 2, SWING_ARC * 2
|
||||
);
|
||||
this.telegraphArc = new THREE.Mesh(arcGeom, arcMat);
|
||||
this.telegraphArc.rotation.x = -Math.PI / 2;
|
||||
this.telegraphArc.position.y = 0.05;
|
||||
this.telegraphArc.visible = false;
|
||||
this.body.add(this.telegraphArc);
|
||||
|
||||
// Axt-Sweep-Visual: heller Bogen fegt von rechts nach links ueber den Boden
|
||||
const swingArcMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffaa88,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.swingArc = new THREE.Mesh(arcGeom, swingArcMat);
|
||||
this.swingArc.rotation.x = -Math.PI / 2;
|
||||
this.swingArc.position.y = 0.05;
|
||||
this.swingArc.visible = false;
|
||||
this.body.add(this.swingArc);
|
||||
|
||||
// Dash-Richtungslinie
|
||||
const lineMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff3333,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.dashLine = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.02, 12), lineMat);
|
||||
this.dashLine.position.y = 0.06;
|
||||
this.dashLine.position.z = 8;
|
||||
this.dashLine.visible = false;
|
||||
this.body.add(this.dashLine);
|
||||
|
||||
// Schockwellen-Ring
|
||||
this.shockRingMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff5533,
|
||||
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: kurz weiss aufblitzen + Puls
|
||||
if (this.hitFlash > 0) {
|
||||
this.hitFlash -= dt;
|
||||
this.furMat.color.set(0xffffff);
|
||||
this.darkMat.color.set(0xffffff);
|
||||
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
|
||||
} else {
|
||||
this.furMat.color.set(0x6b4a2f);
|
||||
this.darkMat.color.set(0x4a3220);
|
||||
this.model.scale.setScalar(MODEL_SCALE);
|
||||
}
|
||||
|
||||
// Phase 2 ab 40% HP
|
||||
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
|
||||
this.enraged = true;
|
||||
this.eyeMat.color.set(0xff2222);
|
||||
playSound('damage', 0.9);
|
||||
game.triggerCameraShake(0.35, 0.5);
|
||||
}
|
||||
|
||||
switch (this.state) {
|
||||
case 'idle': this.updateIdle(dt, game); break;
|
||||
case 'dashwindup': this.updateDashWindup(dt, game); break;
|
||||
case 'dash': this.updateDash(dt, game); break;
|
||||
case 'dashstop': this.updateDashStop(dt); break;
|
||||
case 'stunned': this.updateStunned(dt); break;
|
||||
case 'swingwindup': this.updateSwingWindup(dt, game); break;
|
||||
case 'swing': this.updateSwing(dt, game); break;
|
||||
case 'jump': this.updateJump(dt, game); break;
|
||||
}
|
||||
|
||||
// Stun-Sterne animieren
|
||||
this.starsGroup.visible = this.state === 'stunned';
|
||||
if (this.starsGroup.visible) {
|
||||
this.starsGroup.rotation.y += dt * 3;
|
||||
for (const star of this.starsGroup.children) {
|
||||
star.rotation.x += dt * 5;
|
||||
star.rotation.y += dt * 4;
|
||||
star.position.y = 3.7 + Math.sin(this.waveTime * 6) * 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
// Bein-Animation: Laufen / Dash-Haltung / sonst still
|
||||
if (this.state === 'dash' || this.state === 'dashwindup') {
|
||||
for (const leg of this.legs) leg.rotation.x = 0.5;
|
||||
} else if (this.state === 'jump') {
|
||||
for (const leg of this.legs) leg.rotation.x = 0.25;
|
||||
} else if (this.state === 'idle' && this.moving) {
|
||||
const step = Math.sin(this.waveTime * 9);
|
||||
this.legs[0].rotation.x = step * 0.55;
|
||||
this.legs[1].rotation.x = -step * 0.55;
|
||||
} else {
|
||||
for (const leg of this.legs) leg.rotation.x = 0;
|
||||
}
|
||||
|
||||
// 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 / 11) * 0.5);
|
||||
if (s >= 11) this.shockRing.visible = false;
|
||||
}
|
||||
|
||||
// Felsen
|
||||
for (let i = this.rocks.length - 1; i >= 0; i--) {
|
||||
if (!this.rocks[i].update(dt, game)) {
|
||||
this.rocks[i].dispose();
|
||||
this.rocks.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Wandrisse verblassen
|
||||
for (let i = this.cracks.length - 1; i >= 0; i--) {
|
||||
const c = this.cracks[i];
|
||||
c.life -= dt;
|
||||
const op = Math.max(0, c.life / 2.5) * 0.6;
|
||||
c.group.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
(obj.material as THREE.Material & { opacity: number }).opacity = op;
|
||||
}
|
||||
});
|
||||
if (c.life <= 0) {
|
||||
game.scene.remove(c.group);
|
||||
this.disposeGroup(c.group);
|
||||
this.cracks.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateIdle(dt: number, game: Game) {
|
||||
const playerPos = game.player.body.position;
|
||||
const dx = playerPos.x - this.body.position.x;
|
||||
const dz = playerPos.z - this.body.position.z;
|
||||
const dist = Math.hypot(dx, dz);
|
||||
|
||||
// Turn-Lag: langsam zum Spieler drehen
|
||||
const target = Math.atan2(dx, dz);
|
||||
const turnSpeed = this.enraged ? TURN_SPEED_2 : TURN_SPEED_1;
|
||||
this.yaw = this.lerpAngle(this.yaw, target, Math.min(1, turnSpeed * dt));
|
||||
this.body.rotation.y = this.yaw;
|
||||
|
||||
// Abstand halten: nie zu nah, nie zu weit
|
||||
this.moving = false;
|
||||
const moveDir = new THREE.Vector3();
|
||||
if (dist > IDEAL_DIST + 1 && dist > 0.01) {
|
||||
moveDir.set(dx / dist, 0, dz / dist);
|
||||
this.moving = true;
|
||||
} else if (dist < MIN_DIST && dist > 0.01) {
|
||||
moveDir.set(-dx / dist, 0, -dz / dist);
|
||||
this.moving = true;
|
||||
}
|
||||
if (moveDir.lengthSq() > 0) {
|
||||
this.body.position.x += moveDir.x * MOVE_SPEED * dt;
|
||||
this.body.position.z += moveDir.z * MOVE_SPEED * dt;
|
||||
game.clampToArena(this.body.position);
|
||||
}
|
||||
|
||||
this.dashTimer -= dt;
|
||||
this.swingTimer -= dt;
|
||||
this.stompTimer -= dt;
|
||||
if (this.dashTimer <= 0) {
|
||||
this.dashTimer = this.enraged ? DASH_COOLDOWN_2 : DASH_COOLDOWN;
|
||||
this.doubleDashLeft = this.enraged ? 1 : 0;
|
||||
this.startDashWindup(game);
|
||||
} else if (this.swingTimer <= 0) {
|
||||
this.swingTimer = this.enraged ? SWING_COOLDOWN_2 : SWING_COOLDOWN;
|
||||
this.startSwingWindup();
|
||||
} else if (this.stompTimer <= 0) {
|
||||
this.stompTimer = this.enraged ? STOMP_COOLDOWN_2 : STOMP_COOLDOWN;
|
||||
this.startJump(game);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dash ---
|
||||
|
||||
private startDashWindup(game: Game) {
|
||||
this.state = 'dashwindup';
|
||||
this.stateTime = DASH_WINDUP;
|
||||
this.dashDir.set(
|
||||
game.player.body.position.x - this.body.position.x,
|
||||
0,
|
||||
game.player.body.position.z - this.body.position.z
|
||||
);
|
||||
if (this.dashDir.lengthSq() < 0.01) this.dashDir.set(0, 0, 1);
|
||||
this.dashDir.normalize();
|
||||
this.dashHitPlayer = false;
|
||||
this.yaw = Math.atan2(this.dashDir.x, this.dashDir.z);
|
||||
this.body.rotation.y = this.yaw;
|
||||
this.dashLine.visible = true;
|
||||
playSound('spawn', 0.6);
|
||||
}
|
||||
|
||||
private updateDashWindup(dt: number, game: Game) {
|
||||
this.stateTime -= dt;
|
||||
(this.dashLine.material as THREE.MeshBasicMaterial).opacity =
|
||||
0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 14));
|
||||
if (this.stateTime <= 0) {
|
||||
this.dashLine.visible = false;
|
||||
this.state = 'dash';
|
||||
this.dashDist = 0;
|
||||
playSound('spawn', 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
private updateDash(dt: number, game: Game) {
|
||||
const step = DASH_SPEED * dt;
|
||||
this.dashDist += step;
|
||||
this.body.position.x += this.dashDir.x * step;
|
||||
this.body.position.z += this.dashDir.z * step;
|
||||
|
||||
if (!this.dashHitPlayer && 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) < 1.7) {
|
||||
this.dashHitPlayer = true;
|
||||
game.player.damage(DASH_DAMAGE, this.body.position);
|
||||
playSound('damage', 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(this.body.position.x) > WALL_LIMIT || Math.abs(this.body.position.z) > WALL_LIMIT) {
|
||||
game.clampToArena(this.body.position);
|
||||
this.hitWall(game);
|
||||
return;
|
||||
}
|
||||
if (this.dashDist >= DASH_MAX_DIST) {
|
||||
this.endDash(game);
|
||||
}
|
||||
}
|
||||
|
||||
private endDash(game: Game) {
|
||||
if (this.doubleDashLeft > 0) {
|
||||
this.doubleDashLeft--;
|
||||
this.startDashWindup(game);
|
||||
} else {
|
||||
this.state = 'dashstop';
|
||||
this.stateTime = 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
private updateDashStop(dt: number) {
|
||||
this.stateTime -= dt;
|
||||
if (this.stateTime <= 0) this.state = 'idle';
|
||||
}
|
||||
|
||||
private hitWall(game: Game) {
|
||||
this.state = 'stunned';
|
||||
this.stateTime = BOSS_STUN_TIME;
|
||||
this.spawnWallCracks(game);
|
||||
playSound('explosion', 0.5);
|
||||
}
|
||||
|
||||
private updateStunned(dt: number) {
|
||||
this.stateTime -= dt;
|
||||
// Schwindel-Wackeln
|
||||
this.model.rotation.z = Math.sin(this.waveTime * 20) * 0.08;
|
||||
if (this.stateTime <= 0) {
|
||||
this.model.rotation.z = 0;
|
||||
this.state = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
private spawnWallCracks(game: Game) {
|
||||
const group = new THREE.Group();
|
||||
const crackMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x111111,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
depthWrite: false,
|
||||
});
|
||||
const onXWall = Math.abs(this.body.position.x) > Math.abs(this.body.position.z);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const len = 0.8 + Math.random() * 1.2;
|
||||
const geo = new THREE.BoxGeometry(
|
||||
onXWall ? 0.06 : len,
|
||||
0.06,
|
||||
onXWall ? len : 0.06
|
||||
);
|
||||
const crack = new THREE.Mesh(geo, crackMat);
|
||||
if (onXWall) {
|
||||
crack.position.set(
|
||||
Math.sign(this.body.position.x) * 49.2,
|
||||
0.5 + Math.random() * 1.4,
|
||||
this.body.position.z + (Math.random() - 0.5) * 2.4
|
||||
);
|
||||
} else {
|
||||
crack.position.set(
|
||||
this.body.position.x + (Math.random() - 0.5) * 2.4,
|
||||
0.5 + Math.random() * 1.4,
|
||||
Math.sign(this.body.position.z) * 49.2
|
||||
);
|
||||
}
|
||||
crack.rotation.y = Math.random() * Math.PI;
|
||||
group.add(crack);
|
||||
}
|
||||
game.scene.add(group);
|
||||
this.cracks.push({ group, life: 2.5 });
|
||||
}
|
||||
|
||||
// --- Axt-Schwung ---
|
||||
|
||||
private startSwingWindup() {
|
||||
this.state = 'swingwindup';
|
||||
this.stateTime = SWING_WINDUP;
|
||||
this.swingHit = false;
|
||||
// Axt anheben (leicht), bereit fuer den horizontalen Sweep
|
||||
this.axeGroup.rotation.x = -0.35;
|
||||
this.axeGroup.rotation.y = 1.0;
|
||||
this.telegraphArc.visible = true;
|
||||
playSound('spawn', 0.5);
|
||||
}
|
||||
|
||||
private updateSwingWindup(dt: number, game: Game) {
|
||||
this.stateTime -= dt;
|
||||
(this.telegraphArc.material as THREE.MeshBasicMaterial).opacity =
|
||||
0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 12));
|
||||
if (this.stateTime <= 0) {
|
||||
this.telegraphArc.visible = false;
|
||||
this.state = 'swing';
|
||||
this.stateTime = SWING_TIME;
|
||||
}
|
||||
}
|
||||
|
||||
private updateSwing(dt: number, game: Game) {
|
||||
this.stateTime -= dt;
|
||||
const t = 1 - this.stateTime / SWING_TIME;
|
||||
|
||||
// Axt fegt horizontal von rechts nach links vor dem Koerper
|
||||
this.axeGroup.rotation.y = 1.0 - t * 2.0;
|
||||
this.axeGroup.rotation.x = -0.35 + t * 0.35;
|
||||
|
||||
// Hell: Halbkreis fegt ueber den Boden
|
||||
this.swingArc.visible = true;
|
||||
this.swingArc.rotation.y = SWING_ARC * 0.85 * (1 - 2 * t);
|
||||
const fade = Math.min(1, t * 6, (1 - t) * 6);
|
||||
(this.swingArc.material as THREE.MeshBasicMaterial).opacity = fade * 0.7;
|
||||
|
||||
// Treffer in der Mitte des Schwungs
|
||||
if (!this.swingHit && t >= 0.35) {
|
||||
this.swingHit = true;
|
||||
const playerPos = game.player.body.position;
|
||||
const dx = playerPos.x - this.body.position.x;
|
||||
const dz = playerPos.z - this.body.position.z;
|
||||
const dist = Math.hypot(dx, dz);
|
||||
if (dist < SWING_RANGE) {
|
||||
const ang = Math.atan2(dx, dz);
|
||||
// Ueberspringbar: Treffer nur am Boden
|
||||
if (Math.abs(this.angleDiff(this.yaw, ang)) < SWING_ARC && playerPos.y <= 0.6) {
|
||||
game.player.damage(SWING_DAMAGE, this.body.position);
|
||||
playSound('damage', 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.stateTime <= 0) {
|
||||
this.axeGroup.rotation.x = 0;
|
||||
this.axeGroup.rotation.y = 0;
|
||||
this.swingArc.visible = false;
|
||||
this.state = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sprung + Stampf ---
|
||||
|
||||
private startJump(game: Game) {
|
||||
this.state = 'jump';
|
||||
this.stateTime = STOMP_JUMP_TIME;
|
||||
this.jumpFrom.copy(this.body.position);
|
||||
this.jumpTo.set(game.player.body.position.x, 0, game.player.body.position.z);
|
||||
playSound('spawn', 0.7);
|
||||
}
|
||||
|
||||
private updateJump(dt: number, game: Game) {
|
||||
this.stateTime -= dt;
|
||||
const t = 1 - Math.max(0, this.stateTime) / STOMP_JUMP_TIME;
|
||||
this.body.position.x = this.jumpFrom.x + (this.jumpTo.x - this.jumpFrom.x) * t;
|
||||
this.body.position.z = this.jumpFrom.z + (this.jumpTo.z - this.jumpFrom.z) * t;
|
||||
this.body.position.y = 0.15 + Math.sin(t * Math.PI) * STOMP_JUMP_HEIGHT;
|
||||
|
||||
if (this.stateTime <= 0) {
|
||||
this.body.position.y = 0.15;
|
||||
this.landStomp(game);
|
||||
}
|
||||
}
|
||||
|
||||
private landStomp(game: Game) {
|
||||
// Schockwelle: stunnt den Spieler (ueberspringbar)
|
||||
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
|
||||
);
|
||||
if (dist < STOMP_RADIUS && playerPos.y <= 0.6) {
|
||||
game.player.stunTime = STOMP_STUN;
|
||||
game.player.damage(STOMP_DAMAGE, this.body.position);
|
||||
}
|
||||
|
||||
// Steinschlag um den Spieler
|
||||
const count = this.enraged ? ROCK_COUNT_2 : ROCK_COUNT;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = 2 + Math.random() * 6;
|
||||
const rock = new FallingRock(new THREE.Vector3(
|
||||
playerPos.x + Math.cos(a) * r,
|
||||
0,
|
||||
playerPos.z + Math.sin(a) * r
|
||||
));
|
||||
game.scene.add(rock.body);
|
||||
this.rocks.push(rock);
|
||||
}
|
||||
|
||||
this.state = 'idle';
|
||||
}
|
||||
|
||||
// --- Schaden ---
|
||||
|
||||
override takeDamage(
|
||||
damage: number,
|
||||
game: Game,
|
||||
withKnockback = true,
|
||||
sourcePos?: THREE.Vector3
|
||||
) {
|
||||
const src = sourcePos ?? game.player.body.position;
|
||||
const fwd = new THREE.Vector3(Math.sin(this.yaw), 0, Math.cos(this.yaw));
|
||||
const incoming = new THREE.Vector3().subVectors(src, this.body.position);
|
||||
incoming.y = 0;
|
||||
if (incoming.lengthSq() < 0.0001) incoming.copy(fwd);
|
||||
incoming.normalize();
|
||||
|
||||
// Nur von hinten verletzbar: vorn wird geblockt
|
||||
if (fwd.dot(incoming) > 0.15) {
|
||||
game.spawnDamageText('BLOCK', this.body.position, '#99aabb');
|
||||
playSound('sword_hit1', 0.6);
|
||||
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() {
|
||||
this.starsGroup.visible = false;
|
||||
this.dashLine.visible = false;
|
||||
this.telegraphArc.visible = false;
|
||||
this.swingArc.visible = false;
|
||||
this.shockRing.visible = false;
|
||||
this.furMat.color.set(0x6b4a2f);
|
||||
this.darkMat.color.set(0x4a3220);
|
||||
for (const rock of this.rocks) rock.dispose();
|
||||
this.rocks = [];
|
||||
if (this.game) {
|
||||
for (const c of this.cracks) {
|
||||
this.game.scene.remove(c.group);
|
||||
this.disposeGroup(c.group);
|
||||
}
|
||||
}
|
||||
this.cracks = [];
|
||||
}
|
||||
|
||||
private disposeGroup(group: THREE.Group) {
|
||||
group.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as THREE.Material).dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private lerpAngle(a: number, b: number, t: number): number {
|
||||
return a + this.angleDiff(a, b) * t;
|
||||
}
|
||||
|
||||
private angleDiff(a: number, b: number): number {
|
||||
let diff = b - a;
|
||||
while (diff > Math.PI) diff -= Math.PI * 2;
|
||||
while (diff < -Math.PI) diff += Math.PI * 2;
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
-3
@@ -30,6 +30,13 @@ export class Player {
|
||||
onGround = true;
|
||||
shieldHp = 0;
|
||||
shieldTimer = 0;
|
||||
slowTimer = 0;
|
||||
slowFactor = 0.6;
|
||||
pullTime = 0;
|
||||
pullDir = new THREE.Vector3();
|
||||
stunTime = 0;
|
||||
rootTime = 0;
|
||||
rootCooldown = 0;
|
||||
stats: PlayerStats = {
|
||||
maxHealth: 100,
|
||||
regen: 0,
|
||||
@@ -44,10 +51,32 @@ export class Player {
|
||||
mixer!: THREE.AnimationMixer;
|
||||
private clips: Map<string, THREE.AnimationAction> = new Map();
|
||||
private currentAnim = '';
|
||||
private backShield: THREE.Group | null = null;
|
||||
private bodyMaterials: THREE.MeshToonMaterial[] = [];
|
||||
private hurtCooldown = 0;
|
||||
private flashTime = 0;
|
||||
private stunStars: THREE.Group;
|
||||
private static readonly BASE_COLOR = 0x44aa44;
|
||||
private static readonly HURT_COLOR = 0xff5555;
|
||||
private static readonly SLOW_COLOR = 0x9944cc;
|
||||
private static readonly HURT_FLASH_TIME = 0.3;
|
||||
|
||||
constructor() {
|
||||
this.body = new THREE.Group();
|
||||
|
||||
// Stun-Sterne: kreisen um den Kopf, solange der Spieler gestunnt ist
|
||||
this.stunStars = new THREE.Group();
|
||||
const starGeom = new THREE.OctahedronGeometry(0.13);
|
||||
const starMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = (i / 3) * Math.PI * 2;
|
||||
const star = new THREE.Mesh(starGeom, starMat);
|
||||
star.position.set(Math.cos(a) * 0.55, 1.35, Math.sin(a) * 0.55);
|
||||
this.stunStars.add(star);
|
||||
}
|
||||
this.stunStars.visible = false;
|
||||
this.body.add(this.stunStars);
|
||||
|
||||
// Helmet
|
||||
const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2);
|
||||
const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 });
|
||||
@@ -88,6 +117,49 @@ export class Player {
|
||||
shadow.renderOrder = 999;
|
||||
this.body.add(shadow);
|
||||
|
||||
// Shield on the back while the shield skill is charged
|
||||
loadGLB('shield').then((gltf) => {
|
||||
const shieldModel = gltf.scene;
|
||||
const shieldTexture = new THREE.TextureLoader().load('./textures/shield.png');
|
||||
shieldTexture.flipY = false;
|
||||
shieldTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
shieldModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
const geo = child.geometry;
|
||||
// The converted model has no UVs; project them from the
|
||||
// front plane so the shield texture maps onto the face
|
||||
if (!geo.attributes.uv) {
|
||||
geo.computeBoundingBox();
|
||||
const bb = geo.boundingBox!;
|
||||
const pos = geo.attributes.position;
|
||||
const uv = new Float32Array(pos.count * 2);
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
uv[i * 2] = (pos.getX(i) - bb.min.x) / (bb.max.x - bb.min.x);
|
||||
uv[i * 2 + 1] = (pos.getY(i) - bb.min.y) / (bb.max.y - bb.min.y);
|
||||
}
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
}
|
||||
child.castShadow = true;
|
||||
child.material = new THREE.MeshToonMaterial({ map: shieldTexture });
|
||||
}
|
||||
});
|
||||
// The geometry is offset from the model origin; recenter it so
|
||||
// rotation and scale act on the shield's center
|
||||
const box = new THREE.Box3().setFromObject(shieldModel);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
shieldModel.position.sub(center);
|
||||
const shield = new THREE.Group();
|
||||
shield.add(shieldModel);
|
||||
shield.scale.set(0.25, 0.25, 0.25);
|
||||
shield.position.set(0, 0.85, -0.5);
|
||||
shield.rotation.set(-0.15, Math.PI, 0);
|
||||
shield.visible = this.stats.shield > 0 && this.shieldHp > 0;
|
||||
this.backShield = shield;
|
||||
this.body.add(shield);
|
||||
}).catch(() => {
|
||||
// No shield model available
|
||||
});
|
||||
|
||||
// Weapons
|
||||
this.leftHandWeapon = new Staff();
|
||||
this.rightHandWeapon = new Sword();
|
||||
@@ -107,9 +179,10 @@ export class Player {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
const mat = new THREE.MeshToonMaterial({
|
||||
color: 0x44aa44,
|
||||
color: Player.BASE_COLOR,
|
||||
});
|
||||
child.material = mat;
|
||||
this.bodyMaterials.push(mat);
|
||||
}
|
||||
});
|
||||
this.body.add(model);
|
||||
@@ -126,11 +199,12 @@ export class Player {
|
||||
console.warn('Failed to load blob model, using fallback', e);
|
||||
// Fallback capsule
|
||||
const bodyGeom = new THREE.CapsuleGeometry(0.5, 0.5, 8, 16);
|
||||
const bodyMat = new THREE.MeshToonMaterial({ color: 0x44aa44 });
|
||||
const bodyMat = new THREE.MeshToonMaterial({ color: Player.BASE_COLOR });
|
||||
const bodyMesh = new THREE.Mesh(bodyGeom, bodyMat);
|
||||
bodyMesh.position.y = 0.75;
|
||||
bodyMesh.castShadow = true;
|
||||
this.body.add(bodyMesh);
|
||||
this.bodyMaterials.push(bodyMat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +238,38 @@ export class Player {
|
||||
|
||||
updateAnimations(dt: number) {
|
||||
if (this.mixer) this.mixer.update(dt);
|
||||
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;
|
||||
if (this.stunStars.visible) {
|
||||
this.stunStars.rotation.y += dt * 5;
|
||||
for (const star of this.stunStars.children) {
|
||||
star.rotation.x += dt * 8;
|
||||
star.rotation.y += dt * 6;
|
||||
star.position.y = 1.35 + Math.sin(this.stunTime * 10) * 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
let color: THREE.Color;
|
||||
if (this.flashTime > 0) {
|
||||
this.flashTime -= dt;
|
||||
const t = Math.max(0, this.flashTime) / Player.HURT_FLASH_TIME;
|
||||
color = new THREE.Color(Player.BASE_COLOR).lerp(
|
||||
new THREE.Color(Player.HURT_COLOR), t
|
||||
);
|
||||
} else if (this.slowTimer > 0) {
|
||||
color = new THREE.Color(Player.BASE_COLOR).lerp(
|
||||
new THREE.Color(Player.SLOW_COLOR), 0.55
|
||||
);
|
||||
} else {
|
||||
color = new THREE.Color(Player.BASE_COLOR);
|
||||
}
|
||||
for (const mat of this.bodyMaterials) mat.color.copy(color);
|
||||
}
|
||||
|
||||
setGame(game: Game) {
|
||||
@@ -182,7 +288,7 @@ export class Player {
|
||||
this.health = Math.min(this.health + amount, this.stats.maxHealth);
|
||||
}
|
||||
|
||||
damage(amount: number) {
|
||||
damage(amount: number, sourcePos?: THREE.Vector3) {
|
||||
if (this.shieldHp > 0) {
|
||||
const absorbed = Math.min(this.shieldHp, amount);
|
||||
this.shieldHp -= absorbed;
|
||||
@@ -190,6 +296,13 @@ export class Player {
|
||||
this.shieldTimer = this.stats.shieldRechargeDelay;
|
||||
}
|
||||
this.health -= amount;
|
||||
if (amount > 0 && this.hurtCooldown <= 0) {
|
||||
this.hurtCooldown = 0.25;
|
||||
this.flashTime = Player.HURT_FLASH_TIME;
|
||||
if (this.game) {
|
||||
this.game.onPlayerHurt(sourcePos ?? this.body.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePassives(dt: number) {
|
||||
@@ -206,6 +319,9 @@ export class Player {
|
||||
this.shieldTimer = 0;
|
||||
}
|
||||
}
|
||||
if (this.backShield) {
|
||||
this.backShield.visible = this.stats.shield > 0 && this.shieldHp > 0;
|
||||
}
|
||||
}
|
||||
|
||||
applySkillSystem(skills: SkillSystem) {
|
||||
@@ -232,6 +348,9 @@ export class Player {
|
||||
if (this.stats.shield > 0 && this.shieldHp <= 0) {
|
||||
this.shieldHp = this.stats.shield;
|
||||
}
|
||||
if (this.backShield) {
|
||||
this.backShield.visible = this.stats.shield > 0 && this.shieldHp > 0;
|
||||
}
|
||||
}
|
||||
|
||||
die() {
|
||||
@@ -242,6 +361,11 @@ export class Player {
|
||||
this.health = 100;
|
||||
this.shieldHp = 0;
|
||||
this.shieldTimer = 0;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
const ICONS: Record<string, string> = {
|
||||
fireball: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<radialGradient id="fbCore" cx="50%" cy="62%" r="60%">
|
||||
<stop offset="0%" stop-color="#fff6c0"/>
|
||||
<stop offset="40%" stop-color="#ffc23d"/>
|
||||
<stop offset="78%" stop-color="#ff7b1f"/>
|
||||
<stop offset="100%" stop-color="#d43d17"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="fbFlame" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stop-color="#ff8a2a"/>
|
||||
<stop offset="100%" stop-color="#ffdf6b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="42" r="21" fill="#ff8020" opacity="0.16"/>
|
||||
<path d="M32 3 C38 12 45 17 45 27 C45 34 41 38 37 40 L27 40 C23 38 19 34 19 27 C19 17 26 12 32 3 Z" fill="url(#fbFlame)" opacity="0.95"/>
|
||||
<path d="M32 12 C35 17 39 20 39 26 C39 31 36 34 32 35 C28 34 25 31 25 26 C25 20 29 17 32 12 Z" fill="#fff2b0" opacity="0.9"/>
|
||||
<circle cx="32" cy="43" r="14" fill="url(#fbCore)" stroke="#8a3014" stroke-width="2"/>
|
||||
<ellipse cx="27" cy="38" rx="5" ry="3.2" fill="#fff8d8" opacity="0.7" transform="rotate(-25 27 38)"/>
|
||||
<circle cx="13" cy="22" r="1.6" fill="#ffd75e"/>
|
||||
<circle cx="50" cy="15" r="1.3" fill="#ffd75e"/>
|
||||
<circle cx="52" cy="27" r="1.8" fill="#ffb347"/>
|
||||
<circle cx="15" cy="38" r="1.2" fill="#ffb347"/>
|
||||
</svg>`,
|
||||
|
||||
teleport: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="tpRing" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#59e0ff"/>
|
||||
<stop offset="100%" stop-color="#8f6bff"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="tpGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#59e0ff" stop-opacity="0.3"/>
|
||||
<stop offset="100%" stop-color="#59e0ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="27" fill="url(#tpGlow)"/>
|
||||
<circle cx="32" cy="32" r="23" fill="none" stroke="url(#tpRing)" stroke-width="3.5" stroke-dasharray="11 7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="15" fill="none" stroke="url(#tpRing)" stroke-width="2.5" stroke-dasharray="8 6" stroke-linecap="round" opacity="0.75" transform="rotate(28 32 32)"/>
|
||||
<path d="M32 21 L40 33 L35 33 L35 43 L29 43 L29 33 L24 33 Z" fill="#eafcff" stroke="#2b7f9e" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M49 10 l2.1 4 4 2.1 -4 2.1 -2.1 4 -2.1 -4 -4 -2.1 4 -2.1 Z" fill="#aef2ff"/>
|
||||
<path d="M13 43 l1.7 3.2 3.2 1.7 -3.2 1.7 -1.7 3.2 -1.7 -3.2 -3.2 -1.7 3.2 -1.7 Z" fill="#cbb2ff"/>
|
||||
</svg>`,
|
||||
|
||||
chainlightning: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="clBolt" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#fff8c8"/>
|
||||
<stop offset="45%" stop-color="#ffd83d"/>
|
||||
<stop offset="100%" stop-color="#ff9d1f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="25" fill="#ffd83d" opacity="0.12"/>
|
||||
<path d="M37 3 L17 33 L28 33 L23 61 L47 27 L34 27 Z" fill="url(#clBolt)" stroke="#7a4a08" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M13 10 L8 20 L12 20 L9 29 L17 18 L13 18 Z" fill="#ffd83d" opacity="0.85"/>
|
||||
<path d="M52 38 L47 47 L51 47 L48 56 L56 45 L52 45 Z" fill="#ffd83d" opacity="0.85"/>
|
||||
<path d="M15 17 Q22 21 22 29" fill="none" stroke="#ffe98a" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.8"/>
|
||||
<path d="M49 46 Q42 42 41 34" fill="none" stroke="#ffe98a" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.8"/>
|
||||
</svg>`,
|
||||
|
||||
swordRange: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="srBlade" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#f4f9ff"/>
|
||||
<stop offset="50%" stop-color="#b9c8d8"/>
|
||||
<stop offset="100%" stop-color="#7e93a8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="27" fill="#ffcc00" opacity="0.06"/>
|
||||
<circle cx="32" cy="32" r="27" fill="none" stroke="#ffcc00" stroke-width="2" stroke-dasharray="6 7" opacity="0.85"/>
|
||||
<path d="M32 6 L38 14 L38 36 L26 36 L26 14 Z" fill="url(#srBlade)" stroke="#3c4a58" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M32 7 L32 36" stroke="#ffffff" stroke-width="1.4" opacity="0.7"/>
|
||||
<rect x="21" y="36" width="22" height="5" rx="2" fill="#ffcc00" stroke="#7a5c00" stroke-width="1.2"/>
|
||||
<rect x="29.2" y="41" width="5.6" height="11" rx="1.5" fill="#8a5a2b" stroke="#4a2f14" stroke-width="1.2"/>
|
||||
<circle cx="32" cy="54.5" r="3.2" fill="#ffcc00" stroke="#7a5c00" stroke-width="1.2"/>
|
||||
</svg>`,
|
||||
|
||||
swordDamage: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="sdBlade" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="45%" stop-color="#c8d6e4"/>
|
||||
<stop offset="100%" stop-color="#8298ac"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="26" fill="#ff4444" opacity="0.08"/>
|
||||
<path d="M56 8 L33.9 37.9 L26.1 30.1 Z" fill="url(#sdBlade)" stroke="#3c4a58" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M56 8 L30 34" stroke="#ffffff" stroke-width="1.4" opacity="0.75"/>
|
||||
<path d="M35.3 42.9 L38.9 39.3 L24.7 25.1 L21.1 28.7 Z" fill="#ffcc00" stroke="#7a5c00" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<path d="M31.8 35.8 L23.3 44.3 L19.7 40.7 L28.2 32.2 Z" fill="#8a5a2b" stroke="#4a2f14" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<circle cx="20.3" cy="43.7" r="3.4" fill="#ffcc00" stroke="#7a5c00" stroke-width="1.2"/>
|
||||
<path d="M46 11 l1.8 5.2 5.2 1.8 -5.2 1.8 -1.8 5.2 -1.8 -5.2 -5.2 -1.8 5.2 -1.8 Z" fill="#ffffff" opacity="0.9"/>
|
||||
<path d="M14 52 l1.2 3.4 3.4 1.2 -3.4 1.2 -1.2 3.4 -1.2 -3.4 -3.4 -1.2 3.4 -1.2 Z" fill="#ffd75e" opacity="0.85"/>
|
||||
</svg>`,
|
||||
|
||||
regen: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="rgCross" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#b8ffb0"/>
|
||||
<stop offset="100%" stop-color="#2fae3f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="25" fill="#44cc44" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="24" fill="none" stroke="#7be27b" stroke-width="1.5" stroke-dasharray="2 5" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M26 12 h12 v14 h14 v12 h-14 v14 h-12 v-14 h-14 v-12 h14 Z" fill="url(#rgCross)" stroke="#145a1e" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M50 12 l1.6 3 3 1.6 -3 1.6 -1.6 3 -1.6 -3 -3 -1.6 3 -1.6 Z" fill="#d6ffd0"/>
|
||||
<circle cx="13" cy="17" r="1.8" fill="#b8ffb0"/>
|
||||
<circle cx="52" cy="48" r="1.5" fill="#b8ffb0"/>
|
||||
</svg>`,
|
||||
|
||||
lifesteal: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="lsDrop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ff6b7d"/>
|
||||
<stop offset="55%" stop-color="#c2274f"/>
|
||||
<stop offset="100%" stop-color="#6e1030"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="37" r="24" fill="#c2274f" opacity="0.12"/>
|
||||
<path d="M32 6 C38 15 48 23 48 37 A16 16 0 0 1 16 37 C16 23 26 15 32 6 Z" fill="url(#lsDrop)" stroke="#3d0a1c" stroke-width="2"/>
|
||||
<path d="M26 27 L29.3 37 L32.6 27 Z" fill="#fff" stroke="#3d0a1c" stroke-width="0.8"/>
|
||||
<path d="M33.4 27 L36.7 37 L40 27 Z" fill="#fff" stroke="#3d0a1c" stroke-width="0.8"/>
|
||||
<ellipse cx="24" cy="36" rx="3.5" ry="5" fill="#ff9ab0" opacity="0.5" transform="rotate(18 24 36)"/>
|
||||
<path d="M4 29 H12 M12 29 L8.5 26 M12 29 L8.5 32" fill="none" stroke="#ff9ab0" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M60 41 H52 M52 41 L55.5 38 M52 41 L55.5 44" fill="none" stroke="#ff9ab0" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
|
||||
shield: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="shBody" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#6fc3ff"/>
|
||||
<stop offset="60%" stop-color="#2e7cd6"/>
|
||||
<stop offset="100%" stop-color="#1b4d94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="26" fill="#3399ff" opacity="0.12"/>
|
||||
<path d="M32 5 L53 13 V30 C53 44 45 53 32 59 C19 53 11 44 11 30 V13 Z" fill="url(#shBody)" stroke="#0f2f5c" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<path d="M32 10 L48 16 V30 C48 41 42 48 32 53 C22 48 16 41 16 30 V16 Z" fill="none" stroke="#a8dcff" stroke-width="1.5" opacity="0.7"/>
|
||||
<path d="M32 10 L32 53" stroke="#a8dcff" stroke-width="1.5" opacity="0.5"/>
|
||||
<path d="M47 8 l1.6 3 3 1.6 -3 1.6 -1.6 3 -1.6 -3 -3 -1.6 3 -1.6 Z" fill="#dff1ff"/>
|
||||
</svg>`,
|
||||
|
||||
maxHp: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<radialGradient id="vhHeart" cx="35%" cy="30%" r="80%">
|
||||
<stop offset="0%" stop-color="#ff8f8f"/>
|
||||
<stop offset="55%" stop-color="#e42536"/>
|
||||
<stop offset="100%" stop-color="#8c0f1e"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="26" fill="#e42536" opacity="0.12"/>
|
||||
<path d="M32 55 C22 46 9 37 9 24 C9 15 16 9 23.5 9 C27.5 9 30.7 11 32 14.5 C33.3 11 36.5 9 40.5 9 C48 9 55 15 55 24 C55 37 42 46 32 55 Z" fill="url(#vhHeart)" stroke="#4d0812" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<ellipse cx="23" cy="20" rx="6" ry="4" fill="#ffd0d0" opacity="0.6" transform="rotate(-20 23 20)"/>
|
||||
<path d="M29 23 h6 v7 h7 v6 h-7 v7 h-6 v-7 h-7 v-6 h7 Z" fill="#fff" opacity="0.92"/>
|
||||
</svg>`,
|
||||
|
||||
thorns: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="thThorn" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stop-color="#3e7a2a"/>
|
||||
<stop offset="100%" stop-color="#9fdc6e"/>
|
||||
</linearGradient>
|
||||
<path id="thSpike" d="M32 5.5 L36.5 16 L27.5 16 Z"/>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="25" fill="#5a9e3a" opacity="0.1"/>
|
||||
<g fill="url(#thThorn)" stroke="#1e3d12" stroke-width="1.5" stroke-linejoin="round">
|
||||
<use href="#thSpike"/>
|
||||
<use href="#thSpike" transform="rotate(45 32 32)"/>
|
||||
<use href="#thSpike" transform="rotate(90 32 32)"/>
|
||||
<use href="#thSpike" transform="rotate(135 32 32)"/>
|
||||
<use href="#thSpike" transform="rotate(180 32 32)"/>
|
||||
<use href="#thSpike" transform="rotate(225 32 32)"/>
|
||||
<use href="#thSpike" transform="rotate(270 32 32)"/>
|
||||
<use href="#thSpike" transform="rotate(315 32 32)"/>
|
||||
</g>
|
||||
<circle cx="32" cy="32" r="13" fill="none" stroke="#2f5c1e" stroke-width="5.5"/>
|
||||
<circle cx="32" cy="32" r="13" fill="none" stroke="#78b954" stroke-width="1.5" opacity="0.6"/>
|
||||
</svg>`,
|
||||
|
||||
speed: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="spBoot" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffe08a"/>
|
||||
<stop offset="100%" stop-color="#d99a2b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="34" cy="32" r="26" fill="#ffe08a" opacity="0.08"/>
|
||||
<path d="M4 18 H15" stroke="#ffe08a" stroke-width="3" stroke-linecap="round" opacity="0.85"/>
|
||||
<path d="M2 28 H12" stroke="#ffe08a" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M6 38 H14" stroke="#ffe08a" stroke-width="3" stroke-linecap="round" opacity="0.55"/>
|
||||
<path d="M23 10 h11 v20 c0 2.5 1 4 3.5 5 l9.5 3.5 c5 1.8 8 5.2 8 9.5 v4 h-32 Z" fill="url(#spBoot)" stroke="#6e4a12" stroke-width="2" stroke-linejoin="round"/>
|
||||
<rect x="20.5" y="5" width="16" height="6.5" rx="2.5" fill="#ffcf5e" stroke="#6e4a12" stroke-width="2"/>
|
||||
<path d="M27 48 h24" stroke="#6e4a12" stroke-width="2" opacity="0.6"/>
|
||||
</svg>`,
|
||||
|
||||
aura: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="auRing" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7fe3ff"/>
|
||||
<stop offset="100%" stop-color="#3a7bd6"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="auGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#7fe3ff" stop-opacity="0.35"/>
|
||||
<stop offset="70%" stop-color="#3a7bd6" stop-opacity="0.12"/>
|
||||
<stop offset="100%" stop-color="#3a7bd6" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="28" fill="url(#auGlow)"/>
|
||||
<circle cx="32" cy="32" r="24" fill="none" stroke="url(#auRing)" stroke-width="3.5"/>
|
||||
<circle cx="32" cy="32" r="16" fill="none" stroke="#7fe3ff" stroke-width="1.8" stroke-dasharray="5 6" opacity="0.8"/>
|
||||
<circle cx="32" cy="32" r="8" fill="none" stroke="#a8ecff" stroke-width="1.5" opacity="0.7"/>
|
||||
<path d="M54 12 l1.8 3.4 3.4 1.8 -3.4 1.8 -1.8 3.4 -1.8 -3.4 -3.4 -1.8 3.4 -1.8 Z" fill="#a8ecff"/>
|
||||
<path d="M10 46 l1.4 2.6 2.6 1.4 -2.6 1.4 -1.4 2.6 -1.4 -2.6 -2.6 -1.4 2.6 -1.4 Z" fill="#7fe3ff" opacity="0.9"/>
|
||||
</svg>`,
|
||||
|
||||
boomerang: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bmWood" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#e8a94e"/>
|
||||
<stop offset="55%" stop-color="#b26b25"/>
|
||||
<stop offset="100%" stop-color="#7c4715"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="26" fill="#e8a94e" opacity="0.1"/>
|
||||
<circle cx="32" cy="32" r="24" fill="none" stroke="#e8a94e" stroke-width="1.5" stroke-dasharray="2 6" stroke-linecap="round" opacity="0.6"/>
|
||||
<path d="M44 13 C51 21 51 30 45 36 C39.5 41.5 31 40 27 34 L25 36.5 C28.5 45 40 47.5 47 42.5 C55 37 56 23.5 50 15.5 L44 13 Z"
|
||||
fill="url(#bmWood)" stroke="#4a2a0c" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<path d="M45 16 C50 22.5 50 29 45 34 C41 38 34.5 37 31 32.5" fill="none" stroke="#ffd98a" stroke-width="1.6" opacity="0.75"/>
|
||||
<path d="M20 44 l1.8 3.2 3.2 1.8 -3.2 1.8 -1.8 3.2 -1.8 -3.2 -3.2 -1.8 3.2 -1.8 Z" fill="#ffd98a"/>
|
||||
</svg>`,
|
||||
|
||||
trap: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="trJaw" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#c9d4e0"/>
|
||||
<stop offset="60%" stop-color="#7e93a8"/>
|
||||
<stop offset="100%" stop-color="#4a5a6c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="34" r="25" fill="#7e93a8" opacity="0.1"/>
|
||||
<path d="M10 24 A22 22 0 0 1 54 24 L54 30 L10 30 Z" fill="url(#trJaw)" stroke="#2a3440" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M10 26 A22 22 0 0 1 54 26 L54 24 L10 24 Z" fill="#eef4fa" opacity="0.6"/>
|
||||
<g fill="#eef4fa" stroke="#2a3440" stroke-width="1.4">
|
||||
<path d="M13 30 l2.4 7 -4.8 0 Z"/>
|
||||
<path d="M23 30 l2.4 8 -4.8 0 Z"/>
|
||||
<path d="M33 30 l2.4 8 -4.8 0 Z"/>
|
||||
<path d="M43 30 l2.4 8 -4.8 0 Z"/>
|
||||
<path d="M53 30 l2.4 7 -4.8 0 Z"/>
|
||||
</g>
|
||||
<path d="M20 40 h24" stroke="#2a3440" stroke-width="2" opacity="0.7"/>
|
||||
<path d="M15 12 l1.6 3 3 1.6 -3 1.6 -1.6 3 -1.6 -3 -3 -1.6 3 -1.6 Z" fill="#ffcc00"/>
|
||||
<circle cx="50" cy="13" r="1.6" fill="#eef4fa"/>
|
||||
</svg>`,
|
||||
|
||||
gascloud: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<radialGradient id="gcPuff" cx="40%" cy="35%" r="75%">
|
||||
<stop offset="0%" stop-color="#b8f28a"/>
|
||||
<stop offset="55%" stop-color="#5cae3a"/>
|
||||
<stop offset="100%" stop-color="#2f6b1c"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="38" r="25" fill="#5cae3a" opacity="0.12"/>
|
||||
<g fill="url(#gcPuff)" stroke="#1c3d10" stroke-width="2" stroke-linejoin="round">
|
||||
<circle cx="24" cy="40" r="11"/>
|
||||
<circle cx="40" cy="36" r="13"/>
|
||||
<circle cx="32" cy="28" r="10"/>
|
||||
<circle cx="44" cy="47" r="7"/>
|
||||
<circle cx="18" cy="46" r="6"/>
|
||||
</g>
|
||||
<g fill="#c9f7a0" opacity="0.55">
|
||||
<circle cx="21" cy="37" r="3.4"/>
|
||||
<circle cx="37" cy="33" r="3.8"/>
|
||||
<circle cx="30" cy="25" r="3"/>
|
||||
</g>
|
||||
<circle cx="50" cy="14" r="1.6" fill="#c9f7a0"/>
|
||||
<circle cx="12" cy="20" r="1.4" fill="#b8f28a"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
export function skillIcon(id: string): string {
|
||||
return ICONS[id] ?? '';
|
||||
}
|
||||
+87
-12
@@ -13,6 +13,8 @@ export interface SkillOffer {
|
||||
nextLevel: number;
|
||||
}
|
||||
|
||||
export const HOTBAR_SKILLS = ['chainlightning', 'teleport', 'trap', 'gascloud'];
|
||||
|
||||
export const SKILLS: SkillDef[] = [
|
||||
{
|
||||
id: 'fireball',
|
||||
@@ -20,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' : ''}`,
|
||||
},
|
||||
{
|
||||
@@ -35,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',
|
||||
@@ -93,6 +95,36 @@ export const SKILLS: SkillDef[] = [
|
||||
maxLevel: 3,
|
||||
describe: (l) => `Bewegungsgeschwindigkeit ${(7.5 + l).toFixed(1)}`,
|
||||
},
|
||||
{
|
||||
id: 'aura',
|
||||
name: 'Aura',
|
||||
type: 'passive',
|
||||
maxLevel: 5,
|
||||
describe: (l) => l === 1
|
||||
? 'Schädigt Gegner um dich herum und stößt sie zurück'
|
||||
: `Radius ${auraRadius(l).toFixed(1)} · ${auraDamage(l)} DMG/0,5s`,
|
||||
},
|
||||
{
|
||||
id: 'boomerang',
|
||||
name: 'Bumerang',
|
||||
type: 'passive',
|
||||
maxLevel: 5,
|
||||
describe: (l) => `${boomerangCount(l)}× kreisender Bumerang · ${boomerangDamage(l)} DMG`,
|
||||
},
|
||||
{
|
||||
id: 'trap',
|
||||
name: 'Falle',
|
||||
type: 'active',
|
||||
maxLevel: 5,
|
||||
describe: (l) => `Hält Gegner ${trapHold(l)}s fest (${trapDamage(l)} DMG, ${trapCooldown(l)}s CD)`,
|
||||
},
|
||||
{
|
||||
id: 'gascloud',
|
||||
name: 'Gaswolke',
|
||||
type: 'active',
|
||||
maxLevel: 5,
|
||||
describe: (l) => `${gasDamage(l)} DPS · Radius ${gasRadius(l)} · ${gasCooldown(l)}s CD`,
|
||||
},
|
||||
];
|
||||
|
||||
export function getSkill(id: string): SkillDef {
|
||||
@@ -100,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;
|
||||
}
|
||||
@@ -126,6 +161,45 @@ export function swordRange(level: number): number {
|
||||
export function swordDamage(level: number): number {
|
||||
return 50 + 25 * level;
|
||||
}
|
||||
export function auraRadius(level: number): number {
|
||||
return [3, 3.4, 3.8, 4.2, 4.6][Math.min(level, 5) - 1] ?? 3;
|
||||
}
|
||||
export function auraDamage(level: number): number {
|
||||
return [8, 12, 16, 20, 26][Math.min(level, 5) - 1] ?? 8;
|
||||
}
|
||||
export function auraPush(level: number): number {
|
||||
return [0.8, 0.9, 1.0, 1.1, 1.2][Math.min(level, 5) - 1] ?? 0.8;
|
||||
}
|
||||
export function boomerangDamage(level: number): number {
|
||||
return [12, 18, 24, 32, 40][Math.min(level, 5) - 1] ?? 12;
|
||||
}
|
||||
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 [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;
|
||||
}
|
||||
export function trapMax(level: number): number {
|
||||
return [3, 3, 4, 4, 5][Math.min(level, 5) - 1] ?? 3;
|
||||
}
|
||||
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 [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;
|
||||
}
|
||||
export function gasDuration(level: number): number {
|
||||
return [4, 4.5, 5, 5.5, 6][Math.min(level, 5) - 1] ?? 4;
|
||||
}
|
||||
export function gasCooldown(level: number): number {
|
||||
return [10, 9, 8.5, 8, 7][Math.min(level, 5) - 1] ?? 10;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
@@ -155,22 +229,23 @@ export class SkillSystem {
|
||||
}
|
||||
|
||||
xpNeeded(): number {
|
||||
return 20 + this.level * 10;
|
||||
return 30 + this.level * 15;
|
||||
}
|
||||
|
||||
xpProgress(): number {
|
||||
return this.xp / this.xpNeeded();
|
||||
}
|
||||
|
||||
addXp(amount: number): boolean {
|
||||
addXp(amount: number): number {
|
||||
this.xp += amount;
|
||||
if (!this.hasAnyUpgradeLeft()) return false;
|
||||
if (this.xp >= this.xpNeeded()) {
|
||||
if (!this.hasAnyUpgradeLeft()) return 0;
|
||||
let leveled = 0;
|
||||
while (this.xp >= this.xpNeeded()) {
|
||||
this.xp -= this.xpNeeded();
|
||||
this.level++;
|
||||
return true;
|
||||
leveled++;
|
||||
}
|
||||
return false;
|
||||
return leveled;
|
||||
}
|
||||
|
||||
hasPendingLevelUp(): boolean {
|
||||
|
||||
+2
-2
@@ -25,11 +25,11 @@ export class Slime extends Enemy {
|
||||
this.registerFadeMesh(this.bodyMesh);
|
||||
this.model.add(this.bodyMesh);
|
||||
|
||||
// Cute eyes (face toward the player: -Z)
|
||||
// Cute eyes (face toward the player: +Z)
|
||||
const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
|
||||
for (const side of [-1, 1]) {
|
||||
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat);
|
||||
eye.position.set(side * 0.18, 0.72, -0.42);
|
||||
eye.position.set(side * 0.18, 0.72, 0.42);
|
||||
this.registerFadeMesh(eye);
|
||||
this.model.add(eye);
|
||||
}
|
||||
|
||||
+27
-15
@@ -16,29 +16,41 @@ export async function initSpiderModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Wiederverwendbarer Spinnen-Modell-Builder (für Spinne UND Spinnenkönigin).
|
||||
// tint färbt die Textur mit ein (0xffffff = Original).
|
||||
export function buildSpiderModel(scale: number, tint: number): THREE.Group | null {
|
||||
if (!spiderGLTF) return null;
|
||||
const model = cloneSkeleton(spiderGLTF.scene) as THREE.Group;
|
||||
model.scale.set(scale, scale, scale);
|
||||
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
|
||||
spiderTexture.flipY = false;
|
||||
spiderTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.material = new THREE.MeshToonMaterial({ map: spiderTexture, color: tint });
|
||||
}
|
||||
});
|
||||
return model;
|
||||
}
|
||||
|
||||
// Animationen des Spinnen-Modells (leer, wenn nicht geladen)
|
||||
export function getSpiderAnimations(): THREE.AnimationClip[] {
|
||||
return spiderGLTF ? spiderGLTF.animations : [];
|
||||
}
|
||||
|
||||
export class Spider extends Enemy {
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Game } from './Game';
|
||||
import type { Enemy } from './Enemy';
|
||||
import { trapDamage, trapHold } from './Skills';
|
||||
|
||||
const TRIGGER_RADIUS = 1.1;
|
||||
const LIFETIME = 20;
|
||||
const TICK = 0.5;
|
||||
|
||||
export class Trap {
|
||||
body: THREE.Group;
|
||||
private level: number;
|
||||
private life = LIFETIME;
|
||||
private triggered = false;
|
||||
private tickTimer = 0;
|
||||
private target: Enemy | null = null;
|
||||
private jaws: THREE.Mesh[];
|
||||
private pulse = 0;
|
||||
|
||||
constructor(position: THREE.Vector3, level: number) {
|
||||
this.level = level;
|
||||
this.body = new THREE.Group();
|
||||
this.body.position.copy(position);
|
||||
this.body.position.y = 0;
|
||||
|
||||
const baseGeom = new THREE.CylinderGeometry(0.45, 0.55, 0.12, 24);
|
||||
const baseMat = new THREE.MeshToonMaterial({ color: 0x556066 });
|
||||
const base = new THREE.Mesh(baseGeom, baseMat);
|
||||
base.position.y = 0.06;
|
||||
base.castShadow = true;
|
||||
this.body.add(base);
|
||||
|
||||
const ringGeom = new THREE.TorusGeometry(0.35, 0.05, 8, 24);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffcc33,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = 0.13;
|
||||
this.body.add(ring);
|
||||
|
||||
this.jaws = [];
|
||||
for (const side of [-1, 1]) {
|
||||
const jaw = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.3, 0.06, 0.55),
|
||||
new THREE.MeshToonMaterial({ color: 0x9aa7b0 })
|
||||
);
|
||||
jaw.position.set(side * 0.2, 0.14, 0);
|
||||
jaw.castShadow = true;
|
||||
this.body.add(jaw);
|
||||
this.jaws.push(jaw);
|
||||
}
|
||||
}
|
||||
|
||||
update(dt: number, game: Game): boolean {
|
||||
if (!this.triggered) {
|
||||
this.life -= dt;
|
||||
if (this.life <= 0) return false;
|
||||
|
||||
for (const enemy of game.enemies) {
|
||||
if (enemy.dead) continue;
|
||||
const dist = enemy.body.position.distanceTo(this.body.position);
|
||||
if (dist < TRIGGER_RADIUS) {
|
||||
this.triggered = true;
|
||||
this.target = enemy;
|
||||
enemy.rootTime = trapHold(this.level);
|
||||
this.pulse = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this.tickTimer -= dt;
|
||||
if (this.pulse > 0) {
|
||||
this.pulse -= dt * 4;
|
||||
for (const jaw of this.jaws) {
|
||||
jaw.scale.setScalar(1 + Math.max(0, this.pulse) * 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.target || this.target.dead || this.target.rootTime <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.tickTimer <= 0) {
|
||||
this.tickTimer = TICK;
|
||||
this.target.takeDamage(
|
||||
(trapDamage(this.level) / trapHold(this.level)) * TICK,
|
||||
game,
|
||||
false,
|
||||
this.body.position
|
||||
);
|
||||
}
|
||||
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,38 @@
|
||||
import * as THREE from 'three';
|
||||
import { Weapon } from './Weapon';
|
||||
import { Player } from './Player';
|
||||
import { Trap } from './Trap';
|
||||
import { GasCloud } from './GasCloud';
|
||||
import { playSound } from './SoundManager';
|
||||
import { trapCooldown, trapMax, gasCooldown } from './Skills';
|
||||
|
||||
export class Trapper extends Weapon {
|
||||
skill1(groundPoint: THREE.Vector3, player: Player): void {
|
||||
const level = player.game.skillSystem.getLevel('trap');
|
||||
if (level <= 0) return;
|
||||
if (this.cooldown1 > 0) return;
|
||||
if (player.game.traps.length >= trapMax(level)) return;
|
||||
|
||||
this.COOLDOWN1_TIME = trapCooldown(level);
|
||||
this.cooldown1 = this.COOLDOWN1_TIME;
|
||||
|
||||
const trap = new Trap(groundPoint.clone(), level);
|
||||
player.game.scene.add(trap.body);
|
||||
player.game.traps.push(trap);
|
||||
playSound('spawn', 0.4);
|
||||
}
|
||||
|
||||
skill2(groundPoint: THREE.Vector3, player: Player): void {
|
||||
const level = player.game.skillSystem.getLevel('gascloud');
|
||||
if (level <= 0) return;
|
||||
if (this.cooldown2 > 0) return;
|
||||
|
||||
this.COOLDOWN2_TIME = gasCooldown(level);
|
||||
this.cooldown2 = this.COOLDOWN2_TIME;
|
||||
|
||||
const cloud = new GasCloud(groundPoint.clone(), level);
|
||||
player.game.scene.add(cloud.body);
|
||||
player.game.gasClouds.push(cloud);
|
||||
playSound('spawn', 0.4);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export class Turret extends Enemy {
|
||||
this.speed = 0;
|
||||
this.xp = 25;
|
||||
this.contactDps = 0;
|
||||
this.immovable = true;
|
||||
|
||||
this.model = new THREE.Group();
|
||||
this.fadeOutModel = this.model;
|
||||
|
||||
@@ -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