Compare commits

...
18 Commits
Author SHA1 Message Date
nico 8065104d8a Rework necromancer, archer, shaman and golem models with procedural animations 2026-08-21 11:25:50 +02:00
nico b05835df74 Add model viewer with automated screenshot script 2026-08-21 11:25:50 +02:00
nico d1ba3fc9c3 Rework dragon model with bat wings, detailed head and curved tail 2026-08-20 18:12:54 +02:00
nico 1f311d3208 Add maze mini-boss and rebalance progression 2026-08-20 15:19:53 +02:00
nico e6a370f952 Add six new enemies and spawn enemies within view 2026-08-20 10:54:51 +02:00
nico 2bffefb02c Add Spider Queen, Frost Giant, Necromancer and Dragon bosses 2026-08-20 10:21:08 +02:00
nico 151b07cb4e Add changelog panel to main menu 2026-08-19 16:07:46 +02:00
nico 1f67aaa982 Add Minotaurus boss with front armor, dash stun and jumpable axe swing 2026-08-19 14:41:02 +02:00
nico 7d41f6db6c Add boss hit feedback with flash and floating damage numbers 2026-08-19 14:05:24 +02:00
nico 496c4af50e Add Krake boss with ink, pull marks and sweep tentacles plus boss mode 2026-08-19 13:59:39 +02:00
nico 1a4cccda6e Add Golem boss wave 10 with clear-arena requirement 2026-08-19 10:51:05 +02:00
nico 5ceb37fdb4 Redesign boomerang shape as thin 120-degree crescent 2026-08-19 10:28:09 +02:00
nico 18d8654d9d Add player-following light and darken dungeon surroundings 2026-08-19 09:52:51 +02:00
nico 110a8596a5 Document game architecture and update README 2026-08-19 09:46:22 +02:00
nico 64d473de6d Add aura, boomerang, trap and gas cloud skills with hotbar and ESC pause 2026-08-19 09:46:22 +02:00
nico fd57ca9740 Fix enemy facing direction and add player damage feedback 2026-08-19 07:42:13 +02:00
nicoandQwen-Coder 3fde0925c5 Add back-mounted shield model tied to shield skill
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-18 16:36:16 +02:00
nicoandQwen-Coder 9e3e5c70e0 Add skill icons to level-up offer cards
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-18 13:26:41 +02:00
52 changed files with 9494 additions and 190 deletions
+69
View File
@@ -5,3 +5,72 @@
- Git-Commits nur auf explizite Anforderung des Users. Niemals eigenständig committen. - 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. - 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). - 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 1024 m um den Spieler; stationäre Gegner (`immovable`), die >8 s außerhalb des Bildschirms sind (NDC-Projektion `isOnScreen`), werden wieder in Sicht teleportiert (`updateOffscreenReposition`).
- **Labyrinth-Zwischenboss**: `Game.MAZE_WAVES` (5, 15, 25, …) ersetzt die Welle komplett (`startMazeWave`/`updateMaze`/`endMazeWave`); Zeitlimit (75 s 5 s/Level, Min 50 s) → bei Ablauf wird das Labyrinth neu generiert und der Spieler zur Startecke gesetzt; Gnome nur mit Sichtlinie verletzbar (`hasLineOfSight`); `mazeActive` pausiert Spawn-Logik und Off-Screen-Reposition; Aufräumen über `disposeMaze()` in `resetWorld`; `#maze-arrow`-Overlay zeigt zum Ziel.
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung), `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf) und `rootTime` (Netz-Fessel: keine Bewegung, Angriffe bleiben möglich). Alles wird in `Player.reset()` zurückgesetzt.
- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig).
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
+18 -8
View File
@@ -1,17 +1,27 @@
# ── Wackelpeter container build/deploy ────────────────────────────────────── # ── Wackelpeter build/deploy (podman) ───────────────────────────────────────
# Uses Podman (docker-compose optional). Targets: # Targets:
# make build Build image # make Build web frontend locally (npm run build)
# make push Push image to Gitea registry # make web Same as `make`
# make up/down Start/stop via docker-compose # make dev Start Vite dev server
# make run Start via podman directly # make build Build container image (podman)
# make clean Remove local image # 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 REGISTRY ?= gitea.korbel.network
IMAGE ?= $(REGISTRY)/nico/wackelpeter IMAGE ?= $(REGISTRY)/nico/wackelpeter
TAG ?= latest TAG ?= latest
COMPOSE ?= docker-compose 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: build:
podman build -t $(IMAGE):$(TAG) . podman build -t $(IMAGE):$(TAG) .
+28 -5
View File
@@ -50,14 +50,37 @@ npm run dev
- Three.js + TypeScript + Vite - Three.js + TypeScript + Vite
- Steuerung: WASD bewegen, Space springen, Maus zielen, - Steuerung: WASD bewegen, Space springen, Maus zielen,
Linksklick Schwert, Rechtsklick Feuerball (per Skill), Linksklick Schwert, Rechtsklick Feuerball (per Skill),
Q Kettenblitz (per Skill), E Teleport (per Skill), H Hitboxen (Debug) Q/E/14 Skills (Hotbar, siehe unten), ESC Pause, 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ü) ### Skillsystem
steuert die Angebote, "Nochmal spielen" behält den Seed bei.
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, 14
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 ## Verzeichnisse
- `Original/` — Original-Build (Blobtest.exe, JME3-Libs) - `Original/` — Original-Build (Blobtest.exe, JME3-Libs)
- `extracted/` — Aus dem Original extrahierte Assets - `extracted/` — Aus dem Original extrahierte Assets
- `decompiled/` — Decompilierter Java-Source - `decompiled/` — Decompilierter Java-Source
- `web/` — Browser-Version - `web/` — Browser-Version (Code-Struktur: siehe `AGENTS.md`)
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Visuelle Modell-Checks fuer Wackelpeter (Bazzite/KDE Wayland).
#
# Öffnet den Modell-Viewer (web/viewer.html) in einem eigenen Firefox-Fenster,
# macht Turntable-Screenshots und räumt danach auf (Fenster zu, Konsole wieder
# sichtbar, eigener Dev-Server gestoppt). Die Pfade der Crops gehen auf stdout.
#
# Usage: scripts/model_shot.sh <modell-id> [shots=4] [intervall-sek=1.5] [still]
# modell-id: spider ghost frog bat bee slime brute crab turret shaman archer
# bomber wisp turtle spore golem krake minotaurus spiderqueen
# frostgiant necromancer dragon gnome mazeguard
# still: beliebiges 4. Argument -> statische Pose (&still=1)
#
# Voraussetzung: maximiertes Firefox-Fenster auf 3440x1440 (feste Crop-Box).
set -u
MODEL="${1:?Modell-ID erforderlich, z.B. dragon}"
SHOTS="${2:-4}"
INTERVAL="${3:-1.5}"
STILL="${4:-}"
PORT=3100
cd "$(dirname "$0")/.." || exit 1
URL="http://localhost:$PORT/viewer.html?m=$MODEL"
[ -n "$STILL" ] && URL="$URL&still=1"
# --- eigener Dev-Server auf 3100 (nicht den Nutzer-Server auf 3000 anfassen) ---
started_server=0
if ! ss -tln | grep -q ":$PORT "; then
(cd web && npx vite --port "$PORT" --strictPort > /tmp/viewer-vite.log 2>&1 &)
started_server=1
for _ in $(seq 1 40); do
ss -tln | grep -q ":$PORT " && break
sleep 0.5
done
fi
# --- KWin-Scripte (Fensterverwaltung ohne Fokus-Raten) ---
kwin_run() {
local name="$1" file="/tmp/qwen_kwin_$1.js"
printf '%s\n' "$2" > "$file"
qdbus org.kde.KWin /Scripting unloadScript "$name" > /dev/null 2>&1
qdbus org.kde.KWin /Scripting loadScript "$file" "$name" > /dev/null
qdbus org.kde.KWin /Scripting start > /dev/null
}
kwin_run viewer_min 'for (const c of workspace.windowList()) { if (c.resourceClass.toLowerCase().includes("konsole")) c.minimized = true; }'
flatpak run org.mozilla.firefox --new-window "$URL" > /dev/null 2>&1 &
sleep 6
# --- Screenshots + feste Crop-Box (Mitte des maximierten Fensters) ---
for i in $(seq 1 "$SHOTS"); do
# Viewer-Fenster vor jedem Shot nach vorn holen (sonst kapert ein anderes
# Fenster den Desktop-Screenshot)
kwin_run viewer_raise 'for (const c of workspace.windowList()) { if (c.caption.includes("Modell-Viewer")) { c.minimized = false; workspace.activeWindow = c; } }'
sleep 0.4
spectacle -b -n -o "/tmp/modelshot_$i.png"
magick "/tmp/modelshot_$i.png" -crop 2200x1200+600+100 +repage "/tmp/modelshot_${i}_crop.png"
echo "/tmp/modelshot_${i}_crop.png"
[ "$i" -lt "$SHOTS" ] && sleep "$INTERVAL"
done
# --- Aufräumen: Konsole einblenden, Viewer-Fenster schließen ---
kwin_run viewer_restore 'for (const c of workspace.windowList()) { const cls = c.resourceClass.toLowerCase(); if (cls.includes("konsole")) c.minimized = false; if (c.caption.includes("Modell-Viewer")) c.closeWindow(); }'
if [ "$started_server" = 1 ]; then
PID=$(ss -tlnp | grep ":$PORT " | grep -oP 'pid=\K[0-9]+' | head -1)
[ -n "${PID:-}" ] && kill "$PID" 2> /dev/null
fi
+173 -9
View File
@@ -15,6 +15,56 @@
position: fixed; top: 0; left: 0; width: 100%; height: 100%; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; font-family: monospace; z-index: 10; 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 { #health-bar-bg {
position: absolute; top: 16px; left: 16px; width: 250px; height: 20px; position: absolute; top: 16px; left: 16px; width: 250px; height: 20px;
background: rgba(0,0,0,0.6); border: 2px solid #555; background: rgba(0,0,0,0.6); border: 2px solid #555;
@@ -54,6 +104,8 @@
#sword-cooldown { bottom: 48px; } #sword-cooldown { bottom: 48px; }
#teleport-cooldown { bottom: 24px; } #teleport-cooldown { bottom: 24px; }
#lightning-cooldown { bottom: 96px; } #lightning-cooldown { bottom: 96px; }
#trap-cooldown { bottom: 120px; }
#gas-cooldown { bottom: 144px; }
#wave-bar-container { #wave-bar-container {
position: absolute; top: 16px; right: 16px; width: 250px; height: 16px; 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; text-align: center; line-height: 16px; color: #fff; font-size: 11px;
font-family: monospace; font-family: monospace;
} }
#maze-arrow {
position: absolute;
width: 40px; height: 40px;
color: #ffcc44; font-size: 30px; line-height: 40px; text-align: center;
text-shadow: 0 0 8px #ff8800, 0 0 2px #000;
font-family: monospace;
pointer-events: none; user-select: none;
opacity: 0; visibility: hidden;
}
#game-over { #game-over {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
font-size: 100px; color: #fff; font-family: monospace; opacity: 0; font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
@@ -77,7 +138,8 @@
#game-over-panel { #game-over-panel {
position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%); position: absolute; top: 62%; left: 50%; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: 12px; 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; font-family: monospace;
} }
#game-over-stats { #game-over-stats {
@@ -113,6 +175,28 @@
color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace; color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace;
text-align: center; 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 { #seed-input {
background: rgba(0,0,0,0.7); border: 2px solid #555; color: #ffcc00; background: rgba(0,0,0,0.7); border: 2px solid #555; color: #ffcc00;
font-family: monospace; font-size: 16px; padding: 6px 10px; width: 180px; 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%; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center; 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; 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 { #level-up h2 {
color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000; color: #ffcc00; font-size: 44px; text-shadow: 3px 3px 6px #000;
@@ -167,7 +260,7 @@
display: flex; gap: 20px; display: flex; gap: 20px;
} }
.offer-card { .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; border: 2px solid #888; padding: 14px; cursor: pointer;
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 10px;
transition: transform 0.1s ease, border-color 0.1s ease; transition: transform 0.1s ease, border-color 0.1s ease;
@@ -177,8 +270,17 @@
border-color: #ffcc00; background: rgba(60,50,20,0.95); 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); 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 { .offer-title {
color: #ffcc00; font-size: 18px; font-weight: bold; color: #ffcc00; font-size: 18px; font-weight: bold; text-align: center;
} }
.offer-desc { .offer-desc {
color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.5;
@@ -190,6 +292,14 @@
</head> </head>
<body> <body>
<div id="ui-overlay"> <div id="ui-overlay">
<div id="damage-vignette"></div>
<div id="low-hp-vignette"></div>
<div id="ink-vignette"></div>
<div id="maze-arrow">&#9650;</div>
<div id="boss-bar">
<div id="boss-bar-label">BOSS</div>
<div id="boss-bar-bg"><div id="boss-bar-fill"></div></div>
</div>
<div id="health-bar-bg"></div> <div id="health-bar-bg"></div>
<div id="health-bar"></div> <div id="health-bar"></div>
<div id="health-text">100</div> <div id="health-text">100</div>
@@ -226,6 +336,16 @@
<div class="cooldown-fill"></div> <div class="cooldown-fill"></div>
<div class="cooldown-label">Chain Lightning</div> <div class="cooldown-label">Chain Lightning</div>
</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-container">
<div id="wave-bar-bg"></div> <div id="wave-bar-bg"></div>
@@ -241,19 +361,21 @@
</div> </div>
<div id="level-up"> <div id="level-up">
<h2>Level Up!</h2> <h2>Level Up!</h2> <div id="offer-row">
<div id="offer-row">
<div id="offer-0" class="offer-card"> <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-title-0" class="offer-title"></div>
<div id="offer-desc-0" class="offer-desc"></div> <div id="offer-desc-0" class="offer-desc"></div>
<div class="offer-hint">[1] auswählen</div> <div class="offer-hint">[1] auswählen</div>
</div> </div>
<div id="offer-1" class="offer-card"> <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-title-1" class="offer-title"></div>
<div id="offer-desc-1" class="offer-desc"></div> <div id="offer-desc-1" class="offer-desc"></div>
<div class="offer-hint">[2] auswählen</div> <div class="offer-hint">[2] auswählen</div>
</div> </div>
<div id="offer-2" class="offer-card"> <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-title-2" class="offer-title"></div>
<div id="offer-desc-2" class="offer-desc"></div> <div id="offer-desc-2" class="offer-desc"></div>
<div class="offer-hint">[3] auswählen</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> <button id="btn-confirm" class="menu-button" disabled>Bestätigen (Enter)</button>
</div> </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"> <div id="main-menu">
<h1>WACKELPETER</h1> <h1>WACKELPETER</h1>
<div class="subtitle">Ein Blob kämpft ums Überleben</div> <div class="subtitle">Ein Blob kämpft ums Überleben</div>
<div class="menu-controls"> <div class="menu-controls">
WASD: Bewegen · Space: Springen · Maus: Zielen<br> WASD: Bewegen · Space: Springen · Maus: Zielen · ESC: Pause<br>
Linksklick: Schwert · Rechtsklick: Feuerball (per Skill) · Q: Kettenblitz (per Skill) · E: Teleport (per Skill) Linksklick: Schwert · Rechtsklick: Feuerball (per Skill)<br>
Q, E, 14: Skills (werden nach Erwerb belegt)
</div> </div>
<input id="seed-input" type="text" placeholder="Seed (leer = zufällig)" /> <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-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>
<div id="instructions"> <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>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+388
View File
@@ -0,0 +1,388 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const IDEAL_MIN = 8;
const IDEAL_MAX = 13;
const SHOOT_RANGE = 18;
const SHOOT_COOLDOWN = 3;
const TELEGRAPH_TIME = 0.6;
const ARROW_DAMAGE = 18;
const HIT_FLASH_TIME = 0.14;
// Bogenschütze: hält Distanz und schießt Pfeile mit Boden-Telegraf (ausweichbar)
export class Archer extends Enemy {
private model: THREE.Group;
private boneMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private bowGroup!: THREE.Group;
private legL!: THREE.Group;
private legR!: THREE.Group;
private walkPhase = 0;
private walkAmp = 0;
private handL!: THREE.Mesh;
private arrowNock!: THREE.Mesh;
private draw = 0; // 0 = Bogen entspannt, 1 = voll ausgezogen
private recoil = 0; // klingt nach dem Abschuss ab
private shootTimer = 1 + Math.random();
private telegraphs: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; timer: number }[] = [];
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor() {
super();
this.health = 55;
this.speed = 4.2;
this.xp = 22;
this.contactDps = 15;
this.contactRadius = 1.5;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.boneMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x8a7a5a });
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff4422 });
// --- Skelett (Blickrichtung +Z) ---
// Becken + Gürtel
const pelvis = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), this.boneMat);
pelvis.scale.set(1.2, 0.7, 0.9);
pelvis.position.y = 0.78;
this.registerFadeMesh(pelvis);
this.model.add(pelvis);
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.17, 0.03, 6, 12), this.darkMat);
belt.rotation.x = Math.PI / 2;
belt.position.y = 0.82;
this.registerFadeMesh(belt);
this.model.add(belt);
// Wirbelsäule + Rippenbögen
const spine = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.045, 0.55, 6), this.boneMat);
spine.position.y = 1.08;
this.registerFadeMesh(spine);
this.model.add(spine);
for (const [y, r] of [[1.02, 0.2], [1.14, 0.23], [1.26, 0.19]] as const) {
const rib = new THREE.Mesh(new THREE.TorusGeometry(r, 0.025, 6, 12), this.boneMat);
rib.rotation.x = Math.PI / 2;
rib.scale.set(1, 1, 0.8);
rib.position.y = y;
this.registerFadeMesh(rib);
this.model.add(rib);
}
// Schädel mit Kiefer, Augenhöhlen und roten Augen (+Z)
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.22, 12, 10), this.boneMat);
skull.scale.set(0.95, 1.05, 1);
skull.position.y = 1.52;
skull.castShadow = true;
this.registerFadeMesh(skull);
this.model.add(skull);
const jaw = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.09, 0.16), this.boneMat);
jaw.position.set(0, 1.38, 0.1);
this.registerFadeMesh(jaw);
this.model.add(jaw);
for (const side of [-1, 1]) {
const socket = new THREE.Mesh(new THREE.SphereGeometry(0.055, 8, 6), this.darkMat);
socket.position.set(side * 0.09, 1.55, 0.17);
this.registerFadeMesh(socket);
this.model.add(socket);
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 6, 5), eyeMat);
eye.position.set(side * 0.09, 1.55, 0.2);
this.model.add(eye);
}
// Schultern + Arme (rechts hält den Bogen, links zieht die Sehne)
for (const side of [-1, 1]) {
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), this.boneMat);
shoulder.position.set(side * 0.28, 1.34, 0);
this.registerFadeMesh(shoulder);
this.model.add(shoulder);
}
const upperArmR = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.32, 6), this.boneMat);
upperArmR.position.set(0.34, 1.26, 0.08);
upperArmR.rotation.z = -1.0;
this.registerFadeMesh(upperArmR);
this.model.add(upperArmR);
const foreArmR = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.3, 6), this.boneMat);
foreArmR.position.set(0.4, 1.18, 0.25);
foreArmR.rotation.x = -1.2;
this.registerFadeMesh(foreArmR);
this.model.add(foreArmR);
const handR = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), this.boneMat);
handR.position.set(0.42, 1.15, 0.4);
this.registerFadeMesh(handR);
this.model.add(handR);
const upperArmL = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.32, 6), this.boneMat);
upperArmL.position.set(-0.34, 1.24, 0.05);
upperArmL.rotation.z = 1.1;
this.registerFadeMesh(upperArmL);
this.model.add(upperArmL);
const foreArmL = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.3, 6), this.boneMat);
foreArmL.position.set(-0.24, 1.25, 0.15);
foreArmL.rotation.x = -1.6;
foreArmL.rotation.z = 0.6;
this.registerFadeMesh(foreArmL);
this.model.add(foreArmL);
this.handL = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), this.boneMat);
this.handL.position.set(-0.1, 1.3, 0.28);
this.registerFadeMesh(this.handL);
this.model.add(this.handL);
// Beine mit Kniegelenken und Füßen (Gruppen mit Hüft-Pivot für Laufanimation)
for (const side of [-1, 1]) {
const leg = new THREE.Group();
leg.position.set(side * 0.15, 0.72, 0);
this.model.add(leg);
const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.07, 0.4, 6), this.boneMat);
thigh.position.y = -0.17;
this.registerFadeMesh(thigh);
leg.add(thigh);
const knee = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), this.boneMat);
knee.position.y = -0.37;
this.registerFadeMesh(knee);
leg.add(knee);
const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 0.35, 6), this.boneMat);
shin.position.y = -0.54;
this.registerFadeMesh(shin);
leg.add(shin);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.06, 0.22), this.boneMat);
foot.position.set(0, -0.68, 0.06);
this.registerFadeMesh(foot);
leg.add(foot);
if (side < 0) {
this.legL = leg;
} else {
this.legR = leg;
}
}
// Zerfetzter Umhang auf dem Rücken
const cape = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.75, 8, 1, true), this.darkMat);
cape.position.set(0, 1.0, -0.2);
cape.scale.set(1.1, 1, 0.5);
this.registerFadeMesh(cape);
this.model.add(cape);
for (const [x, z] of [[-0.15, -0.28], [0, -0.32], [0.15, -0.28]] as const) {
const tatter = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.25, 4), this.darkMat);
tatter.rotation.x = Math.PI;
tatter.position.set(x, 0.62, z);
this.registerFadeMesh(tatter);
this.model.add(tatter);
}
// Köcher mit Pfeilen am Rücken
const quiver = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.11, 0.5, 8), this.darkMat);
quiver.position.set(0.12, 1.15, -0.28);
quiver.rotation.x = 0.5;
this.registerFadeMesh(quiver);
this.model.add(quiver);
for (const dx of [-0.03, 0.02, 0.06]) {
const qArrow = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.28, 5), this.boneMat);
qArrow.position.set(0.12 + dx, 1.42, -0.4);
qArrow.rotation.x = 0.5;
this.registerFadeMesh(qArrow);
this.model.add(qArrow);
}
// Bogen (hängt an Gruppe, wird beim Schuss angehoben):
// Halbkreis-Bogen mit Sehne und eingelegtem Pfeil
this.bowGroup = new THREE.Group();
this.bowGroup.position.set(0.42, 1.15, 0.45);
this.model.add(this.bowGroup);
const bowOrient = new THREE.Group();
bowOrient.rotation.y = -Math.PI / 2; // Bogenebene in Blickrichtung
this.bowGroup.add(bowOrient);
const bow = new THREE.Mesh(new THREE.TorusGeometry(0.4, 0.028, 6, 16, Math.PI), this.darkMat);
bow.rotation.z = -Math.PI / 2; // Bogenbauch zeigt nach +Z
this.registerFadeMesh(bow);
bowOrient.add(bow);
const string = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.8, 5), this.darkMat);
this.registerFadeMesh(string);
bowOrient.add(string);
// Pfeil sitzt auf Sehne und Bogen: Schaft entlang lokaler X-Achse
// (zeigt nach Welt-+Z), Nocke an der Sehne
this.arrowNock = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, 0.75, 5), this.boneMat);
this.arrowNock.rotation.z = -Math.PI / 2;
this.arrowNock.position.x = 0.28;
this.registerFadeMesh(this.arrowNock);
bowOrient.add(this.arrowNock);
const arrowHead = new THREE.Mesh(new THREE.ConeGeometry(0.035, 0.1, 6), this.darkMat);
arrowHead.position.y = 0.42;
this.registerFadeMesh(arrowHead);
this.arrowNock.add(arrowHead);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.9, sink: 0.4 });
}
update(dt: number, game: Game) {
this.time += dt;
this.game = game;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.boneMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
} else {
this.boneMat.color.set(0xe8dcc0);
this.darkMat.color.set(0x8a7a5a);
}
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
let moved = false;
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
// Distanz halten: weg, wenn zu nah; hin, wenn zu weit; sonst seitlich
let move = new THREE.Vector3();
if (dist < IDEAL_MIN) {
move.copy(dir).multiplyScalar(-1);
} else if (dist > IDEAL_MAX) {
move.copy(dir);
} else {
move.set(-dir.z, 0, dir.x).multiplyScalar(Math.sin(this.time * 2) * 0.6);
}
if (this.knockback > 0) {
move = dir.clone().multiplyScalar(-1);
this.knockback -= dt;
}
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar(this.speed);
this.body.position.x += move.x * dt;
this.body.position.z += move.z * dt;
game.clampToArena(this.body.position);
moved = true;
}
}
// Laufanimation: Beine gegengleich schwingen, Amplitude weich ein-/ausblenden
this.walkAmp += ((moved ? 0.55 : 0) - this.walkAmp) * Math.min(1, dt * 8);
if (this.walkAmp > 0.01) {
this.walkPhase += dt * 11;
}
const swing = Math.sin(this.walkPhase) * this.walkAmp;
this.legL.rotation.x = swing;
this.legR.rotation.x = -swing;
this.model.position.y = Math.abs(Math.cos(this.walkPhase)) * this.walkAmp * 0.12;
// Schuss mit Telegraf
this.shootTimer -= dt;
if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) {
this.shootTimer = SHOOT_COOLDOWN + Math.random() * 0.5;
this.startShot(game);
}
// Telegrafen ablaufen lassen -> Pfeil los
for (let i = this.telegraphs.length - 1; i >= 0; i--) {
const t = this.telegraphs[i];
t.timer -= dt;
if (t.timer <= 0) {
this.fireArrow(game, t.mesh.position);
game.scene.remove(t.mesh);
t.mesh.geometry.dispose();
t.mat.dispose();
this.telegraphs.splice(i, 1);
} else {
t.mat.opacity = 0.25 + 0.3 * Math.abs(Math.sin(this.time * 10));
}
}
// Schießanimation: während des Telegrafs Bogen heben und Sehne spannen,
// beim Abschuss Recoil-Kick und schnelles Entspannen
const aiming = this.telegraphs.length > 0;
const drawRate = aiming ? 6 : this.recoil > 0 ? 25 : 10;
this.draw += ((aiming ? 1 : 0) - this.draw) * Math.min(1, dt * drawRate);
this.recoil = Math.max(0, this.recoil - dt);
const aim = this.draw;
this.bowGroup.position.set(
0.42 - aim * 0.17,
1.15 + aim * 0.2,
0.45 + aim * 0.1
);
this.bowGroup.rotation.x = -aim * 0.15;
this.bowGroup.rotation.z =
Math.sin(this.time * 2) * 0.1 * (1 - aim) - this.recoil * 2;
this.handL.position.z = 0.28 - aim * 0.16;
this.arrowNock.position.x = 0.28 - aim * 0.14;
}
private startShot(game: Game) {
// Roter Telegraf an der aktuellen Spielerposition (Zielpunkt des Pfeils)
const mat = new THREE.MeshBasicMaterial({
color: 0xff3344,
transparent: true,
opacity: 0.4,
side: THREE.DoubleSide,
depthWrite: false,
});
const ring = new THREE.Mesh(new THREE.RingGeometry(0.5, 0.8, 24), mat);
ring.rotation.x = -Math.PI / 2;
const pos = game.player.body.position;
ring.position.set(pos.x, 0.06, pos.z);
game.scene.add(ring);
this.telegraphs.push({ mesh: ring, mat, timer: TELEGRAPH_TIME });
playSound('spawn', 0.4);
}
private fireArrow(game: Game, targetPos: THREE.Vector3) {
if (game.player.health <= 0) return;
const from = this.body.position.clone();
from.y += 1.2;
const target = targetPos.clone();
target.y += 0.6;
game.spawnEnemyProjectile(from, target, ARROW_DAMAGE);
// Recoil-Kick, klingt in update() ab
this.recoil = 0.25;
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(game: Game) {
this.cleanupTelegraphs(game);
this.beginFadeDeath();
}
override dispose() {
super.dispose();
if (this.game) this.cleanupTelegraphs(this.game);
}
private game: Game | null = null;
private cleanupTelegraphs(game: Game) {
for (const t of this.telegraphs) {
game.scene.remove(t.mesh);
t.mesh.geometry.dispose();
t.mat.dispose();
}
this.telegraphs = [];
}
}
+62
View File
@@ -1,6 +1,35 @@
import * as THREE from 'three'; import * as THREE from 'three';
interface Theme {
floorLight: number;
floorDark: number;
wall: number;
fog: number;
}
// 8 Stufen: Start + eine pro besiegten Boss (Golem, Krake, Minotaurus,
// Spinnenkönigin, Frostriese, Erz-Nekromant, Drache)
const THEMES: Theme[] = [
{ floorLight: 0x668866, floorDark: 0x557755, wall: 0x776677, fog: 0x0d1117 }, // Start (Grün)
{ floorLight: 0x8a7a55, floorDark: 0x7a6a48, wall: 0x776055, fog: 0x141210 }, // Golem (Erdig)
{ floorLight: 0x3a4a66, floorDark: 0x2f3d55, wall: 0x44506a, fog: 0x0a1220 }, // Krake (Tinte)
{ floorLight: 0x8a4a3a, floorDark: 0x6e3a2c, wall: 0x7a4438, fog: 0x180d0b }, // Minotaurus (Asche)
{ floorLight: 0x4a7a4a, floorDark: 0x3a643a, wall: 0x4a6a4a, fog: 0x0a160d }, // Spinnenkönigin (Gift)
{ floorLight: 0x88bbcc, floorDark: 0x6a9ab0, wall: 0x7aa0b5, fog: 0x0d1a24 }, // Frostriese (Eis)
{ floorLight: 0x6a4a8a, floorDark: 0x543a70, wall: 0x5a3f74, fog: 0x140a20 }, // Erz-Nekromant (Lila)
{ floorLight: 0xaa6a33, floorDark: 0x8a5428, wall: 0x8f5c34, fog: 0x1e0f06 }, // Drache (Glut)
];
const LERP_SPEED = 2;
export class Arena extends THREE.Group { export class Arena extends THREE.Group {
private lightMats: THREE.MeshStandardMaterial[] = [];
private darkMats: THREE.MeshStandardMaterial[] = [];
private wallMats: THREE.MeshToonMaterial[] = [];
private targets:
| { floorLight: THREE.Color; floorDark: THREE.Color; wall: THREE.Color }
| null = null;
constructor() { constructor() {
super(); super();
@@ -23,6 +52,7 @@ export class Arena extends THREE.Group {
tile.position.set(x, -0.15, z); tile.position.set(x, -0.15, z);
tile.receiveShadow = true; tile.receiveShadow = true;
this.add(tile); this.add(tile);
(isDark ? this.darkMats : this.lightMats).push(tileMat);
} }
} }
@@ -38,6 +68,12 @@ export class Arena extends THREE.Group {
this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat); this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat); this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
} }
for (const wall of this.children) {
const mesh = wall as THREE.Mesh;
if (mesh.material instanceof THREE.MeshToonMaterial) {
this.wallMats.push(mesh.material);
}
}
} }
private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) { private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) {
@@ -47,4 +83,30 @@ export class Arena extends THREE.Group {
wall.receiveShadow = true; wall.receiveShadow = true;
this.add(wall); this.add(wall);
} }
// Setzt die Ziel-Farben für eine Theme-Stufe und gibt die Nebel-Farbe zurück
applyTheme(stage: number): THREE.Color {
const t = THEMES[Math.min(Math.max(stage, 0), THEMES.length - 1)];
this.targets = {
floorLight: new THREE.Color(t.floorLight),
floorDark: new THREE.Color(t.floorDark),
wall: new THREE.Color(t.wall),
};
return new THREE.Color(t.fog);
}
// Lerpt die aktuellen Farben weich in Richtung Ziel-Theme
update(dt: number) {
if (!this.targets) return;
const t = Math.min(1, dt * LERP_SPEED);
for (const mat of this.lightMats) {
mat.color.lerp(this.targets.floorLight, t);
}
for (const mat of this.darkMats) {
mat.color.lerp(this.targets.floorDark, t);
}
for (const mat of this.wallMats) {
mat.color.lerp(this.targets.wall, t);
}
}
} }
+101
View File
@@ -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
View File
@@ -28,15 +28,15 @@ export class Bat extends Enemy {
this.registerFadeMesh(bodyMesh); this.registerFadeMesh(bodyMesh);
this.model.add(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); 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.registerFadeMesh(head);
this.model.add(head); this.model.add(head);
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.18, 6), furMat); 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.registerFadeMesh(ear);
this.model.add(ear); this.model.add(ear);
} }
@@ -45,7 +45,7 @@ export class Bat extends Enemy {
const eyeMat = new THREE.MeshToonMaterial({ color: 0xff2222 }); const eyeMat = new THREE.MeshToonMaterial({ color: 0xff2222 });
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 8), eyeMat); 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.registerFadeMesh(eye);
this.model.add(eye); this.model.add(eye);
} }
+206
View File
@@ -0,0 +1,206 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const EXPLODE_DIST = 2.2;
const EXPLODE_RADIUS = 4.5;
const EXPLODE_DAMAGE = 30;
const FLASH_TIME = 0.35;
// Goblin-Bomber: rast auf den Spieler zu und explodiert in seiner Nähe
export class Bomber extends Enemy {
private model: THREE.Group;
private skinMat!: THREE.MeshToonMaterial;
private clothMat!: THREE.MeshToonMaterial;
private fuseMat!: THREE.MeshBasicMaterial;
private fuse!: THREE.Mesh;
private time = Math.random() * Math.PI * 2;
private exploding = false;
private flashTime = 0;
private flashRing!: THREE.Mesh;
private flashRingMat!: THREE.MeshBasicMaterial;
constructor() {
super();
this.health = 45;
this.speed = 6.5;
this.xp = 18;
this.contactDps = 0;
this.contactRadius = 0;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.skinMat = new THREE.MeshToonMaterial({ color: 0x55aa44 });
this.clothMat = new THREE.MeshToonMaterial({ color: 0xaa4433 });
this.fuseMat = new THREE.MeshBasicMaterial({ color: 0xffaa33 });
// Körper
const body = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 10), this.skinMat);
body.position.y = 0.65;
body.scale.set(1, 1.1, 1);
body.castShadow = true;
this.registerFadeMesh(body);
this.model.add(body);
// Stoff-Höschen
const cloth = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.35, 8, 1, true), this.clothMat);
cloth.position.y = 0.25;
cloth.rotation.x = Math.PI;
this.registerFadeMesh(cloth);
this.model.add(cloth);
// Kopf
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), this.skinMat);
head.position.y = 1.1;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Spitze Ohren
for (const side of [-1, 1]) {
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.3, 6), this.skinMat);
ear.position.set(side * 0.22, 1.32, 0.05);
ear.rotation.z = -side * 0.4;
this.registerFadeMesh(ear);
this.model.add(ear);
}
// Augen (+Z)
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.06, 6, 5), eyeMat);
eye.position.set(side * 0.1, 1.15, 0.26);
this.model.add(eye);
}
// Beine
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.4, 6), this.skinMat);
leg.position.set(side * 0.15, 0.2, 0);
this.registerFadeMesh(leg);
this.model.add(leg);
}
// Zündschnur mit Glut
const fuseGeom = new THREE.CylinderGeometry(0.03, 0.03, 0.3, 6);
this.fuse = new THREE.Mesh(fuseGeom, this.clothMat);
this.fuse.position.set(0, 1.45, 0);
this.fuse.rotation.z = 0.5;
this.registerFadeMesh(this.fuse);
this.model.add(this.fuse);
const spark = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 5), this.fuseMat);
spark.position.set(0.1, 1.6, 0);
this.model.add(spark);
// Explosions-Ring (Kind des Body, wird in updateAnimation animiert)
this.flashRingMat = new THREE.MeshBasicMaterial({
color: 0xff8833,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.flashRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), this.flashRingMat);
this.flashRing.rotation.x = -Math.PI / 2;
this.flashRing.position.y = 0.07;
this.flashRing.visible = false;
this.body.add(this.flashRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.5, shrink: 0.8 });
if (this.exploding && this.flashTime > 0) {
this.flashTime -= dt;
const t = 1 - Math.max(0, this.flashTime) / FLASH_TIME;
this.flashRing.visible = true;
this.flashRing.scale.setScalar(0.5 + t * 8);
this.flashRingMat.opacity = 0.8 * (1 - t);
if (this.flashTime <= 0) {
this.flashRing.visible = false;
this.flashRingMat.opacity = 0;
}
}
}
update(dt: number, game: Game) {
this.time += dt;
// Lauf-Animation: Wippen + Beine
this.body.position.y = 0.12 + Math.abs(Math.sin(this.time * 12)) * 0.06;
// Zündschnur glüht schneller, je näher der Gegner ist
const distToPlayer = Math.hypot(
game.player.body.position.x - this.body.position.x,
game.player.body.position.z - this.body.position.z
);
const fusePulse = Math.min(1, 1 - distToPlayer / 12);
this.fuseMat.color.setHSL(0.08, 1, 0.4 + fusePulse * 0.5);
this.fuse.scale.y = 0.8 + Math.abs(Math.sin(this.time * (4 + fusePulse * 16))) * 0.5;
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(this.speed);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-this.speed);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Explosion bei Annäherung
if (dist <= EXPLODE_DIST && game.player.health > 0 && !this.dead) {
this.explode(game);
}
}
private explode(game: Game) {
this.exploding = true;
this.flashTime = FLASH_TIME;
this.flashRing.scale.setScalar(0.5);
this.flashRing.visible = true;
this.flashRing.position.set(this.body.position.x, 0.07, this.body.position.z);
this.fuseMat.color.set(0xffffff);
playSound('explosion', 0.8);
game.triggerCameraShake(0.3, 0.35);
if (game.player.health > 0) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < EXPLODE_RADIUS && game.player.body.position.y <= 0.8) {
game.player.damage(EXPLODE_DAMAGE, this.body.position);
}
}
this.takeDamage(99999, game, false);
}
protected onDeath(_game: Game) {
if (!this.exploding) {
playSound('damage', 0.5);
}
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.flashRing.geometry.dispose();
this.flashRingMat.dispose();
}
}
+110
View File
@@ -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 = [];
}
}
-32
View File
@@ -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
View File
@@ -4,7 +4,12 @@ import { Enemy } from './Enemy';
export class Brute extends Enemy { export class Brute extends Enemy {
protected model: THREE.Group; protected model: THREE.Group;
protected legL!: THREE.Group;
protected legR!: THREE.Group;
protected armL: THREE.Group | null = null;
protected armR: THREE.Group | null = null;
private stepTime = 0; private stepTime = 0;
private walkAmp = 0;
constructor() { constructor() {
super(); super();
@@ -21,7 +26,11 @@ export class Brute extends Enemy {
const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 }); const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 });
const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 }); const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 });
// Torso // Becken + Torso
const pelvis = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.35, 0.55), darkMat);
pelvis.position.y = 0.62;
this.registerFadeMesh(pelvis);
this.model.add(pelvis);
const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat); const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat);
torso.position.y = 0.95; torso.position.y = 0.95;
torso.castShadow = true; torso.castShadow = true;
@@ -53,14 +62,34 @@ export class Brute extends Enemy {
this.registerFadeMesh(head); this.registerFadeMesh(head);
this.model.add(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 }); const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 });
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.1, 0.05), eyeMat); 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.registerFadeMesh(eye);
this.model.add(eye); this.model.add(eye);
} }
// Beine (Gruppen mit Hüft-Pivot für die Laufanimation)
for (const side of [-1, 1]) {
const leg = new THREE.Group();
leg.position.set(side * 0.35, 0.6, 0);
this.model.add(leg);
const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.35, 6), rockMat);
thigh.position.y = -0.17;
this.registerFadeMesh(thigh);
leg.add(thigh);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.12, 0.4), darkMat);
foot.position.set(0, -0.5, 0.06);
this.registerFadeMesh(foot);
leg.add(foot);
if (side < 0) {
this.legL = leg;
} else {
this.legR = leg;
}
}
} }
playAnim() { playAnim() {
@@ -81,6 +110,7 @@ export class Brute extends Enemy {
.subVectors(game.player.body.position, this.body.position); .subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0; toPlayer.y = 0;
const dist = toPlayer.length(); const dist = toPlayer.length();
let moved = false;
if (dist > 0.1) { if (dist > 0.1) {
const dir = toPlayer.normalize(); const dir = toPlayer.normalize();
this.body.lookAt( this.body.lookAt(
@@ -96,7 +126,16 @@ export class Brute extends Enemy {
this.body.position.x += moveDir.x * dt; this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt; this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position); game.clampToArena(this.body.position);
moved = true;
} }
// Laufanimation: Beine gegengleich im Takt der Schritte, Arme leicht mit
this.walkAmp += ((moved ? 0.5 : 0) - this.walkAmp) * Math.min(1, dt * 8);
const swing = Math.sin(this.stepTime * 3) * this.walkAmp;
this.legL.rotation.x = swing;
this.legR.rotation.x = -swing;
if (this.armL) this.armL.rotation.x = -swing * 0.4;
if (this.armR) this.armR.rotation.x = swing * 0.4;
} }
protected onDeath(_game: Game) { protected onDeath(_game: Game) {
+3 -3
View File
@@ -1,10 +1,9 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { Weapon } from './Weapon'; import { Weapon } from './Weapon';
import { Player } from './Player'; import { Player } from './Player';
import { lightningDamage, lightningJumps, lightningCooldown } from './Skills'; import { lightningDamage, lightningJumps, lightningJumpRange, lightningCooldown } from './Skills';
const CHAIN_FIRST_RANGE = 14; const CHAIN_FIRST_RANGE = 14;
const CHAIN_JUMP_RANGE = 8;
const FLASH_TIME = 0.35; const FLASH_TIME = 0.35;
export class ChainLightning extends Weapon { export class ChainLightning extends Weapon {
@@ -25,6 +24,7 @@ export class ChainLightning extends Weapon {
const game = player.game; const game = player.game;
const damage = lightningDamage(level); const damage = lightningDamage(level);
const maxJumps = lightningJumps(level); const maxJumps = lightningJumps(level);
const jumpRange = lightningJumpRange(level);
// Chain through enemies, always the closest un-hit one // Chain through enemies, always the closest un-hit one
const start = player.body.position.clone(); const start = player.body.position.clone();
@@ -35,7 +35,7 @@ export class ChainLightning extends Weapon {
for (let i = 0; i < maxJumps; i++) { for (let i = 0; i < maxJumps; i++) {
let best: import('./Enemy').Enemy | null = null; let best: import('./Enemy').Enemy | null = null;
let bestDist = i === 0 ? CHAIN_FIRST_RANGE : CHAIN_JUMP_RANGE; let bestDist = i === 0 ? CHAIN_FIRST_RANGE : jumpRange;
for (const enemy of game.enemies) { for (const enemy of game.enemies) {
if (enemy.dead || hit.has(enemy)) continue; if (enemy.dead || hit.has(enemy)) continue;
const dist = enemy.body.position.distanceTo(from); const dist = enemy.body.position.distanceTo(from);
+6 -6
View File
@@ -40,16 +40,16 @@ export class Crab extends Enemy {
this.model.add(spot); 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 stalkMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 }); const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.22, 6), stalkMat); 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.registerFadeMesh(stalk);
this.model.add(stalk); this.model.add(stalk);
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat); 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.registerFadeMesh(eye);
this.model.add(eye); this.model.add(eye);
} }
@@ -70,8 +70,8 @@ export class Crab extends Enemy {
const clawGeom = new THREE.SphereGeometry(0.22, 12, 10); const clawGeom = new THREE.SphereGeometry(0.22, 12, 10);
this.clawL = new THREE.Mesh(clawGeom, shellMat); this.clawL = new THREE.Mesh(clawGeom, shellMat);
this.clawR = new THREE.Mesh(clawGeom, shellMat); this.clawR = new THREE.Mesh(clawGeom, shellMat);
this.clawL.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.clawR.position.set(0.45, 0.45, 0.5);
this.clawL.scale.set(1.2, 0.7, 1.4); this.clawL.scale.set(1.2, 0.7, 1.4);
this.clawR.scale.set(1.2, 0.7, 1.4); this.clawR.scale.set(1.2, 0.7, 1.4);
this.clawL.castShadow = true; this.clawL.castShadow = true;
@@ -144,7 +144,7 @@ export class Crab extends Enemy {
) { ) {
// Armored from the front: reduce damage coming from the facing side // Armored from the front: reduce damage coming from the facing side
if (sourcePos) { if (sourcePos) {
const front = new THREE.Vector3(0, 0, -1) const front = new THREE.Vector3(0, 0, 1)
.applyQuaternion(this.body.quaternion); .applyQuaternion(this.body.quaternion);
front.y = 0; front.y = 0;
front.normalize(); front.normalize();
+53
View File
@@ -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();
}
}
+971
View File
@@ -0,0 +1,971 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const MAX_HEALTH = 16000;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 1.6;
const FLY_TIME = 4.5;
const FLY_TIME_2 = 3.5;
const FLY_HEIGHT = 7;
const FLY_RADIUS = 9;
const FLY_FIREBALL_CD = 1.8;
const DIVEWINDUP_TIME = 1.0;
const DIVEWINDUP_TIME_2 = 0.7;
const DIVE_TIME = 0.9;
const DIVE_IMPACT_RADIUS = 2.8;
const DIVE_DAMAGE = 30;
const DIVERECOVER_TIME = 0.4;
const LANDED_TIME = 4.0;
const LANDED_TIME_2 = 2.5;
const TAKEOFF_TIME = 0.8;
const TAKEOFF_HEIGHT = 6;
const BREATH_WINDUP = 0.9;
const BREATH_TIME = 1.1;
const BREATH_RANGE = 11;
const BREATH_HALF_ANGLE = 0.65;
const BREATH_DAMAGE = 35;
const FIREBALL_SPREAD = 0.35;
const FIREBALL_SPEED = 13;
const FIREBALL_GRAVITY = 6;
const FIREBALL_HOMING = 3.2;
const FIREBALL_DAMAGE = 20;
const FIREBALL_HIT_RADIUS = 1.1;
const FIREBALL_COUNT = 3;
const FIREBALL_COUNT_2 = 5;
const ENRAGE_RATIO = 0.35;
const WALL_LIMIT = 48;
type State = 'fly' | 'divewindup' | 'dive' | 'diverecover' | 'landed' | 'takeoff';
export class Dragon extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private state: State = 'fly';
private stateTime = 0;
private enraged = false;
private hitFlash = 0;
private doubleDiveLeft = 0;
private skinMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private bellyMat!: THREE.MeshToonMaterial;
private wingMat!: THREE.MeshToonMaterial;
private eyeMat!: THREE.MeshBasicMaterial;
private shadow!: THREE.Mesh;
private shadowMat!: THREE.MeshBasicMaterial;
private wingL!: THREE.Group;
private wingR!: THREE.Group;
private jaw!: THREE.Group;
private tailGroup!: THREE.Group;
private breathGroup!: THREE.Group;
private breathMat!: THREE.MeshBasicMaterial;
private telegraphArc!: THREE.Mesh;
private telegraphArcMat!: THREE.MeshBasicMaterial;
private flyAngle = Math.random() * Math.PI * 2;
private flyFireballTimer = 1.5;
private diveTarget = new THREE.Vector3();
private diveFrom = new THREE.Vector3();
private diveShadow!: THREE.Mesh;
private diveShadowMat!: THREE.MeshBasicMaterial;
private diveShadowActive = false;
private diveShadowAdded = false;
private breathHit = false;
private breathActive = false;
private fireballs: DragonFireball[] = [];
private impactFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
private breathPhase = 'idle';
private breathTime = 0;
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Drache';
this.speed = 0;
this.xp = 400;
this.contactDps = 30;
this.contactRadius = 3.6;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
this.fadeOutModel = this.model;
this.body.add(this.model);
this.stateTime = FLY_TIME;
this.skinMat = new THREE.MeshToonMaterial({ color: 0xbb4433 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x772a1e });
this.bellyMat = new THREE.MeshToonMaterial({ color: 0xe8c890 });
this.wingMat = new THREE.MeshToonMaterial({ color: 0x993322, side: THREE.DoubleSide });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xffcc33 });
const boneMat = new THREE.MeshToonMaterial({ color: 0xf0e6cf });
let boneFadeRegistered = false;
const registerBone = (mesh: THREE.Mesh) => {
// Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt)
if (!boneFadeRegistered) {
this.registerFadeMesh(mesh);
boneFadeRegistered = true;
}
};
// Hilfspunkt: Strebe/Knochen zwischen zwei Punkten
const strut = (
a: THREE.Vector3, b: THREE.Vector3, r1: number, r2: number
): THREE.Mesh => {
const dir = new THREE.Vector3().subVectors(b, a);
const len = dir.length();
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r2, r1, len, 6), this.darkMat);
mesh.position.copy(a).addScaledVector(dir, 0.5);
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
return mesh;
};
// --- Körper (Blickrichtung +Z) ---
// Brust, Rumpfmitte, Hüfte (überlappende Kugeln für organische Silhouette)
const chest = new THREE.Mesh(new THREE.SphereGeometry(0.8, 18, 12), this.skinMat);
chest.scale.set(0.95, 0.92, 1.05);
chest.position.set(0, 1.4, 0.85);
chest.castShadow = true;
this.registerFadeMesh(chest);
this.model.add(chest);
const torso = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 14), this.skinMat);
torso.scale.set(1, 0.85, 1.5);
torso.position.set(0, 1.5, -0.1);
torso.castShadow = true;
this.registerFadeMesh(torso);
this.model.add(torso);
const hips = new THREE.Mesh(new THREE.SphereGeometry(0.85, 18, 12), this.skinMat);
hips.scale.set(1.0, 0.9, 1.1);
hips.position.set(0, 1.45, -0.85);
hips.castShadow = true;
this.registerFadeMesh(hips);
this.model.add(hips);
// Bauchplatten (hell, segmentiert)
for (let i = 0; i < 5; i++) {
const plate = new THREE.Mesh(
new THREE.SphereGeometry(0.58 - i * 0.06, 14, 8),
this.bellyMat
);
plate.scale.set(0.95, 0.5, 0.8);
plate.position.set(0, 1.02 - i * 0.02, 1.15 - i * 0.62);
this.registerFadeMesh(plate);
this.model.add(plate);
}
// Schwanz (geschwungen, mit Zacken und Spadel-Spitze)
this.tailGroup = new THREE.Group();
this.tailGroup.position.set(0, 1.35, -1.5);
this.model.add(this.tailGroup);
for (let i = 0; i < 7; i++) {
const radius = 0.46 - i * 0.055;
const seg = new THREE.Mesh(
new THREE.SphereGeometry(radius, 12, 8),
i % 2 === 0 ? this.skinMat : this.darkMat
);
const y = -0.13 * i + Math.max(0, i - 3) * 0.18;
seg.position.set(0, y, -0.15 - i * 0.45);
seg.scale.set(1, 0.92, 1.15);
this.registerFadeMesh(seg);
this.tailGroup.add(seg);
if (i % 2 === 0 && i < 5) {
const spike = new THREE.Mesh(
new THREE.ConeGeometry(0.09 - i * 0.012, 0.32 - i * 0.03, 5),
this.darkMat
);
spike.position.set(0, y + radius * 0.9 + 0.1, -0.15 - i * 0.45);
spike.rotation.x = -0.3;
this.registerFadeMesh(spike);
this.tailGroup.add(spike);
}
}
const tailTip = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.9, 4), this.darkMat);
tailTip.rotation.x = -Math.PI / 2;
tailTip.scale.x = 0.4;
tailTip.position.set(0, -0.35, -3.3);
this.registerFadeMesh(tailTip);
this.tailGroup.add(tailTip);
// Hals (S-Kurve nach oben)
for (const [y, z, r] of [[1.95, 1.3, 0.46], [2.2, 1.55, 0.4]]) {
const neck = new THREE.Mesh(new THREE.SphereGeometry(r, 14, 10), this.skinMat);
neck.scale.set(0.85, 0.95, 1.1);
neck.position.set(0, y, z);
this.registerFadeMesh(neck);
this.model.add(neck);
}
// Kopf-Gruppe (+Z)
const headGroup = new THREE.Group();
headGroup.position.set(0, 2.4, 1.8);
headGroup.rotation.x = 0.06;
this.model.add(headGroup);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), this.skinMat);
skull.scale.set(0.95, 0.9, 1.15);
skull.castShadow = true;
this.registerFadeMesh(skull);
headGroup.add(skull);
// Schnauze (+Z)
const snout = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.32, 0.7), this.skinMat);
snout.position.set(0, -0.1, 0.72);
this.registerFadeMesh(snout);
headGroup.add(snout);
// Stirnleiste (grimmiger Blick)
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.14, 0.26), this.darkMat);
brow.position.set(0, 0.3, 0.36);
brow.rotation.x = 0.12;
this.registerFadeMesh(brow);
headGroup.add(brow);
// Augen (+Z, ragen aus dem Kopf heraus)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 6), this.eyeMat);
eye.position.set(side * 0.3, 0.2, 0.48);
headGroup.add(eye);
}
// Hörner (nach hinten geschwungen, zwei Paare)
for (const side of [-1, 1]) {
const horn = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.75, 6), this.darkMat);
horn.position.set(side * 0.28, 0.42, -0.12);
horn.rotation.set(-1.15, 0, side * 0.35);
this.registerFadeMesh(horn);
headGroup.add(horn);
const horn2 = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.4, 6), this.darkMat);
horn2.position.set(side * 0.17, 0.3, -0.34);
horn2.rotation.set(-1.3, 0, side * 0.25);
this.registerFadeMesh(horn2);
headGroup.add(horn2);
}
// Ohr-Fransen (seitlich nach hinten)
for (const side of [-1, 1]) {
const frill = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.45, 5), this.darkMat);
frill.position.set(side * 0.48, 0.1, -0.18);
frill.rotation.set(0.35, 0, side * -1.75);
frill.scale.set(1, 1, 0.5);
this.registerFadeMesh(frill);
headGroup.add(frill);
}
// Nüstern
for (const side of [-1, 1]) {
const nostril = new THREE.Mesh(new THREE.SphereGeometry(0.04, 6, 4), this.darkMat);
nostril.position.set(side * 0.1, 0.06, 1.0);
headGroup.add(nostril);
}
// Zähne im Oberkiefer (zeigen nach unten)
for (const side of [-1, 1]) {
for (const z of [0.72, 0.88, 1.02]) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.032, 0.11, 5), boneMat);
tooth.rotation.x = Math.PI;
tooth.position.set(side * 0.15, -0.3, z);
registerBone(tooth);
headGroup.add(tooth);
}
}
// Kiefer (beweglich, öffnet sich beim Feuerspeer)
this.jaw = new THREE.Group();
this.jaw.position.set(0, -0.28, 0.28);
const jawMesh = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.16, 0.62), this.darkMat);
jawMesh.position.set(0, -0.05, 0.3);
this.registerFadeMesh(jawMesh);
this.jaw.add(jawMesh);
for (const side of [-1, 1]) {
for (const z of [0.42, 0.56]) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.028, 0.09, 5), boneMat);
tooth.position.set(side * 0.11, 0.06, z);
registerBone(tooth);
this.jaw.add(tooth);
}
}
this.jaw.rotation.x = 0.12;
headGroup.add(this.jaw);
// Rückenplatten (vom Nacken bis in den Schwanz)
const backPlates: [number, number, number, number][] = [
[2.25, 0.95, 0.15, 0.48],
[2.38, 0.35, 0.19, 0.6],
[2.4, -0.25, 0.2, 0.64],
[2.32, -0.85, 0.17, 0.52],
[1.9, -1.5, 0.14, 0.44],
[1.62, -2.4, 0.12, 0.38],
[1.45, -3.3, 0.1, 0.32],
];
for (const [y, z, r, h] of backPlates) {
const plate = new THREE.Mesh(new THREE.ConeGeometry(r, h, 5), this.darkMat);
plate.position.set(0, y, z);
plate.rotation.x = -0.22;
this.registerFadeMesh(plate);
this.model.add(plate);
}
// Beine (Hinterbeine kräftiger, Vorderbeine schlanker)
for (const side of [-1, 1]) {
// Hinterbein
const thigh = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 10), this.skinMat);
thigh.scale.set(0.75, 1.1, 0.95);
thigh.position.set(side * 0.72, 1.0, -0.8);
this.registerFadeMesh(thigh);
this.model.add(thigh);
const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.19, 0.75, 8), this.darkMat);
shin.position.set(side * 0.8, 0.45, -0.9);
shin.rotation.x = 0.12;
this.registerFadeMesh(shin);
this.model.add(shin);
const hindFoot = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.16, 0.6), this.skinMat);
hindFoot.position.set(side * 0.8, 0.09, -0.78);
this.registerFadeMesh(hindFoot);
this.model.add(hindFoot);
for (const dx of [-0.14, 0, 0.14]) {
const claw = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.2, 5), boneMat);
claw.rotation.x = Math.PI / 2;
claw.position.set(side * 0.8 + dx, 0.09, -0.44);
registerBone(claw);
this.model.add(claw);
}
// Vorderbein
const upperArm = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 8), this.skinMat);
upperArm.scale.set(0.8, 1.15, 0.9);
upperArm.position.set(side * 0.62, 1.05, 0.8);
this.registerFadeMesh(upperArm);
this.model.add(upperArm);
const foreArm = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.15, 0.7, 8), this.darkMat);
foreArm.position.set(side * 0.6, 0.5, 0.85);
this.registerFadeMesh(foreArm);
this.model.add(foreArm);
const foreFoot = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.14, 0.5), this.skinMat);
foreFoot.position.set(side * 0.6, 0.08, 0.95);
this.registerFadeMesh(foreFoot);
this.model.add(foreFoot);
for (const dx of [-0.11, 0, 0.11]) {
const claw = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.18, 5), boneMat);
claw.rotation.x = Math.PI / 2;
claw.position.set(side * 0.6 + dx, 0.08, 1.24);
registerBone(claw);
this.model.add(claw);
}
}
// --- Flügel (Fledermaus-Silhouette mit Membran und Fingerknochen) ---
const wingShape = new THREE.Shape();
wingShape.moveTo(0.2, 0.1);
wingShape.quadraticCurveTo(0.8, -0.12, 1.5, 0.0); // Vorderkante zum Handgelenk
wingShape.quadraticCurveTo(2.5, 0.0, 3.05, 0.95); // zur Flügelspitze
// Hinterkante: Fingerspitzen mit konkaven Bögen dazwischen
wingShape.quadraticCurveTo(2.2, 1.0, 2.7, 1.65);
wingShape.quadraticCurveTo(1.7, 1.45, 2.0, 2.1);
wingShape.quadraticCurveTo(1.05, 1.6, 1.1, 2.25);
wingShape.quadraticCurveTo(0.4, 1.2, 0.15, 1.5);
wingShape.closePath();
const wingGeom = new THREE.ShapeGeometry(wingShape, 10);
const buildWing = (): THREE.Group => {
const wing = new THREE.Group();
const membrane = new THREE.Mesh(wingGeom, this.wingMat);
membrane.rotation.x = -Math.PI / 2;
membrane.castShadow = true;
this.registerFadeMesh(membrane);
wing.add(membrane);
// Flügelknochen: Arm, Außenkante, ein Fingerstrahl
const bones = [
strut(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.5, 0.06, 0), 0.07, 0.05),
strut(new THREE.Vector3(1.5, 0.06, 0), new THREE.Vector3(3.05, 0.1, -0.95), 0.05, 0.028),
strut(new THREE.Vector3(1.3, 0.03, -0.25), new THREE.Vector3(2.0, 0.05, -2.1), 0.04, 0.02),
];
for (const bone of bones) {
this.registerFadeMesh(bone);
wing.add(bone);
}
// Daumenkralle am Handgelenk
const thumb = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.22, 5), boneMat);
thumb.rotation.x = Math.PI / 2;
thumb.position.set(1.55, 0.06, 0.14);
registerBone(thumb);
wing.add(thumb);
return wing;
};
this.wingR = buildWing();
this.wingR.position.set(0.7, 2.25, -0.2);
this.model.add(this.wingR);
this.wingL = buildWing();
this.wingL.position.set(-0.7, 2.25, -0.2);
this.wingL.scale.x = -1; // spiegeln für linke Seite
this.model.add(this.wingL);
// --- Effekte ---
// Boden-Schatten (folgt dem Drachen)
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(2.2, 24), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
// Telegraf-Schatten am Sturz-Ziel (wächst)
this.diveShadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0,
depthWrite: false,
});
this.diveShadow = new THREE.Mesh(new THREE.CircleGeometry(2.4, 24), this.diveShadowMat);
this.diveShadow.rotation.x = -Math.PI / 2;
this.diveShadow.position.y = 0.05;
this.diveShadow.scale.setScalar(0.3);
this.diveShadow.visible = false;
// Feuer-Telegraf (Bogen vor dem Drachen) und Feuer-Kegel
this.telegraphArcMat = new THREE.MeshBasicMaterial({
color: 0xff6644,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const arcGeom = new THREE.RingGeometry(
BREATH_RANGE - 0.8, BREATH_RANGE - 0.2, 48, 1,
-BREATH_HALF_ANGLE - Math.PI / 2, BREATH_HALF_ANGLE * 2
);
this.telegraphArc = new THREE.Mesh(arcGeom, this.telegraphArcMat);
this.telegraphArc.rotation.x = -Math.PI / 2;
this.telegraphArc.position.y = 0.05;
this.telegraphArc.visible = false;
this.body.add(this.telegraphArc);
this.breathGroup = new THREE.Group();
this.breathMat = new THREE.MeshBasicMaterial({
color: 0xff8833,
transparent: true,
opacity: 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const flame = new THREE.Mesh(new THREE.ConeGeometry(1.8, BREATH_RANGE, 16, 1, true), this.breathMat);
flame.rotation.x = Math.PI / 2;
flame.position.z = BREATH_RANGE / 2;
this.breathGroup.add(flame);
// auf Maulhöhe und leicht nach unten geneigt
this.breathGroup.position.y = 3.3;
this.breathGroup.rotation.x = 0.3;
this.breathGroup.visible = false;
this.body.add(this.breathGroup);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.skinMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
this.skinMat.color.set(this.enraged ? 0x992211 : 0xbb4433);
this.darkMat.color.set(this.enraged ? 0x551108 : 0x772a1e);
this.model.scale.setScalar(MODEL_SCALE);
}
// Enrage unter 35% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff2200);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
// Flügel-Flattern (schneller im Flug)
const flapSpeed = this.state === 'fly' || this.state === 'takeoff' ? 9 : 2;
const flap = Math.sin(this.waveTime * flapSpeed) * (this.state === 'fly' ? 0.7 : 0.2);
this.wingL.rotation.z = flap;
this.wingR.rotation.z = -flap;
// Flügel-Einstellwinkel (wegen scale.x-Spiegelung auf beiden Seiten gleich)
this.wingL.rotation.x = 0.3;
this.wingR.rotation.x = 0.3;
// Schwebe-Wippen im Flug und sanftes Schwanz-Schwenken
const airborne = this.state === 'fly' || this.state === 'takeoff';
this.model.position.y = airborne ? Math.sin(this.waveTime * 2.3) * 0.15 : 0;
this.tailGroup.rotation.y = Math.sin(this.waveTime * 1.7) * 0.12;
// Schatten bleibt am Boden
this.shadow.position.y = 0.06 - this.body.position.y;
const shadowScale = 0.6 + this.body.position.y * 0.15;
this.shadow.scale.setScalar(shadowScale);
// Sturz-Telegraf-Schatten
if (this.diveShadowActive) {
if (!this.diveShadowAdded) {
game.scene.add(this.diveShadow);
this.diveShadowAdded = true;
}
const t = 1 - this.stateTime / DIVEWINDUP_TIME;
this.diveShadow.position.set(this.diveTarget.x, 0.05, this.diveTarget.z);
this.diveShadow.scale.setScalar(0.3 + t * 0.9);
this.diveShadowMat.opacity = 0.2 + t * 0.35;
} else {
this.diveShadow.visible = false;
}
switch (this.state) {
case 'fly': this.updateFly(dt, game); break;
case 'divewindup': this.updateDiveWindup(dt, game); break;
case 'dive': this.updateDive(dt, game); break;
case 'diverecover': this.updateDiveRecover(dt); break;
case 'landed': this.updateLanded(dt, game); break;
case 'takeoff': this.updateTakeoff(dt); break;
}
// Feuerbälle
for (let i = this.fireballs.length - 1; i >= 0; i--) {
if (!this.fireballs[i].update(dt, game)) {
this.fireballs[i].dispose();
this.fireballs.splice(i, 1);
}
}
// Aufprall-Blitzflächen
for (let i = this.impactFlashes.length - 1; i >= 0; i--) {
const f = this.impactFlashes[i];
f.life -= dt;
const t = 1 - Math.max(0, f.life) / 0.35;
f.mesh.scale.setScalar(0.5 + t * 7);
f.mat.opacity = 0.7 * (1 - t);
if (f.life <= 0) {
game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
this.impactFlashes.splice(i, 1);
}
}
}
// --- Flug ---
private updateFly(dt: number, game: Game) {
this.stateTime -= dt;
this.body.position.y = FLY_HEIGHT;
// Kreisen über dem Spieler
const playerPos = game.player.body.position;
this.flyAngle += dt * 0.8;
const cx = playerPos.x;
const cz = playerPos.z;
const x = cx + Math.cos(this.flyAngle) * FLY_RADIUS;
const z = cz + Math.sin(this.flyAngle) * FLY_RADIUS;
this.body.position.x = THREE.MathUtils.clamp(x, -WALL_LIMIT + 3, WALL_LIMIT - 3);
this.body.position.z = THREE.MathUtils.clamp(z, -WALL_LIMIT + 3, WALL_LIMIT - 3);
// Flugrichtung anschauen (kreisen)
const lookX = cx + Math.cos(this.flyAngle + 0.5) * FLY_RADIUS;
const lookZ = cz + Math.sin(this.flyAngle + 0.5) * FLY_RADIUS;
this.body.lookAt(lookX, this.body.position.y, lookZ);
// Feuerbälle abwerfen
this.flyFireballTimer -= dt;
if (this.flyFireballTimer <= 0) {
this.flyFireballTimer = FLY_FIREBALL_CD;
this.throwFireballs(game);
}
if (this.stateTime <= 0) {
// Sturzangriff vorbereiten
this.state = 'divewindup';
this.stateTime = this.enraged ? DIVEWINDUP_TIME_2 : DIVEWINDUP_TIME;
this.doubleDiveLeft = this.enraged ? 1 : 0;
this.diveTarget.copy(game.player.body.position);
this.diveTarget.y = 0;
this.game?.clampToArena(this.diveTarget);
this.diveShadow.visible = true;
this.diveShadow.position.set(this.diveTarget.x, 0.05, this.diveTarget.z);
this.diveShadowActive = true;
playSound('spawn', 0.7);
}
}
// --- Sturzangriff ---
private updateDiveWindup(dt: number, game: Game) {
this.stateTime -= dt;
this.body.position.y = FLY_HEIGHT;
// Über dem Ziel schweben
this.body.position.x += (this.diveTarget.x - this.body.position.x) * Math.min(1, dt * 4);
this.body.position.z += (this.diveTarget.z - this.body.position.z) * Math.min(1, dt * 4);
this.body.lookAt(
this.diveTarget.x,
this.body.position.y,
this.diveTarget.z
);
if (this.stateTime <= 0) {
this.diveShadowActive = false;
this.diveShadow.visible = false;
this.state = 'dive';
this.stateTime = DIVE_TIME;
this.diveFrom.copy(this.body.position);
playSound('spawn', 0.9);
}
}
private updateDive(dt: number, game: Game) {
this.stateTime -= dt;
const t = 1 - Math.max(0, this.stateTime) / DIVE_TIME;
const ease = t * t;
this.body.position.x = this.diveFrom.x + (this.diveTarget.x - this.diveFrom.x) * ease;
this.body.position.z = this.diveFrom.z + (this.diveTarget.z - this.diveFrom.z) * ease;
this.body.position.y = FLY_HEIGHT * (1 - ease);
if (this.stateTime <= 0) {
this.body.position.y = 0.15;
this.diveImpact(game);
}
}
private diveImpact(game: Game) {
// Aufprall: Schock + Schaden (überspringbar: nur am Boden)
game.triggerCameraShake(0.4, 0.5);
playSound('explosion', 0.8);
const flashMat = new THREE.MeshBasicMaterial({
color: 0xff8844,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat);
flash.rotation.x = -Math.PI / 2;
flash.position.set(this.body.position.x, 0.07, this.body.position.z);
game.scene.add(flash);
this.impactFlashes.push({ mesh: flash, mat: flashMat, life: 0.35 });
if (game.player.health > 0 && game.player.body.position.y <= 0.8) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < DIVE_IMPACT_RADIUS) {
game.player.damage(DIVE_DAMAGE, this.body.position);
}
}
if (this.doubleDiveLeft > 0) {
this.doubleDiveLeft--;
this.state = 'diverecover';
this.stateTime = DIVERECOVER_TIME;
} else {
this.state = 'landed';
this.stateTime = this.enraged ? LANDED_TIME_2 : LANDED_TIME;
this.breathPhase = 'idle';
this.breathHit = false;
this.breathActive = false;
}
}
private updateDiveRecover(dt: number) {
this.stateTime -= dt;
if (this.stateTime <= 0) {
// Doppel-Sturz: neues Ziel = aktuelle Spielerposition
this.state = 'divewindup';
this.stateTime = this.enraged ? DIVEWINDUP_TIME_2 : DIVEWINDUP_TIME;
const p = this.game?.player.body.position;
if (p) {
this.diveTarget.set(p.x, 0, p.z);
this.game?.clampToArena(this.diveTarget);
}
this.diveShadow.visible = true;
this.diveShadowActive = true;
}
}
// --- Gelandet (Angriffs-Fenster) ---
private updateLanded(dt: number, game: Game) {
this.stateTime -= dt;
this.body.position.y = 0.15;
// Zum Spieler drehen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
if (toPlayer.length() > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
// Feuerspeer: Windup -> aktiv -> fertig
if (this.breathPhase === 'idle') {
this.breathPhase = 'windup';
this.breathTime = BREATH_WINDUP;
this.breathHit = false;
this.telegraphArc.visible = true;
playSound('spawn', 0.6);
} else if (this.breathPhase === 'windup') {
this.breathTime -= dt;
(this.telegraphArc.material as THREE.MeshBasicMaterial).opacity =
0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 12));
if (this.breathTime <= 0) {
this.breathPhase = 'active';
this.breathTime = BREATH_TIME;
this.telegraphArc.visible = false;
this.breathGroup.visible = true;
this.breathActive = true;
this.jaw.rotation.x = 0.55;
}
} else if (this.breathPhase === 'active') {
this.breathTime -= dt;
const t = 1 - this.breathTime / BREATH_TIME;
this.breathMat.opacity = 0.35 + Math.sin(t * Math.PI) * 0.4;
this.breathGroup.scale.z = 0.8 + Math.sin(t * Math.PI * 2) * 0.15;
// Treffer in der Mitte des Feuers
if (!this.breathHit && t >= 0.4) {
this.breathHit = true;
const dx = playerPos.x - this.body.position.x;
const dz = playerPos.z - this.body.position.z;
const dist = Math.hypot(dx, dz);
if (dist < BREATH_RANGE && playerPos.y <= 0.6) {
const ang = Math.atan2(dx, dz);
const yaw = this.body.rotation.y;
let diff = ang - yaw;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
if (Math.abs(diff) < BREATH_HALF_ANGLE) {
game.player.damage(BREATH_DAMAGE, this.body.position);
playSound('explosion', 0.7);
}
}
}
if (this.breathTime <= 0) {
this.breathPhase = 'done';
this.breathGroup.visible = false;
this.breathMat.opacity = 0;
this.jaw.rotation.x = 0.12;
}
}
if (this.stateTime <= 0) {
this.state = 'takeoff';
this.stateTime = TAKEOFF_TIME;
this.telegraphArc.visible = false;
this.breathGroup.visible = false;
playSound('spawn', 0.6);
}
}
// --- Abheben ---
private updateTakeoff(dt: number) {
this.stateTime -= dt;
const t = 1 - Math.max(0, this.stateTime) / TAKEOFF_TIME;
this.body.position.y = 0.15 + t * TAKEOFF_HEIGHT;
if (this.stateTime <= 0) {
this.state = 'fly';
this.stateTime = this.enraged ? FLY_TIME_2 : FLY_TIME;
this.flyFireballTimer = 1.2;
this.breathPhase = 'idle';
}
}
// --- Feuerbälle (Flug) ---
private throwFireballs(game: Game) {
const from = new THREE.Vector3(this.body.position.x, this.body.position.y - 2, this.body.position.z);
const target = game.player.body.position.clone();
const count = this.enraged ? FIREBALL_COUNT_2 : FIREBALL_COUNT;
const baseDir = new THREE.Vector3().subVectors(target, from);
baseDir.y = 0;
const horizDist = baseDir.length();
baseDir.normalize();
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
// Ballistisches Zielen: Abwärtsgeschwindigkeit so wählen, dass der Ball
// die Spielerhöhe genau beim Überflug erreicht (sonst segeln sie drüber).
const flightTime = Math.max(0.3, horizDist / FIREBALL_SPEED);
const dropVy = (target.y - from.y + 0.5 * FIREBALL_GRAVITY * flightTime * flightTime) / flightTime;
for (let i = 0; i < count; i++) {
const off = (i / (count - 1) - 0.5) * 2 * FIREBALL_SPREAD;
const dir = new THREE.Vector3()
.addScaledVector(baseDir, 1)
.addScaledVector(perp, off)
.normalize()
.multiplyScalar(FIREBALL_SPEED);
dir.y = dropVy;
const fb = new DragonFireball(from.clone(), dir);
game.scene.add(fb.body);
this.fireballs.push(fb);
}
playSound('spawn', 0.5);
}
// --- Schaden ---
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
// Im Flug unverwundbar: nur am Boden (landed) verletzbar
if (this.state !== 'landed') return;
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
for (const fb of this.fireballs) fb.dispose();
this.fireballs = [];
if (this.game) {
for (const f of this.impactFlashes) {
this.game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
}
}
this.impactFlashes = [];
this.diveShadowActive = false;
this.diveShadow.visible = false;
if (this.diveShadowAdded && this.game) {
this.game.scene.remove(this.diveShadow);
}
this.diveShadowAdded = false;
this.telegraphArc.visible = false;
this.breathGroup.visible = false;
this.skinMat.color.set(0xbb4433);
this.darkMat.color.set(0x772a1e);
this.eyeMat.color.set(0xffcc33);
this.model.scale.setScalar(MODEL_SCALE);
}
}
// Feuerball: gerades Projektil mit leichter Gravitation
class DragonFireball {
body: THREE.Group;
private vel: THREE.Vector3;
private life = 4;
dead = false;
constructor(from: THREE.Vector3, dir: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.copy(from);
this.vel = dir.clone();
const glowMat = new THREE.MeshBasicMaterial({
color: 0xffaa33,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.45, 12, 12), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshBasicMaterial({
color: 0xffdd55,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const core = new THREE.Mesh(new THREE.SphereGeometry(0.24, 10, 10), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
// Homing: sanft in Richtung des aktuellen Spielerstandorts lenken,
// damit die Bälle nicht über den Kopf segeln. Der begrenzte Lenkwinkel
// (FIREBALL_HOMING als Lerp-Rate) hält sie ausweichbar.
if (game.player.health > 0) {
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position)
.normalize();
const desired = toPlayer.multiplyScalar(FIREBALL_SPEED);
this.vel.lerp(desired, Math.min(1, dt * FIREBALL_HOMING));
}
this.body.position.addScaledVector(this.vel, dt);
if (this.body.position.y < 0.15) {
this.body.position.y = 0.15;
}
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < FIREBALL_HIT_RADIUS) {
game.player.damage(FIREBALL_DAMAGE, this.body.position);
this.dead = true;
return false;
}
}
if (this.life <= 0) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+7 -1
View File
@@ -7,12 +7,18 @@ export abstract class Enemy {
dead = false; dead = false;
deathTimer = 0; deathTimer = 0;
knockback = 0; knockback = 0;
rootTime = 0;
health = 100; health = 100;
maxHealth = 100;
isBoss = false;
displayName = '';
speed = 6; speed = 6;
xp = 10; xp = 10;
contactDps = 25; contactDps = 25;
contactRadius = 2; contactRadius = 2;
guaranteedDrop = false; guaranteedDrop = false;
spawnAtCenter = false;
immovable = false;
grace = 0; grace = 0;
mixer: THREE.AnimationMixer | null = null; mixer: THREE.AnimationMixer | null = null;
protected clips: Map<string, THREE.AnimationAction> = new Map(); protected clips: Map<string, THREE.AnimationAction> = new Map();
@@ -91,7 +97,7 @@ export abstract class Enemy {
} }
playSound('damage', 0.7); playSound('damage', 0.7);
game.onDamageDealt(damage); game.onDamageDealt(damage, this);
if (this.health <= 0) { if (this.health <= 0) {
this.die(game); this.die(game);
+1 -1
View File
@@ -51,7 +51,7 @@ export class EnemyProjectile {
if (game.player.health > 0) { if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position); const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < HIT_RADIUS) { if (dist < HIT_RADIUS) {
game.player.damage(this.damage); game.player.damage(this.damage, this.body.position);
this.dead = true; this.dead = true;
return false; return false;
} }
+112
View File
@@ -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
View File
@@ -52,18 +52,18 @@ export class Frog extends Enemy {
// Belly // Belly
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat); const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat);
belly.scale.set(0.85, 0.5, 0.85); 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.registerFadeMesh(belly);
this.model.add(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 eyeGeom = new THREE.SphereGeometry(0.17, 14, 12);
const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8); const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8);
const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 }); const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 });
this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat); this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat);
this.eyeR = 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.eyeL.position.set(-0.24, 0.72, 0.28);
this.eyeR.position.set(0.24, 0.72, -0.28); this.eyeR.position.set(0.24, 0.72, 0.28);
this.eyeL.castShadow = true; this.eyeL.castShadow = true;
this.eyeR.castShadow = true; this.eyeR.castShadow = true;
this.registerFadeMesh(this.eyeL); this.registerFadeMesh(this.eyeL);
@@ -72,7 +72,7 @@ export class Frog extends Enemy {
for (const eye of [this.eyeL, this.eyeR]) { for (const eye of [this.eyeL, this.eyeR]) {
const pupil = new THREE.Mesh(pupilGeom, pupilMat); 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); this.registerFadeMesh(pupil);
eye.add(pupil); eye.add(pupil);
} }
@@ -82,7 +82,7 @@ export class Frog extends Enemy {
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const thigh = new THREE.Mesh(legGeom, greenMat); const thigh = new THREE.Mesh(legGeom, greenMat);
thigh.scale.set(0.8, 1, 1.2); 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.rotation.y = side * 0.5;
thigh.castShadow = true; thigh.castShadow = true;
this.registerFadeMesh(thigh); this.registerFadeMesh(thigh);
+692
View File
@@ -0,0 +1,692 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const MAX_HEALTH = 10500;
const MAX_ARMOR = 2500;
const ARMOR_REGEN_TIME = 10;
const ARMOR_REDUCTION = 0.7;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 2.0;
const MOVE_SPEED = 3.5;
const BURST_COOLDOWN = 7;
const BURST_COOLDOWN_2 = 5;
const BURST_MARK_COUNT = 8;
const BURST_MARK_COUNT_2 = 12;
const BURST_RING2_RADIUS = 13;
const BURST_MARK_RADIUS = 2.8;
const BURST_TELEGRAPH = 1.6;
const BURST_STAGGER = 0.18;
const BURST_DAMAGE = 22;
const BURST_RADIUS = 9;
const RAIN_COOLDOWN = 5;
const RAIN_COOLDOWN_2 = 4;
const RAIN_COUNT = 7;
const RAIN_COUNT_2 = 10;
const STOMP_COOLDOWN = 10;
const STOMP_COOLDOWN_2 = 8;
const STOMP_RADIUS = 11;
const STOMP_DAMAGE = 15;
const ENRAGE_RATIO = 0.35;
const ICICLE_DAMAGE = 30;
const ICICLE_TELEGRAPH = 0.9;
const ICICLE_IMPACT_RADIUS = 1.8;
export class FrostGiant extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private enraged = false;
private hitFlash = 0;
private skinMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private eyeMat!: THREE.MeshBasicMaterial;
private armorShards: THREE.Mesh[] = [];
private armorShardMats: THREE.MeshToonMaterial[] = [];
private armorBroken = false;
private armorHp = MAX_ARMOR;
private armorRegenTimer = 0;
private armorGrow = 0;
private burstTimer = 3;
private rainTimer = 2;
private stompTimer = 6;
private burstMarks: {
group: THREE.Group;
ringMat: THREE.MeshBasicMaterial;
discMat: THREE.MeshBasicMaterial;
index: number;
exploded: boolean;
explAt: number;
}[] = [];
private burstActive = false;
private burstExplodeStart = 0;
private burstStartTime = 0;
private burstFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
private icicles: FallingIcicle[] = [];
private shockRing!: THREE.Mesh;
private shockRingMat!: THREE.MeshBasicMaterial;
private shockRingLife = 0;
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Frostriese';
this.speed = MOVE_SPEED;
this.xp = 280;
this.contactDps = 35;
this.contactRadius = 3.2;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
this.fadeOutModel = this.model;
this.body.add(this.model);
this.skinMat = new THREE.MeshToonMaterial({ color: 0x77bbdd });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x4477aa });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0x88ddff });
const crystalMat = new THREE.MeshToonMaterial({ color: 0xccf0ff, emissive: 0x223344 });
const metalMat = new THREE.MeshToonMaterial({ color: 0x5599bb });
// Beine
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.45, 1.2, 10), this.darkMat);
leg.position.set(side * 0.55, -0.6, 0);
leg.castShadow = true;
this.registerFadeMesh(leg);
this.model.add(leg);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.25, 0.9), this.skinMat);
foot.position.set(side * 0.55, -1.25, 0.1);
this.registerFadeMesh(foot);
this.model.add(foot);
}
// Torso
const torso = new THREE.Mesh(new THREE.BoxGeometry(2.0, 2.2, 1.4), this.skinMat);
torso.position.y = 0.9;
torso.castShadow = true;
this.registerFadeMesh(torso);
this.model.add(torso);
// Bauchpanzer
const belly = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.9, 0.1), metalMat);
belly.position.set(0, 0.55, 0.72);
this.registerFadeMesh(belly);
this.model.add(belly);
// Schultern
for (const side of [-1, 1]) {
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.7, 12, 10), this.skinMat);
shoulder.position.set(side * 1.3, 1.7, 0);
shoulder.castShadow = true;
this.registerFadeMesh(shoulder);
this.model.add(shoulder);
// Arme
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.42, 1.4, 8), this.darkMat);
arm.position.set(side * 1.6, 0.6, 0);
this.registerFadeMesh(arm);
this.model.add(arm);
// Faust
const fist = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), this.skinMat);
fist.position.set(side * 1.7, 0.0, 0.2);
this.registerFadeMesh(fist);
this.model.add(fist);
}
// Kopf
const head = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.9, 0.95), this.skinMat);
head.position.y = 2.5;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Frostbart (+Z)
const beard = new THREE.Mesh(new THREE.ConeGeometry(0.45, 0.7, 8), this.darkMat);
beard.position.set(0, 1.95, 0.5);
beard.rotation.x = Math.PI;
this.registerFadeMesh(beard);
this.model.add(beard);
// Augen (+Z)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), this.eyeMat);
eye.position.set(side * 0.3, 2.62, 0.5);
this.model.add(eye);
}
// Eiskristall-Panzer (verdeckt den Rumpf, solange die Rüstung hält)
const shardPositions: [number, number, number, number][] = [
[0, 1.5, 0.75, 0],
[-0.7, 1.3, 0.7, 0.4],
[0.7, 1.3, 0.7, -0.4],
[0, 2.1, 0.7, 0],
[-1.4, 1.7, 0, 0.8],
[1.4, 1.7, 0, -0.8],
[-0.6, 0.7, 0.75, 0.3],
[0.6, 0.7, 0.75, -0.3],
[-0.3, 2.6, 0.5, 0.5],
[0.3, 2.6, 0.5, -0.5],
];
for (const [x, y, z, rotY] of shardPositions) {
const mat = crystalMat.clone();
const shard = new THREE.Mesh(new THREE.ConeGeometry(0.35, 0.9, 6), mat);
shard.position.set(x, y, z);
shard.rotation.set(-0.15, rotY, 0);
this.registerFadeMesh(shard);
this.model.add(shard);
this.armorShards.push(shard);
this.armorShardMats.push(mat);
}
// Schockwellen-Ring
this.shockRingMat = new THREE.MeshBasicMaterial({
color: 0x88ccff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.shockRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.0, 48), this.shockRingMat);
this.shockRing.rotation.x = -Math.PI / 2;
this.shockRing.position.y = 0.06;
this.shockRing.visible = false;
this.body.add(this.shockRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.skinMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
const dark = this.armorBroken;
this.skinMat.color.set(dark ? 0x4477aa : 0x77bbdd);
this.darkMat.color.set(dark ? 0x2a4a6a : 0x4477aa);
this.model.scale.setScalar(MODEL_SCALE);
}
// Enrage unter 35% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff4422);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
// Rüstungs-Regeneration
if (this.armorBroken) {
this.armorRegenTimer -= dt;
const t = 1 - Math.max(0, this.armorRegenTimer) / ARMOR_REGEN_TIME;
// Kristalle wachsen langsam nach, kurz vor Abschluss deutlich sichtbar
this.armorGrow = Math.min(1, t * 1.6);
const showScale = this.armorGrow;
for (const shard of this.armorShards) shard.visible = showScale > 0.02;
for (let i = 0; i < this.armorShards.length; i++) {
this.armorShards[i].scale.setScalar(showScale);
this.armorShardMats[i].opacity = Math.min(1, showScale * 2);
}
if (this.armorRegenTimer <= 0) {
this.armorBroken = false;
this.armorHp = MAX_ARMOR;
this.armorRegenTimer = 0;
for (const shard of this.armorShards) shard.scale.setScalar(1);
for (const mat of this.armorShardMats) mat.opacity = 1;
playSound('spawn', 0.7);
}
}
// Zum Spieler laufen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(MOVE_SPEED);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-MOVE_SPEED);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Angriffe
this.burstTimer -= dt;
if (this.burstTimer <= 0 && !this.burstActive) {
this.burstTimer = this.enraged ? BURST_COOLDOWN_2 : BURST_COOLDOWN;
this.startBurst(game);
}
this.rainTimer -= dt;
if (this.rainTimer <= 0) {
this.rainTimer = this.enraged ? RAIN_COOLDOWN_2 : RAIN_COOLDOWN;
this.icicleRain(game);
}
this.stompTimer -= dt;
if (this.stompTimer <= 0) {
this.stompTimer = this.enraged ? STOMP_COOLDOWN_2 : STOMP_COOLDOWN;
this.stomp(game);
}
this.updateBurst(dt, game);
this.updateBurstFlashes(dt);
// Schockwellen-Ring expandieren
if (this.shockRing.visible) {
const s = this.shockRing.scale.x + dt * 26;
this.shockRing.scale.setScalar(s);
this.shockRingMat.opacity = Math.max(0, 0.5 - (s / STOMP_RADIUS) * 0.5);
if (s >= STOMP_RADIUS) this.shockRing.visible = false;
}
// Eiszapfen
for (let i = this.icicles.length - 1; i >= 0; i--) {
if (!this.icicles[i].update(dt, game)) {
this.icicles[i].dispose();
this.icicles.splice(i, 1);
}
}
}
// --- Eispanzer ---
private breakArmor(game: Game) {
this.armorBroken = true;
this.armorRegenTimer = ARMOR_REGEN_TIME;
this.armorGrow = 0;
for (const shard of this.armorShards) shard.visible = false;
for (const mat of this.armorShardMats) mat.opacity = 0;
game.spawnDamageText('PANZER WEG!', this.body.position, '#88ccff');
game.triggerCameraShake(0.4, 0.5);
playSound('explosion', 0.9);
}
// --- Eisberst-Ring ---
private startBurst(game: Game) {
this.burstActive = true;
this.burstExplodeStart = BURST_TELEGRAPH;
this.burstStartTime = this.waveTime;
this.burstMarks = [];
const count = this.enraged ? BURST_MARK_COUNT_2 : BURST_MARK_COUNT;
const offset = Math.random() * Math.PI * 2;
const rings = this.enraged
? [{ radius: BURST_RADIUS, count: BURST_MARK_COUNT }]
: [{ radius: BURST_RADIUS, count }];
if (this.enraged) {
rings.push({ radius: BURST_RING2_RADIUS, count: BURST_MARK_COUNT - 2 });
}
const cx = this.body.position.x;
const cz = this.body.position.z;
let index = 0;
for (const ring of rings) {
for (let i = 0; i < ring.count; i++) {
const a = offset + (i / ring.count) * Math.PI * 2;
const ringMat = new THREE.MeshBasicMaterial({
color: 0x66ccff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const discMat = new THREE.MeshBasicMaterial({
color: 0x4488ff,
transparent: true,
opacity: 0,
depthWrite: false,
});
const ringMesh = new THREE.Mesh(
new THREE.RingGeometry(BURST_MARK_RADIUS - 0.15, BURST_MARK_RADIUS + 0.15, 32),
ringMat
);
ringMesh.rotation.x = -Math.PI / 2;
ringMesh.position.y = 0.05;
const disc = new THREE.Mesh(
new THREE.CircleGeometry(BURST_MARK_RADIUS, 32),
discMat
);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.03;
const group = new THREE.Group();
group.add(ringMesh, disc);
group.position.set(
cx + Math.cos(a) * ring.radius,
0,
cz + Math.sin(a) * ring.radius
);
game.scene.add(group);
this.burstMarks.push({ group, ringMat, discMat, index, exploded: false, explAt: 0 });
index++;
}
}
playSound('spawn', 0.7);
}
private updateBurst(dt: number, game: Game) {
if (!this.burstActive) return;
if (this.burstExplodeStart > 0) {
// Telegraf: Marken pulsieren
this.burstExplodeStart -= dt;
const pulse = Math.abs(Math.sin(this.waveTime * 9));
for (const mark of this.burstMarks) {
mark.ringMat.opacity = 0.3 + 0.5 * pulse;
mark.discMat.opacity = 0.12 + 0.08 * pulse;
}
if (this.burstExplodeStart <= 0) {
// Explosionsfenster: nacheinander
this.burstExplodeStart = -1;
}
return;
}
let allDone = true;
for (const mark of this.burstMarks) {
if (!mark.exploded) {
allDone = false;
const t = (this.waveTime - this.burstStartTime) - (mark.index * BURST_STAGGER);
if (t >= 0) {
mark.exploded = true;
this.explodeMark(mark, game);
}
}
}
if (allDone) {
this.burstActive = false;
for (const mark of this.burstMarks) {
game.scene.remove(mark.group);
mark.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
this.burstMarks = [];
}
}
private explodeMark(mark: { group: THREE.Group }, game: Game) {
// Expandierender Frost-Ring
const flashMat = new THREE.MeshBasicMaterial({
color: 0x99ddff,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat);
flash.rotation.x = -Math.PI / 2;
flash.position.set(mark.group.position.x, 0.07, mark.group.position.z);
game.scene.add(flash);
this.burstFlashes.push({ mesh: flash, mat: flashMat, life: 0.35 });
// Schaden (überspringbar: nur am Boden)
if (game.player.health > 0 && game.player.body.position.y <= 0.8) {
const dx = game.player.body.position.x - mark.group.position.x;
const dz = game.player.body.position.z - mark.group.position.z;
if (Math.hypot(dx, dz) < BURST_MARK_RADIUS + 0.3) {
game.player.damage(BURST_DAMAGE, mark.group.position);
}
}
playSound('explosion', 0.4);
}
private updateBurstFlashes(dt: number) {
for (let i = this.burstFlashes.length - 1; i >= 0; i--) {
const f = this.burstFlashes[i];
f.life -= dt;
const t = 1 - Math.max(0, f.life) / 0.35;
f.mesh.scale.setScalar(0.5 + t * 6);
f.mat.opacity = 0.7 * (1 - t);
if (f.life <= 0) {
this.game?.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
this.burstFlashes.splice(i, 1);
}
}
}
// --- Eiszapfen-Regen ---
private icicleRain(game: Game) {
const playerPos = game.player.body.position;
const count = this.enraged ? RAIN_COUNT_2 : RAIN_COUNT;
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = 2 + Math.random() * 6;
const icicle = new FallingIcicle(new THREE.Vector3(
playerPos.x + Math.cos(a) * r,
0,
playerPos.z + Math.sin(a) * r
));
game.scene.add(icicle.body);
this.icicles.push(icicle);
}
playSound('spawn', 0.5);
}
// --- Stampf ---
private stomp(game: Game) {
this.shockRing.visible = true;
this.shockRing.scale.setScalar(0.2);
this.shockRingMat.opacity = 0.5;
playSound('explosion', 0.7);
const playerPos = game.player.body.position;
const dist = Math.hypot(
playerPos.x - this.body.position.x,
playerPos.z - this.body.position.z
);
// Überspringbar: nur Treffer am Boden
if (dist < STOMP_RADIUS && playerPos.y <= 0.6) {
game.player.damage(STOMP_DAMAGE, this.body.position);
}
game.triggerCameraShake(0.3, 0.3);
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
if (this.armorBroken === false && this.armorShards.length > 0) {
const absorbed = Math.min(MAX_ARMOR, damage * ARMOR_REDUCTION);
damage *= 1 - ARMOR_REDUCTION;
game.spawnDamageText('Eispanzer!', this.body.position, '#99ccff');
// Rüstung abbauen: Kristalle werden mit sinkender Armor-HP blasser
const pct = 1 - this.armorHp / MAX_ARMOR;
for (let i = 0; i < this.armorShards.length; i++) {
const shard = this.armorShards[i];
const threshold = (i + 1) / this.armorShards.length;
shard.visible = pct < threshold;
if (!shard.visible) {
this.armorShardMats[i].opacity = 0;
}
}
this.armorHp -= absorbed;
if (this.armorHp <= 0) {
this.armorHp = 0;
this.breakArmor(game);
}
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
this.burstActive = false;
if (this.game) {
for (const mark of this.burstMarks) {
this.game.scene.remove(mark.group);
mark.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
for (const f of this.burstFlashes) {
this.game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
}
}
this.burstMarks = [];
this.burstFlashes = [];
for (const icicle of this.icicles) icicle.dispose();
this.icicles = [];
this.shockRing.visible = false;
this.skinMat.color.set(0x77bbdd);
this.darkMat.color.set(0x4477aa);
this.eyeMat.color.set(0x88ddff);
this.model.scale.setScalar(MODEL_SCALE);
for (const shard of this.armorShards) shard.visible = true;
for (const mat of this.armorShardMats) mat.opacity = 1;
}
}
// Eiszapfen: fällt mit wachsendem Schatten-Telegrafen vom Himmel
class FallingIcicle {
body: THREE.Group;
private shadow: THREE.Mesh;
private icicle: THREE.Mesh;
private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph';
private timer = ICICLE_TELEGRAPH;
private dust: THREE.Mesh[] = [];
private dustDirs: THREE.Vector3[] = [];
private dustMat: THREE.MeshBasicMaterial;
constructor(pos: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.set(pos.x, 0, pos.z);
const shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
depthWrite: false,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.position.y = 0.06;
this.shadow.scale.setScalar(0.4);
this.body.add(this.shadow);
const iceMat = new THREE.MeshToonMaterial({ color: 0xbbeeff, emissive: 0x334455 });
this.icicle = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.6, 6), iceMat);
this.icicle.position.y = 16;
this.icicle.rotation.x = Math.PI;
this.body.add(this.icicle);
this.dustMat = new THREE.MeshBasicMaterial({
color: 0x99bbdd,
transparent: true,
opacity: 0,
depthWrite: false,
});
}
update(dt: number, game: Game): boolean {
this.timer -= dt;
if (this.phase === 'telegraph') {
const t = 1 - this.timer / ICICLE_TELEGRAPH;
this.shadow.scale.setScalar(0.4 + t * 0.6);
(this.shadow.material as THREE.MeshBasicMaterial).opacity = 0.2 + t * 0.2;
if (this.timer <= 0) {
this.phase = 'fall';
this.timer = 2;
}
} else if (this.phase === 'fall') {
this.icicle.position.y -= 30 * dt;
this.icicle.rotation.z += dt * 2;
if (this.icicle.position.y <= 0.15) {
this.icicle.position.y = 0.15;
this.icicle.visible = false;
this.shadow.visible = false;
if (game.player.health > 0) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < ICICLE_IMPACT_RADIUS) {
game.player.damage(ICICLE_DAMAGE, this.body.position);
}
}
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2;
const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a));
const puff = new THREE.Mesh(new THREE.SphereGeometry(0.25, 6, 5), this.dustMat);
this.body.add(puff);
this.dust.push(puff);
this.dustDirs.push(dir);
}
this.phase = 'dust';
this.timer = 0.4;
}
} else {
const t = 1 - this.timer / 0.4;
for (let i = 0; i < this.dust.length; i++) {
this.dust[i].position.copy(this.dustDirs[i]).multiplyScalar(0.5 + t * 1.8);
this.dust[i].position.y = 0.2 + t * 0.8;
}
this.dustMat.opacity = 0.5 * (1 - t);
if (this.timer <= 0) return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
this.body.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
}
+970 -80
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -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();
}
}
}
+5
View File
@@ -198,6 +198,11 @@ export class Ghost extends Enemy {
} }
// Chase the player (stays hittable and dangerous even while invisible) // Chase the player (stays hittable and dangerous even while invisible)
this.chase(dt, game);
}
// Getrennt für Subklassen (z. B. MazeGuard: erst aktiv, wenn der Spieler nah ist)
protected chase(dt: number, game: Game) {
const toPlayer = new THREE.Vector3() const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position); .subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0; toPlayer.y = 0;
+142
View File
@@ -0,0 +1,142 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const HIT_FLASH_TIME = 0.14;
// Gnom: das Ziel im Labyrinth. Stationär, nur mit Sichtlinie (durch die
// Wände hindurch) verletzbar -> man muss wirklich zu ihm hingehen.
export class Gnome extends Enemy {
private model: THREE.Group;
private skinMat!: THREE.MeshToonMaterial;
private hatMat!: THREE.MeshToonMaterial;
private beardMat!: THREE.MeshToonMaterial;
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor(hp: number, xp: number) {
super();
this.health = hp;
this.maxHealth = hp;
this.isBoss = true;
this.displayName = 'Gnom';
this.speed = 0;
this.xp = xp;
this.contactDps = 0;
this.contactRadius = 0;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.skinMat = new THREE.MeshToonMaterial({ color: 0xe8c8a0 });
this.hatMat = new THREE.MeshToonMaterial({ color: 0x44aa44 });
this.beardMat = new THREE.MeshToonMaterial({ color: 0xf8f0e0 });
// Körper
const body = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), this.skinMat);
body.scale.set(1, 1.15, 1);
body.position.y = 0.5;
body.castShadow = true;
this.registerFadeMesh(body);
this.model.add(body);
// Mantel
const coat = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.45, 8, 1, true), this.hatMat);
coat.position.y = 0.28;
coat.rotation.x = Math.PI;
this.registerFadeMesh(coat);
this.model.add(coat);
// Kopf
const head = new THREE.Mesh(new THREE.SphereGeometry(0.24, 12, 10), this.skinMat);
head.position.y = 0.95;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Spitzer Hut
const hat = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.55, 8), this.hatMat);
hat.position.set(0, 1.3, 0.05);
hat.rotation.x = 0.12;
this.registerFadeMesh(hat);
this.model.add(hat);
// Bart (+Z)
const beard = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), this.beardMat);
beard.scale.set(1, 1.2, 0.7);
beard.position.set(0, 0.72, 0.2);
this.registerFadeMesh(beard);
this.model.add(beard);
// Augen (+Z)
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x223322 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.04, 6, 5), eyeMat);
eye.position.set(side * 0.09, 1.0, 0.22);
this.model.add(eye);
}
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.0, shrink: 0.8 });
}
update(dt: number, game: Game) {
this.time += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.skinMat.color.set(0xffffff);
this.hatMat.color.set(0xffffff);
} else {
this.skinMat.color.set(0xe8c8a0);
this.hatMat.color.set(0x44aa44);
}
// Wippen + ab und zu aufhüpfen
this.body.position.y = 0.1 + Math.abs(Math.sin(this.time * 2.2)) * 0.08;
this.model.rotation.y = Math.sin(this.time * 1.5) * 0.15;
// Zum Spieler drehen
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
if (toPlayer.length() > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
// Nur mit Sichtlinie verletzbar (kein Feuerball durch die Wand)
const src = sourcePos ?? game.player.body.position;
if (game.maze && !game.maze.hasLineOfSight(src, this.body.position)) {
game.spawnDamageText('WAND!', this.body.position, '#8899aa');
return;
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
playSound('damage', 0.6);
}
protected onDeath(_game: Game) {
this.beginFadeDeath();
}
}
+172
View File
@@ -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();
}
}
+74
View File
@@ -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();
}
}
}
}
+78
View File
@@ -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();
}
}
}
+548
View File
@@ -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
View File
@@ -0,0 +1,240 @@
import * as THREE from 'three';
const GRID = 11;
const CELL = 92 / GRID;
const HALF = 46;
const WALL_THICK = 1;
const WALL_HEIGHT = 2.5;
interface WallBox {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
// Labyrinth-Zwischenboss: randomisierter Prim auf einem 11x11-Grid.
// Erzeugt ein "perfektes" Labyrinth (jede Zelle erreichbar, genau ein Pfad
// zwischen zwei beliebigen Zellen) mit kurzen, verzweigten Lösungswegen.
export class Maze {
private scene: THREE.Scene;
private wallMeshes: THREE.Mesh[] = [];
private wallBoxes: WallBox[] = [];
private openH: boolean[][] = []; // Passage (i,j)-(i+1,j)
private openV: boolean[][] = []; // Passage (i,j)-(i,j+1)
constructor(scene: THREE.Scene) {
this.scene = scene;
this.generate();
this.buildWalls();
}
// Start-Ecke = Zelle (0,0), Ziel = Zelle (10,10)
startPosition(): THREE.Vector3 {
return this.cellCenter(0, 0);
}
// goalIndex 0 = Ziel-Ecke (max,max), 1 = Neben-Ecke (min,max) für 2x-Finden
goalPosition(goalIndex: number): THREE.Vector3 {
if (goalIndex === 1) return this.cellCenter(0, GRID - 1);
return this.cellCenter(GRID - 1, GRID - 1);
}
private cellCenter(i: number, j: number): THREE.Vector3 {
return new THREE.Vector3(-HALF + (i + 0.5) * CELL, 0, -HALF + (j + 0.5) * CELL);
}
private generate() {
for (let i = 0; i < GRID; i++) {
this.openH.push(new Array(GRID - 1).fill(false));
this.openV.push(new Array(GRID - 1).fill(false));
}
// Randomized Prim: startet bei Zelle (0,0), wählt zufällige Frontier-Kante
const visited: boolean[][] = Array.from({ length: GRID }, () => new Array(GRID).fill(false));
const frontier: { i: number; j: number; dir: 'h' | 'v' }[] = [];
const addFrontier = (i: number, j: number) => {
if (i < GRID - 1 && !visited[i + 1][j]) frontier.push({ i, j, dir: 'h' });
if (i > 0 && !visited[i - 1][j]) frontier.push({ i: i - 1, j, dir: 'h' });
if (j < GRID - 1 && !visited[i][j + 1]) frontier.push({ i, j, dir: 'v' });
if (j > 0 && !visited[i][j - 1]) frontier.push({ i, j: j - 1, dir: 'v' });
};
visited[0][0] = true;
addFrontier(0, 0);
while (frontier.length > 0) {
const idx = Math.floor(Math.random() * frontier.length);
const { i, j, dir } = frontier[idx];
frontier.splice(idx, 1);
const ci = dir === 'h' ? i + 1 : i;
const cj = dir === 'h' ? j : j + 1;
if (visited[ci][cj]) continue;
visited[ci][cj] = true;
if (dir === 'h') this.openH[i][j] = true;
else this.openV[i][j] = true;
addFrontier(ci, cj);
}
}
private buildWalls() {
const wallMat = new THREE.MeshToonMaterial({ color: 0x776677 });
// Außenwände: Labyrinth bis zur Begrenzung abdichten,
// damit man nicht außen herumlaufen kann
const outer = HALF + WALL_THICK / 2;
this.addWall(-outer, -HALF + WALL_THICK / 2, -outer, outer, WALL_THICK, 2 * outer, -HALF, 0, 0, wallMat);
this.addWall(HALF - WALL_THICK / 2, outer, -outer, outer, WALL_THICK, 2 * outer, HALF, 0, 0, wallMat);
this.addWall(-outer, outer, -outer, -HALF + WALL_THICK / 2, 2 * outer, WALL_THICK, 0, 0, -HALF, wallMat);
this.addWall(-outer, outer, HALF - WALL_THICK / 2, outer, 2 * outer, WALL_THICK, 0, 0, HALF, wallMat);
// Vertikale Gitterlinien (konstantes x): Wand blockiert Bewegung in x
for (let i = 1; i < GRID; i++) {
const bx = -HALF + i * CELL;
for (let j = 0; j < GRID; j++) {
if (this.openH[i - 1][j]) continue;
const zc = -HALF + (j + 0.5) * CELL;
this.addWall(bx - WALL_THICK / 2, bx + WALL_THICK / 2, zc - CELL / 2, zc + CELL / 2, WALL_THICK, CELL, bx, 0, zc, wallMat);
}
}
// Horizontale Gitterlinien (konstantes z): Wand blockiert Bewegung in z
for (let j = 1; j < GRID; j++) {
const bz = -HALF + j * CELL;
for (let i = 0; i < GRID; i++) {
if (this.openV[i][j - 1]) continue;
const xc = -HALF + (i + 0.5) * CELL;
this.addWall(xc - CELL / 2, xc + CELL / 2, bz - WALL_THICK / 2, bz + WALL_THICK / 2, CELL, WALL_THICK, xc, 0, bz, wallMat);
}
}
}
private addWall(
minX: number, maxX: number, minZ: number, maxZ: number,
sizeX: number, sizeZ: number,
px: number, py: number, pz: number,
mat: THREE.Material
) {
const geom = new THREE.BoxGeometry(sizeX, WALL_HEIGHT, sizeZ);
const mesh = new THREE.Mesh(geom, mat);
mesh.position.set(px, py + WALL_HEIGHT / 2, pz);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
this.wallMeshes.push(mesh);
this.wallBoxes.push({ minX, maxX, minZ, maxZ });
}
// Circle-vs-AABB-Kollision: Spieler aus Wänden schieben (3 Durchgänge)
collidePlayer(pos: THREE.Vector3, radius: number) {
for (let pass = 0; pass < 3; pass++) {
let moved = false;
for (const w of this.wallBoxes) {
const cx = Math.max(w.minX, Math.min(pos.x, w.maxX));
const cz = Math.max(w.minZ, Math.min(pos.z, w.maxZ));
const dx = pos.x - cx;
const dz = pos.z - cz;
const d2 = dx * dx + dz * dz;
if (d2 < radius * radius) {
if (d2 > 1e-6) {
const d = Math.sqrt(d2);
pos.x += (dx / d) * (radius - d);
pos.z += (dz / d) * (radius - d);
} else {
// Mittelpunkt in der Wand: kleinste Überlappung wählen
const ox = Math.min(pos.x - w.minX, w.maxX - pos.x);
const oz = Math.min(pos.z - w.minZ, w.maxZ - pos.z);
if (ox < oz) {
pos.x += (pos.x < (w.minX + w.maxX) / 2 ? -1 : 1) * (radius + ox);
} else {
pos.z += (pos.z < (w.minZ + w.maxZ) / 2 ? -1 : 1) * (radius + oz);
}
}
moved = true;
}
}
if (!moved) break;
}
}
// Sichtlinie: Segment alle ~0.8 m abtasten, Wand dazwischen = kein Sichtkontakt
hasLineOfSight(a: THREE.Vector3, b: THREE.Vector3): boolean {
const dx = b.x - a.x;
const dz = b.z - a.z;
const dist = Math.hypot(dx, dz);
const steps = Math.max(1, Math.ceil(dist / 0.8));
for (let s = 1; s < steps; s++) {
const t = s / steps;
const px = a.x + dx * t;
const pz = a.z + dz * t;
for (const w of this.wallBoxes) {
if (px > w.minX - 0.3 && px < w.maxX + 0.3 &&
pz > w.minZ - 0.3 && pz < w.maxZ + 0.3) {
return false;
}
}
}
return true;
}
// Wächter-Positionen: gleichmäßig verteilte Zellen entlang des Lösungswegs
guardPositions(count: number): THREE.Vector3[] {
if (count <= 0) return [];
// BFS von (0,0) zu (10,10)
const prev: ([number, number] | null)[][] = Array.from(
{ length: GRID },
() => new Array<[number, number] | null>(GRID).fill(null)
);
const queue: [number, number][] = [[0, 0]];
prev[0][0] = [0, 0];
while (queue.length > 0) {
const [i, j] = queue.shift()!;
if (i === GRID - 1 && j === GRID - 1) break;
const neigh: [number, number][] = [];
if (i < GRID - 1 && this.openH[i][j]) neigh.push([i + 1, j]);
if (i > 0 && this.openH[i - 1][j]) neigh.push([i - 1, j]);
if (j < GRID - 1 && this.openV[i][j]) neigh.push([i, j + 1]);
if (j > 0 && this.openV[i][j - 1]) neigh.push([i, j - 1]);
for (const [ni, nj] of neigh) {
if (prev[ni][nj]) continue;
prev[ni][nj] = [i, j];
queue.push([ni, nj]);
}
}
// Pfad rekonstruieren
const path: [number, number][] = [];
let cur: [number, number] | null = [GRID - 1, GRID - 1];
while (cur) {
path.push(cur);
const p: [number, number] | null = prev[cur[0]][cur[1]];
if (!p || (p[0] === cur[0] && p[1] === cur[1])) break;
cur = p;
}
path.reverse();
if (path.length < 3) return [];
// count Zellen gleichmäßig auf dem Weg verteilen (ohne Start/Ziel)
const res: THREE.Vector3[] = [];
const used = new Set<string>();
for (let k = 1; k <= count; k++) {
const idx = Math.min(path.length - 2, Math.max(1, Math.round((k * path.length) / (count + 1))));
const [i, j] = path[idx];
const key = `${i},${j}`;
if (used.has(key)) continue;
used.add(key);
res.push(this.cellCenter(i, j));
}
return res;
}
dispose() {
for (const mesh of this.wallMeshes) {
this.scene.remove(mesh);
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
}
this.wallMeshes = [];
this.wallBoxes = [];
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Game } from './Game';
import { Ghost } from './Ghost';
const ACTIVATE_RADIUS = 8;
// Labyrinth-Wächter: steht still am Wegpunkt und greift erst an,
// wenn der Spieler in die Nähe kommt.
export class MazeGuard extends Ghost {
private activated = false;
protected chase(dt: number, game: Game) {
if (!this.activated) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < ACTIVATE_RADIUS) {
this.activated = true;
}
return;
}
super.chase(dt, game);
}
}
+769
View File
@@ -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;
}
}
+646
View File
@@ -0,0 +1,646 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { Ghost } from './Ghost';
import { Slime } from './Slime';
import { Spider } from './Spider';
import { Archer } from './Archer';
import { Shaman } from './Shaman';
import { Turtle } from './Turtle';
import { playSound } from './SoundManager';
const MAX_HEALTH = 13000;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 1.8;
const FLOAT_HEIGHT = 1.2;
const BOB_AMPLITUDE = 0.25;
const DRIFT_SPEED = 2.5;
const TELEPORT_COOLDOWN = 6;
const TELEPORT_COOLDOWN_2 = 4;
const TELEPORT_CHARGE = 1.0;
const TELEPORT_CHARGE_2 = 0.6;
const TELEPORT_RADIUS = 3.2;
const TELEPORT_DAMAGE = 15;
const TELEPORT_RING_PULSE = 6;
const VOLLEY_COOLDOWN = 4;
const VOLLEY_COOLDOWN_2 = 3;
const VOLLEY_COUNT = 5;
const VOLLEY_COUNT_2 = 7;
const VOLLEY_SPREAD = 0.4;
const BOLT_DAMAGE = 18;
const SUMMON_COOLDOWN = 8;
const SUMMON_COOLDOWN_2 = 6;
const SUMMON_COUNT = 3;
const SUMMON_COUNT_2 = 5;
const SUMMON_TELEGRAPH = 0.8;
const ENRAGE_RATIO = 0.4;
export class Necromancer extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private enraged = false;
private hitFlash = 0;
private robeMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private glowMat!: THREE.MeshBasicMaterial;
private eyeMat!: THREE.MeshBasicMaterial;
private shadow!: THREE.Mesh;
private shadowMat!: THREE.MeshBasicMaterial;
private teleportTimer = 3;
private volleyTimer = 1.5;
private summonTimer = 5;
private teleportState: 'idle' | 'charge' = 'idle';
private teleportCharge = 0;
private teleportTarget = new THREE.Vector3();
private teleportRing!: THREE.Group;
private teleportRingMesh!: THREE.Mesh;
private teleportRingDisc!: THREE.Mesh;
private teleportRingMat!: THREE.MeshBasicMaterial;
private teleportDiscMat!: THREE.MeshBasicMaterial;
private teleportRingInScene = false;
private spawnRings: { group: THREE.Group; timer: number; spawn: () => void }[] = [];
private arrivalFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Erz-Nekromant';
this.speed = 0;
this.xp = 320;
this.contactDps = 15;
this.contactRadius = 1.8;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
this.fadeOutModel = this.model;
this.body.add(this.model);
this.robeMat = new THREE.MeshToonMaterial({ color: 0x6a3a9a, emissive: 0x1a0a2a });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x2a1445 });
this.glowMat = new THREE.MeshBasicMaterial({ color: 0xbb66ff });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xdd88ff });
const boneMat = new THREE.MeshToonMaterial({ color: 0xf0e6cf });
const faceMat = new THREE.MeshBasicMaterial({ color: 0x0a0614 });
let boneFadeRegistered = false;
const registerBone = (mesh: THREE.Mesh) => {
// Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt)
if (!boneFadeRegistered) {
this.registerFadeMesh(mesh);
boneFadeRegistered = true;
}
};
// --- Robe (Blickrichtung +Z, schwebt: unten offen und zerfetzt) ---
const robeProfile = [
new THREE.Vector2(0.75, -1.15), // Saum
new THREE.Vector2(0.62, -0.75),
new THREE.Vector2(0.45, -0.2),
new THREE.Vector2(0.38, 0.25),
new THREE.Vector2(0.5, 0.55), // Schulteransatz
new THREE.Vector2(0.32, 0.78), // Hals
];
const robe = new THREE.Mesh(new THREE.LatheGeometry(robeProfile, 14), this.robeMat);
robe.castShadow = true;
this.registerFadeMesh(robe);
this.model.add(robe);
// Innenschatten im Saum (von unten sichtbar)
const hemInner = new THREE.Mesh(new THREE.CircleGeometry(0.7, 14), this.darkMat);
hemInner.rotation.x = Math.PI / 2;
hemInner.position.y = -1.08;
this.registerFadeMesh(hemInner);
this.model.add(hemInner);
// Zerfetzte Saumfetzen
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const tatter = new THREE.Mesh(
new THREE.ConeGeometry(0.08, 0.4, 4),
i % 2 === 0 ? this.robeMat : this.darkMat
);
tatter.rotation.x = Math.PI;
tatter.position.set(Math.sin(a) * 0.68, -1.28, Math.cos(a) * 0.68);
this.registerFadeMesh(tatter);
this.model.add(tatter);
}
// Gürtelkordel + Quaste
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.42, 0.035, 6, 16), this.darkMat);
belt.rotation.x = Math.PI / 2;
belt.position.y = 0.12;
this.registerFadeMesh(belt);
this.model.add(belt);
const tasselCord = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.28, 5), this.darkMat);
tasselCord.position.set(-0.3, -0.05, 0.36);
this.registerFadeMesh(tasselCord);
this.model.add(tasselCord);
const tassel = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.14, 5), boneMat);
tassel.rotation.x = Math.PI;
tassel.position.set(-0.3, -0.24, 0.38);
registerBone(tassel);
this.model.add(tassel);
// Oberkörper
const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), this.robeMat);
body.position.y = 0.6;
body.scale.set(1, 1.2, 1);
body.castShadow = true;
this.registerFadeMesh(body);
this.model.add(body);
// Kragen
const collar = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.045, 6, 14), this.darkMat);
collar.rotation.x = Math.PI / 2 - 0.2;
collar.position.set(0, 0.85, 0.08);
this.registerFadeMesh(collar);
this.model.add(collar);
// Schulterpanzer mit Spitzen
for (const side of [-1, 1]) {
const pauldron = new THREE.Mesh(new THREE.SphereGeometry(0.26, 12, 8), this.darkMat);
pauldron.scale.set(1.25, 0.65, 1.25);
pauldron.position.set(side * 0.45, 0.78, 0);
this.registerFadeMesh(pauldron);
this.model.add(pauldron);
const spike = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.22, 5), this.darkMat);
spike.position.set(side * 0.55, 0.95, 0);
spike.rotation.z = side * -0.5;
this.registerFadeMesh(spike);
this.model.add(spike);
}
// Kapuze mit Krempe und schwarzem Gesichts-Leerraum
const hood = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.95, 12, 1, true), this.darkMat);
hood.position.set(0, 1.18, 0.02);
hood.rotation.x = 0.22;
hood.castShadow = true;
this.registerFadeMesh(hood);
this.model.add(hood);
const hoodRim = new THREE.Mesh(new THREE.TorusGeometry(0.34, 0.05, 8, 16), this.darkMat);
hoodRim.rotation.x = -0.5;
hoodRim.position.set(0, 1.05, 0.34);
this.registerFadeMesh(hoodRim);
this.model.add(hoodRim);
const face = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), faceMat);
face.scale.set(1, 1.1, 0.8);
face.position.set(0, 1.04, 0.26);
this.registerFadeMesh(face);
this.model.add(face);
// Leuchtende Augen in der Kapuze (+Z)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.075, 8, 6), this.eyeMat);
eye.position.set(side * 0.12, 1.08, 0.48);
this.model.add(eye);
}
// Amulett + Runen auf der Robe (glühen, färben sich im Enrage)
const amulet = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), this.glowMat);
amulet.position.set(0, 0.6, 0.5);
this.model.add(amulet);
const runeSpots: [number, number, number][] = [
[0, -0.15, 0.5],
[-0.2, -0.5, 0.5],
[0.2, -0.5, 0.5],
];
for (const [x, y, z] of runeSpots) {
const rune = new THREE.Mesh(new THREE.OctahedronGeometry(0.06), this.glowMat);
rune.scale.set(1, 1.7, 0.5);
rune.position.set(x, y, z);
this.model.add(rune);
}
// Rechter Arm: Ärmel zum Stab
const sleeveR = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.16, 0.55, 8), this.robeMat);
sleeveR.position.set(0.65, 0.84, 0.14);
sleeveR.rotation.z = 1.2;
this.registerFadeMesh(sleeveR);
this.model.add(sleeveR);
const handR = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat);
handR.position.set(0.85, 0.9, 0.23);
registerBone(handR);
this.model.add(handR);
// Linker Arm: Ärmel nach vorn (Beschwörungs-Geste) + Energiekugel
const sleeveL = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.18, 0.6, 8), this.robeMat);
sleeveL.position.set(-0.55, 0.75, 0.32);
sleeveL.rotation.x = -1.25;
sleeveL.rotation.z = -0.35;
this.registerFadeMesh(sleeveL);
this.model.add(sleeveL);
const handL = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat);
handL.position.set(-0.68, 0.72, 0.62);
registerBone(handL);
this.model.add(handL);
const castOrb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.glowMat);
castOrb.position.set(-0.7, 0.92, 0.68);
this.model.add(castOrb);
// Stab mit Manschetten, Totenkopf und schwebendem Orb
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 2.4, 8), this.darkMat);
staff.position.set(0.8, 1.2, 0.28);
staff.rotation.z = 0.18;
staff.rotation.x = 0.15;
this.registerFadeMesh(staff);
this.model.add(staff);
for (const [x, y, z] of [[0.88, 0.76, 0.21], [0.96, 0.31, 0.15]] as const) {
const collarM = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.07, 8), this.darkMat);
collarM.position.set(x, y, z);
this.registerFadeMesh(collarM);
this.model.add(collarM);
}
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.15, 10, 8), boneMat);
skull.position.set(0.58, 2.42, 0.47);
registerBone(skull);
this.model.add(skull);
for (const side of [-1, 1]) {
const socket = new THREE.Mesh(new THREE.SphereGeometry(0.03, 6, 4), this.darkMat);
socket.position.set(0.58 + side * 0.05, 2.44, 0.6);
this.model.add(socket);
}
const orbMat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.14, 10, 8), orbMat);
orb.position.set(0.58, 2.66, 0.47);
this.model.add(orb);
this.orbMat = orbMat;
this.orb = orb;
// Boden-Schatten
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.9, 20), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
// Teleport-Telegraf: Ring + Scheibe am Zielpunkt
this.teleportRingMat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.teleportDiscMat = new THREE.MeshBasicMaterial({
color: 0x8833cc,
transparent: true,
opacity: 0,
depthWrite: false,
});
const ringMesh = new THREE.Mesh(
new THREE.RingGeometry(1.6, 2.0, 32),
this.teleportRingMat
);
ringMesh.rotation.x = -Math.PI / 2;
ringMesh.position.y = 0.05;
const disc = new THREE.Mesh(new THREE.CircleGeometry(1.8, 32), this.teleportDiscMat);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.03;
this.teleportRingMesh = ringMesh;
this.teleportRingDisc = disc;
this.teleportRing = new THREE.Group();
this.teleportRing.add(ringMesh, disc);
this.teleportRing.visible = false;
}
private orb!: THREE.Mesh;
private orbMat!: THREE.MeshBasicMaterial;
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Schweben
this.body.position.y = FLOAT_HEIGHT + Math.sin(this.waveTime * 2.2) * BOB_AMPLITUDE;
this.shadow.position.y = 0.06 - this.body.position.y;
const sway = Math.sin(this.waveTime * 1.5) * 0.06;
this.model.rotation.z = sway;
this.model.rotation.x = Math.sin(this.waveTime * 2.2) * 0.04;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.robeMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
this.robeMat.color.set(this.enraged ? 0x8a2233 : 0x6a3a9a);
this.darkMat.color.set(this.enraged ? 0x551520 : 0x2a1445);
this.model.scale.setScalar(MODEL_SCALE);
}
// Orb pulsiert
const orbPulse = 1 + Math.abs(Math.sin(this.waveTime * 5)) * 0.25;
this.orb.scale.setScalar(orbPulse);
this.orbMat.opacity = 0.6 + Math.abs(Math.sin(this.waveTime * 5)) * 0.3;
// Enrage unter 40% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff4422);
this.glowMat.color.set(0xff6644);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
// Zum Spieler driften (langsam)
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(DRIFT_SPEED);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-DRIFT_SPEED);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Teleport
if (this.teleportState === 'idle') {
this.teleportTimer -= dt;
if (this.teleportTimer <= 0) {
this.teleportTimer = this.enraged ? TELEPORT_COOLDOWN_2 : TELEPORT_COOLDOWN;
this.startTeleport(game);
}
} else {
this.updateTeleportCharge(dt, game);
}
// Salven
this.volleyTimer -= dt;
if (this.volleyTimer <= 0) {
this.volleyTimer = this.enraged ? VOLLEY_COOLDOWN_2 : VOLLEY_COOLDOWN;
this.fireVolley(game);
}
// Beschwörung
this.summonTimer -= dt;
if (this.summonTimer <= 0) {
this.summonTimer = this.enraged ? SUMMON_COOLDOWN_2 : SUMMON_COOLDOWN;
this.startSummon(game);
}
// Beschwörungs-Telegraphen
for (let i = this.spawnRings.length - 1; i >= 0; i--) {
const r = this.spawnRings[i];
r.timer -= dt;
if (r.timer <= 0) {
r.spawn();
game.scene.remove(r.group);
r.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
this.spawnRings.splice(i, 1);
} else {
const t = 1 - r.timer / SUMMON_TELEGRAPH;
r.group.scale.setScalar(0.5 + t * 1.2);
}
}
// Ankunfts-Blitz-Flächen
for (let i = this.arrivalFlashes.length - 1; i >= 0; i--) {
const f = this.arrivalFlashes[i];
f.life -= dt;
const t = 1 - Math.max(0, f.life) / 0.3;
f.mesh.scale.setScalar(0.5 + t * 5);
f.mat.opacity = 0.7 * (1 - t);
if (f.life <= 0) {
game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
this.arrivalFlashes.splice(i, 1);
}
}
}
// --- Teleport ---
private startTeleport(game: Game) {
this.teleportState = 'charge';
this.teleportCharge = this.enraged ? TELEPORT_CHARGE_2 : TELEPORT_CHARGE;
const playerPos = game.player.body.position;
// Ziel = aktuelle Spielerposition (leicht zufällig versetzt)
this.teleportTarget.set(
playerPos.x + (Math.random() - 0.5) * 2,
0,
playerPos.z + (Math.random() - 0.5) * 2
);
game.clampToArena(this.teleportTarget);
if (!this.teleportRingInScene) {
game.scene.add(this.teleportRing);
this.teleportRingInScene = true;
}
this.teleportRing.position.set(this.teleportTarget.x, 0, this.teleportTarget.z);
this.teleportRing.visible = true;
playSound('spawn', 0.7);
}
private updateTeleportCharge(dt: number, game: Game) {
this.teleportCharge -= dt;
const pulse = Math.abs(Math.sin(this.waveTime * TELEPORT_RING_PULSE));
this.teleportRingMat.opacity = 0.3 + 0.5 * pulse;
this.teleportDiscMat.opacity = 0.1 + 0.08 * pulse;
if (this.teleportCharge <= 0) {
this.teleportState = 'idle';
this.teleportRing.visible = false;
this.teleportRingMat.opacity = 0;
this.teleportDiscMat.opacity = 0;
// Blitz
this.body.position.x = this.teleportTarget.x;
this.body.position.z = this.teleportTarget.z;
playSound('teleport', 0.8);
// Ankunftsexplosion + Schaden, wenn der Spieler unter dem Zielpunkt steht
const flashMat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat);
flash.rotation.x = -Math.PI / 2;
flash.position.set(this.teleportTarget.x, 0.07, this.teleportTarget.z);
game.scene.add(flash);
this.arrivalFlashes.push({ mesh: flash, mat: flashMat, life: 0.3 });
if (game.player.health > 0 && game.player.body.position.y <= 0.8) {
const dx = game.player.body.position.x - this.teleportTarget.x;
const dz = game.player.body.position.z - this.teleportTarget.z;
if (Math.hypot(dx, dz) < TELEPORT_RADIUS) {
game.player.damage(TELEPORT_DAMAGE, this.teleportTarget);
}
}
// Salve vom neuen Standort
this.fireVolley(game);
}
}
// --- Projektilsalve ---
private fireVolley(game: Game) {
const from = new THREE.Vector3(this.body.position.x, FLOAT_HEIGHT + 0.6, this.body.position.z);
const target = game.player.body.position.clone();
const count = this.enraged ? VOLLEY_COUNT_2 : VOLLEY_COUNT;
const baseDir = new THREE.Vector3().subVectors(target, from);
baseDir.y = 0;
baseDir.normalize();
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
for (let i = 0; i < count; i++) {
const off = (i / (count - 1) - 0.5) * 2 * VOLLEY_SPREAD;
const dir = new THREE.Vector3()
.addScaledVector(baseDir, 1)
.addScaledVector(perp, off)
.normalize();
const t = from.clone().add(dir.multiplyScalar(25));
t.y = 0.5;
game.spawnEnemyProjectile(from, t, BOLT_DAMAGE);
}
playSound('spawn', 0.5);
}
// --- Beschwörung ---
private startSummon(game: Game) {
const count = this.enraged ? SUMMON_COUNT_2 : SUMMON_COUNT;
const types = [() => new Ghost(), () => new Slime(), () => new Spider(),
() => new Archer(), () => new Shaman(), () => new Turtle()];
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = 5 + Math.random() * 8;
const [x, z] = this.clampToArena(game,
this.body.position.x + Math.cos(a) * r,
this.body.position.z + Math.sin(a) * r
);
// Wachsender Boden-Glow als Telegraf
const mat = new THREE.MeshBasicMaterial({
color: 0xbb66ff,
transparent: true,
opacity: 0.35,
depthWrite: false,
});
const ring = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), mat);
ring.rotation.x = -Math.PI / 2;
ring.position.set(x, 0.05, z);
ring.scale.setScalar(0.5);
const group = new THREE.Group();
group.add(ring);
game.scene.add(group);
const type = types[Math.floor(Math.random() * types.length)];
this.spawnRings.push({
group,
timer: SUMMON_TELEGRAPH,
spawn: () => {
game.spawnEnemyInstance(type(), x, z, 0.1);
playSound('spawn', 0.5);
},
});
}
playSound('spawn', 0.6);
}
private clampToArena(game: Game, x: number, z: number): [number, number] {
const v = new THREE.Vector3(x, 0, z);
game.clampToArena(v);
return [v.x, v.z];
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
if (this.game) {
for (const r of this.spawnRings) {
this.game.scene.remove(r.group);
r.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
for (const f of this.arrivalFlashes) {
this.game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
}
}
this.spawnRings = [];
this.arrivalFlashes = [];
this.teleportState = 'idle';
this.teleportRing.visible = false;
if (this.teleportRingInScene && this.game) {
this.game.scene.remove(this.teleportRing);
}
this.teleportRingInScene = false;
this.robeMat.color.set(0x6a3a9a);
this.darkMat.color.set(0x2a1445);
this.eyeMat.color.set(0xdd88ff);
this.glowMat.color.set(0xbb66ff);
this.model.scale.setScalar(MODEL_SCALE);
}
}
+127 -3
View File
@@ -30,6 +30,13 @@ export class Player {
onGround = true; onGround = true;
shieldHp = 0; shieldHp = 0;
shieldTimer = 0; shieldTimer = 0;
slowTimer = 0;
slowFactor = 0.6;
pullTime = 0;
pullDir = new THREE.Vector3();
stunTime = 0;
rootTime = 0;
rootCooldown = 0;
stats: PlayerStats = { stats: PlayerStats = {
maxHealth: 100, maxHealth: 100,
regen: 0, regen: 0,
@@ -44,10 +51,32 @@ export class Player {
mixer!: THREE.AnimationMixer; mixer!: THREE.AnimationMixer;
private clips: Map<string, THREE.AnimationAction> = new Map(); private clips: Map<string, THREE.AnimationAction> = new Map();
private currentAnim = ''; 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() { constructor() {
this.body = new THREE.Group(); 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 // Helmet
const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2); const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2);
const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 }); const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 });
@@ -88,6 +117,49 @@ export class Player {
shadow.renderOrder = 999; shadow.renderOrder = 999;
this.body.add(shadow); 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 // Weapons
this.leftHandWeapon = new Staff(); this.leftHandWeapon = new Staff();
this.rightHandWeapon = new Sword(); this.rightHandWeapon = new Sword();
@@ -107,9 +179,10 @@ export class Player {
child.castShadow = true; child.castShadow = true;
child.receiveShadow = true; child.receiveShadow = true;
const mat = new THREE.MeshToonMaterial({ const mat = new THREE.MeshToonMaterial({
color: 0x44aa44, color: Player.BASE_COLOR,
}); });
child.material = mat; child.material = mat;
this.bodyMaterials.push(mat);
} }
}); });
this.body.add(model); this.body.add(model);
@@ -126,11 +199,12 @@ export class Player {
console.warn('Failed to load blob model, using fallback', e); console.warn('Failed to load blob model, using fallback', e);
// Fallback capsule // Fallback capsule
const bodyGeom = new THREE.CapsuleGeometry(0.5, 0.5, 8, 16); 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); const bodyMesh = new THREE.Mesh(bodyGeom, bodyMat);
bodyMesh.position.y = 0.75; bodyMesh.position.y = 0.75;
bodyMesh.castShadow = true; bodyMesh.castShadow = true;
this.body.add(bodyMesh); this.body.add(bodyMesh);
this.bodyMaterials.push(bodyMat);
} }
} }
@@ -164,6 +238,38 @@ export class Player {
updateAnimations(dt: number) { updateAnimations(dt: number) {
if (this.mixer) this.mixer.update(dt); 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) { setGame(game: Game) {
@@ -182,7 +288,7 @@ export class Player {
this.health = Math.min(this.health + amount, this.stats.maxHealth); this.health = Math.min(this.health + amount, this.stats.maxHealth);
} }
damage(amount: number) { damage(amount: number, sourcePos?: THREE.Vector3) {
if (this.shieldHp > 0) { if (this.shieldHp > 0) {
const absorbed = Math.min(this.shieldHp, amount); const absorbed = Math.min(this.shieldHp, amount);
this.shieldHp -= absorbed; this.shieldHp -= absorbed;
@@ -190,6 +296,13 @@ export class Player {
this.shieldTimer = this.stats.shieldRechargeDelay; this.shieldTimer = this.stats.shieldRechargeDelay;
} }
this.health -= amount; 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) { updatePassives(dt: number) {
@@ -206,6 +319,9 @@ export class Player {
this.shieldTimer = 0; this.shieldTimer = 0;
} }
} }
if (this.backShield) {
this.backShield.visible = this.stats.shield > 0 && this.shieldHp > 0;
}
} }
applySkillSystem(skills: SkillSystem) { applySkillSystem(skills: SkillSystem) {
@@ -232,6 +348,9 @@ export class Player {
if (this.stats.shield > 0 && this.shieldHp <= 0) { if (this.stats.shield > 0 && this.shieldHp <= 0) {
this.shieldHp = this.stats.shield; this.shieldHp = this.stats.shield;
} }
if (this.backShield) {
this.backShield.visible = this.stats.shield > 0 && this.shieldHp > 0;
}
} }
die() { die() {
@@ -242,6 +361,11 @@ export class Player {
this.health = 100; this.health = 100;
this.shieldHp = 0; this.shieldHp = 0;
this.shieldTimer = 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.body.position.set(0, 0.5, 0);
this.velocity.set(0, 0, 0); this.velocity.set(0, 0, 0);
this.jumpVelocity = 0; this.jumpVelocity = 0;
+327
View File
@@ -0,0 +1,327 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const HEAL_RADIUS = 8;
const HEAL_TICK = 0.9;
const HEAL_AMOUNT = 6;
const HIT_FLASH_TIME = 0.14;
// Schamane: heilt andere Gegner im Radius -> Prioritätsziel
export class Shaman extends Enemy {
private model: THREE.Group;
private robeMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private healRing!: THREE.Mesh;
private healRingMat!: THREE.MeshBasicMaterial;
private healTimer = 0.5;
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor() {
super();
this.health = 60;
this.speed = 2.8;
this.xp = 25;
this.contactDps = 10;
this.contactRadius = 1.8;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.robeMat = new THREE.MeshToonMaterial({ color: 0x33aa55, emissive: 0x0a1f10 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x1c5c2e });
const boneMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
const faceMat = new THREE.MeshBasicMaterial({ color: 0x06120a });
let boneFadeRegistered = false;
const registerBone = (mesh: THREE.Mesh) => {
// Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt)
if (!boneFadeRegistered) {
this.registerFadeMesh(mesh);
boneFadeRegistered = true;
}
};
// --- Robe (Blickrichtung +Z, bis zum Boden) ---
const robeProfile = [
new THREE.Vector2(0.52, 0.02), // Saum
new THREE.Vector2(0.44, 0.35),
new THREE.Vector2(0.32, 0.7),
new THREE.Vector2(0.3, 0.95),
new THREE.Vector2(0.38, 1.1), // Schulteransatz
new THREE.Vector2(0.24, 1.25), // Hals
];
const robe = new THREE.Mesh(new THREE.LatheGeometry(robeProfile, 12), this.robeMat);
robe.castShadow = true;
this.registerFadeMesh(robe);
this.model.add(robe);
// Zerfetzter Saum
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
const tatter = new THREE.Mesh(
new THREE.ConeGeometry(0.06, 0.28, 4),
i % 2 === 0 ? this.robeMat : this.darkMat
);
tatter.rotation.x = Math.PI;
tatter.position.set(Math.sin(a) * 0.48, -0.02, Math.cos(a) * 0.48);
this.registerFadeMesh(tatter);
this.model.add(tatter);
}
// Gürtel mit hängenden Knochen-Amuletten
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.33, 0.03, 6, 14), this.darkMat);
belt.rotation.x = Math.PI / 2;
belt.position.y = 0.75;
this.registerFadeMesh(belt);
this.model.add(belt);
for (const [x, z] of [[-0.14, 0.3], [0, 0.34], [0.14, 0.3]] as const) {
const charm = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.16, 5), boneMat);
charm.position.set(x, 0.62, z);
registerBone(charm);
this.model.add(charm);
}
// Brust + Fellkragen mit Zacken
const chest = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), this.robeMat);
chest.position.y = 1.12;
chest.scale.set(1, 1.15, 1);
this.registerFadeMesh(chest);
this.model.add(chest);
const collar = new THREE.Mesh(new THREE.TorusGeometry(0.26, 0.09, 8, 14), this.darkMat);
collar.rotation.x = Math.PI / 2;
collar.scale.set(1, 1, 0.8);
collar.position.y = 1.28;
this.registerFadeMesh(collar);
this.model.add(collar);
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const fur = new THREE.Mesh(new THREE.ConeGeometry(0.04, 0.16, 4), this.darkMat);
fur.position.set(Math.sin(a) * 0.28, 1.36, Math.cos(a) * 0.24);
fur.rotation.x = -0.4;
fur.rotation.y = a;
this.registerFadeMesh(fur);
this.model.add(fur);
}
// Knochenkette mit Zähnen auf der Brust
const necklace = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.02, 6, 12), this.darkMat);
necklace.rotation.x = Math.PI / 2 - 0.3;
necklace.position.set(0, 1.2, 0.2);
this.registerFadeMesh(necklace);
this.model.add(necklace);
for (const [x, z] of [[-0.08, 0.32], [0, 0.35], [0.08, 0.32]] as const) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.1, 4), boneMat);
tooth.rotation.x = Math.PI;
tooth.position.set(x, 1.1, z);
registerBone(tooth);
this.model.add(tooth);
}
// Kapuze mit Krempe, schwarzem Gesicht und grünen Augen (+Z)
const hood = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.6, 10, 1, true), this.darkMat);
hood.position.set(0, 1.55, 0.02);
hood.rotation.x = 0.2;
hood.castShadow = true;
this.registerFadeMesh(hood);
this.model.add(hood);
const hoodRim = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.04, 8, 14), this.darkMat);
hoodRim.rotation.x = -0.5;
hoodRim.position.set(0, 1.44, 0.22);
this.registerFadeMesh(hoodRim);
this.model.add(hoodRim);
const face = new THREE.Mesh(new THREE.SphereGeometry(0.2, 12, 10), faceMat);
face.scale.set(1, 1.1, 0.8);
face.position.set(0, 1.44, 0.16);
this.registerFadeMesh(face);
this.model.add(face);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x66ff88 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 5), eyeMat);
eye.position.set(side * 0.08, 1.47, 0.35);
this.model.add(eye);
}
// Federn an der Kapuze (hängen nach hinten)
for (const side of [-1, 1]) {
const feather = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.4, 4), boneMat);
feather.rotation.x = Math.PI + side * 0.15;
feather.scale.set(1, 1, 0.3);
feather.position.set(side * 0.14, 1.35, -0.22);
registerBone(feather);
this.model.add(feather);
}
// Rechter Arm zum Stab
const sleeveR = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.12, 0.45, 8), this.robeMat);
sleeveR.position.set(0.32, 1.05, 0.1);
sleeveR.rotation.z = 1.0;
this.registerFadeMesh(sleeveR);
this.model.add(sleeveR);
const handR = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), boneMat);
handR.position.set(0.45, 0.95, 0.18);
registerBone(handR);
this.model.add(handR);
// Linker Arm erhoben mit kleiner Energiekugel
const sleeveL = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.12, 0.45, 8), this.robeMat);
sleeveL.position.set(-0.34, 1.12, 0.18);
sleeveL.rotation.x = -1.1;
sleeveL.rotation.z = -0.5;
this.registerFadeMesh(sleeveL);
this.model.add(sleeveL);
const handL = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), boneMat);
handL.position.set(-0.4, 1.22, 0.34);
registerBone(handL);
this.model.add(handL);
const orbMat = new THREE.MeshBasicMaterial({
color: 0x66ff88,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const castOrb = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), orbMat);
castOrb.position.set(-0.42, 1.38, 0.38);
this.model.add(castOrb);
// Totem-Stab: Schädel mit Klauenkrone, Glüh-Orb, hängender Anhänger
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.04, 1.7, 6), this.darkMat);
staff.position.set(0.5, 0.85, 0.2);
staff.rotation.z = 0.15;
staff.rotation.x = 0.1;
this.registerFadeMesh(staff);
this.model.add(staff);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), boneMat);
skull.position.set(0.37, 1.72, 0.29);
registerBone(skull);
this.model.add(skull);
for (const side of [-1, 1]) {
const claw = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.22, 5), boneMat);
claw.position.set(0.37 + side * 0.1, 1.84, 0.29);
claw.rotation.z = side * -0.5;
registerBone(claw);
this.model.add(claw);
const socket = new THREE.Mesh(new THREE.SphereGeometry(0.025, 6, 4), this.darkMat);
socket.position.set(0.37 + side * 0.04, 1.74, 0.39);
this.model.add(socket);
}
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), orbMat);
orb.position.set(0.37, 1.94, 0.29);
this.model.add(orb);
const dangle = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.14, 5), boneMat);
dangle.position.set(0.44, 1.58, 0.27);
dangle.rotation.z = 0.4;
registerBone(dangle);
this.model.add(dangle);
// Heil-Aura-Ring am Boden
this.healRingMat = new THREE.MeshBasicMaterial({
color: 0x44ff77,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.healRing = new THREE.Mesh(
new THREE.RingGeometry(HEAL_RADIUS - 0.15, HEAL_RADIUS + 0.15, 32),
this.healRingMat
);
this.healRing.rotation.x = -Math.PI / 2;
this.healRing.position.y = 0.05;
this.body.add(this.healRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.0, sink: 0.5 });
}
update(dt: number, game: Game) {
this.time += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.robeMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
} else {
this.robeMat.color.set(0x33aa55);
this.darkMat.color.set(0x1c5c2e);
}
// Langsames Schwebe-Wippen
this.model.rotation.z = Math.sin(this.time * 1.5) * 0.04;
// Zum Spieler laufen
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(this.speed);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-this.speed);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Heil-Tick
this.healTimer -= dt;
if (this.healTimer <= 0) {
this.healTimer = HEAL_TICK;
let healed = false;
for (const e of game.enemies) {
if (e === this || e.dead) continue;
const d = Math.hypot(
e.body.position.x - this.body.position.x,
e.body.position.z - this.body.position.z
);
if (d < HEAL_RADIUS && e.health < e.maxHealth) {
e.health = Math.min(e.maxHealth, e.health + HEAL_AMOUNT);
healed = true;
}
}
if (healed) playSound('spawn', 0.35);
}
// Aura-Ring pulsiert mit dem Tick
const pulse = 0.15 + Math.max(0, this.healTimer / HEAL_TICK) * 0.25;
this.healRingMat.opacity = pulse;
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.healRing.geometry.dispose();
this.healRingMat.dispose();
}
}
+298
View File
@@ -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
View File
@@ -13,6 +13,8 @@ export interface SkillOffer {
nextLevel: number; nextLevel: number;
} }
export const HOTBAR_SKILLS = ['chainlightning', 'teleport', 'trap', 'gascloud'];
export const SKILLS: SkillDef[] = [ export const SKILLS: SkillDef[] = [
{ {
id: 'fireball', id: 'fireball',
@@ -20,7 +22,7 @@ export const SKILLS: SkillDef[] = [
type: 'active', type: 'active',
maxLevel: 5, maxLevel: 5,
describe: (l) => l === 1 describe: (l) => l === 1
? 'Schießt einen Feuerball (100 DMG, 5s CD)' ? 'Schießt einen Feuerball (85 DMG, 5.5s CD)'
: `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`, : `${fireballDamage(l)} DMG · ${fireballCooldown(l)}s CD${fireballRadius(l) > 8 ? ' · größere Explosion' : ''}`,
}, },
{ {
@@ -35,7 +37,7 @@ export const SKILLS: SkillDef[] = [
name: 'Kettenblitz', name: 'Kettenblitz',
type: 'active', type: 'active',
maxLevel: 5, maxLevel: 5,
describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD)`, describe: (l) => `Blitz springt auf ${lightningJumps(l)} Gegner (${lightningDamage(l)} DMG, ${lightningCooldown(l)}s CD, ${lightningJumpRange(l)} m Sprungweite)`,
}, },
{ {
id: 'swordRange', id: 'swordRange',
@@ -93,6 +95,36 @@ export const SKILLS: SkillDef[] = [
maxLevel: 3, maxLevel: 3,
describe: (l) => `Bewegungsgeschwindigkeit ${(7.5 + l).toFixed(1)}`, 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 { export function getSkill(id: string): SkillDef {
@@ -100,23 +132,26 @@ export function getSkill(id: string): SkillDef {
} }
export function fireballDamage(level: number): number { export function fireballDamage(level: number): number {
return [100, 150, 200, 250, 300][Math.min(level, 5) - 1] ?? 100; return [85, 125, 165, 205, 245][Math.min(level, 5) - 1] ?? 85;
} }
export function fireballCooldown(level: number): number { export function fireballCooldown(level: number): number {
return [5, 4.5, 4, 3.5, 3][Math.min(level, 5) - 1] ?? 5; return [5.5, 5, 4.5, 4, 3.5][Math.min(level, 5) - 1] ?? 5.5;
} }
export function fireballRadius(level: number): number { export function fireballRadius(level: number): number {
return [8, 8, 10, 10, 12][Math.min(level, 5) - 1] ?? 8; return [7, 7, 9, 9, 11][Math.min(level, 5) - 1] ?? 7;
} }
export function teleportCooldown(level: number): number { export function teleportCooldown(level: number): number {
return [50, 35, 20][Math.min(level, 3) - 1] ?? 50; return [50, 35, 20][Math.min(level, 3) - 1] ?? 50;
} }
export function lightningDamage(level: number): number { export function lightningDamage(level: number): number {
return [40, 60, 80, 100, 120][Math.min(level, 5) - 1] ?? 40; return [55, 75, 95, 115, 135][Math.min(level, 5) - 1] ?? 55;
} }
export function lightningJumps(level: number): number { export function lightningJumps(level: number): number {
return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2; return [2, 3, 4, 5, 6][Math.min(level, 5) - 1] ?? 2;
} }
export function lightningJumpRange(level: number): number {
return [9, 10, 11, 12, 13][Math.min(level, 5) - 1] ?? 9;
}
export function lightningCooldown(level: number): number { export function lightningCooldown(level: number): number {
return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8; return [8, 7, 6, 5, 4][Math.min(level, 5) - 1] ?? 8;
} }
@@ -126,6 +161,45 @@ export function swordRange(level: number): number {
export function swordDamage(level: number): number { export function swordDamage(level: number): number {
return 50 + 25 * level; 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 { function mulberry32(seed: number): () => number {
let a = seed >>> 0; let a = seed >>> 0;
@@ -155,22 +229,23 @@ export class SkillSystem {
} }
xpNeeded(): number { xpNeeded(): number {
return 20 + this.level * 10; return 30 + this.level * 15;
} }
xpProgress(): number { xpProgress(): number {
return this.xp / this.xpNeeded(); return this.xp / this.xpNeeded();
} }
addXp(amount: number): boolean { addXp(amount: number): number {
this.xp += amount; this.xp += amount;
if (!this.hasAnyUpgradeLeft()) return false; if (!this.hasAnyUpgradeLeft()) return 0;
if (this.xp >= this.xpNeeded()) { let leveled = 0;
while (this.xp >= this.xpNeeded()) {
this.xp -= this.xpNeeded(); this.xp -= this.xpNeeded();
this.level++; this.level++;
return true; leveled++;
} }
return false; return leveled;
} }
hasPendingLevelUp(): boolean { hasPendingLevelUp(): boolean {
+2 -2
View File
@@ -25,11 +25,11 @@ export class Slime extends Enemy {
this.registerFadeMesh(this.bodyMesh); this.registerFadeMesh(this.bodyMesh);
this.model.add(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 }); const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat); 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.registerFadeMesh(eye);
this.model.add(eye); this.model.add(eye);
} }
+27 -15
View File
@@ -16,29 +16,41 @@ export async function initSpiderModel() {
} }
} }
// Wiederverwendbarer Spinnen-Modell-Builder (für Spinne UND Spinnenkönigin).
// tint färbt die Textur mit ein (0xffffff = Original).
export function buildSpiderModel(scale: number, tint: number): THREE.Group | null {
if (!spiderGLTF) return null;
const model = cloneSkeleton(spiderGLTF.scene) as THREE.Group;
model.scale.set(scale, scale, scale);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture, color: tint });
}
});
return model;
}
// Animationen des Spinnen-Modells (leer, wenn nicht geladen)
export function getSpiderAnimations(): THREE.AnimationClip[] {
return spiderGLTF ? spiderGLTF.animations : [];
}
export class Spider extends Enemy { export class Spider extends Enemy {
constructor() { constructor() {
super(); super();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3); this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
if (spiderGLTF) { const model = buildSpiderModel(0.5, 0xffffff);
const model = cloneSkeleton(spiderGLTF.scene); if (model) {
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
model.scale.set(0.5, 0.5, 0.5);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
}
});
this.body.add(model); this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) { if (getSpiderAnimations().length > 0 && ANIMATIONS_ENABLED) {
this.mixer = new THREE.AnimationMixer(model); this.mixer = new THREE.AnimationMixer(model);
for (const clip of spiderGLTF.animations) { for (const clip of getSpiderAnimations()) {
const action = this.mixer.clipAction(clip); const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action); this.clips.set(clip.name, action);
} }
+408
View File
@@ -0,0 +1,408 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { Spider, buildSpiderModel, getSpiderAnimations } from './Spider';
import { playSound } from './SoundManager';
import { WebPatch } from './WebPatch';
const MAX_HEALTH = 8500;
const WEB_COOLDOWN = 4.5;
const SCREAM_COOLDOWN = 6;
const SPIT_COOLDOWN = 5;
const WEB_RADIUS = 2.2;
const WEB_DURATION = 3;
const WEB_DIRECT_ROOT = 0.5;
const SPIDERLING_COUNT = 3;
const SPIDERLING_COUNT_2 = 5;
const SPIDERLING_MAX_ALIVE = 8;
const SPIT_COUNT = 3;
const SPIT_COUNT_2 = 5;
const SPIT_DAMAGE = 15;
const ENRAGE_RATIO = 0.4;
const HIT_FLASH_TIME = 0.14;
const WEB_BLOB_SPEED = 7;
const WEB_BLOB_PROXIMITY = 1.3;
const WEB_BLOB_DAMAGE = 10;
export class SpiderQueen extends Enemy {
spawnAtCenter = true;
private game: Game | null = null;
private waveTime = 0;
private webTimer = 2;
private screamTimer = 4;
private spitTimer = 5;
private enraged = false;
private effectsReady = false;
private hitFlash = 0;
private model!: THREE.Group;
private tintMats: THREE.MeshToonMaterial[] = [];
private webBlobs: WebBlob[] = [];
private webs: WebPatch[] = [];
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Spinnenkönigin';
this.immovable = true;
this.speed = 0;
this.xp = 240;
this.contactDps = 0;
this.contactRadius = 0;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
// Königinnen-Modell: bestehende Spinne, größer + violett eingefärbt
const spiderModel = buildSpiderModel(3.2, 0x9a5ac8);
if (spiderModel) {
this.model.add(spiderModel);
spiderModel.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshToonMaterial) {
this.tintMats.push(child.material);
}
});
if (getSpiderAnimations().length > 0) {
this.mixer = new THREE.AnimationMixer(spiderModel);
for (const clip of getSpiderAnimations()) {
this.clips.set(clip.name, this.mixer.clipAction(clip));
}
this.playAnim('stand');
}
} else {
// Fallback (Modell nicht geladen): einfache große Spinne
const skinMat = new THREE.MeshToonMaterial({ color: 0x7a3a8a });
const darkMat = new THREE.MeshToonMaterial({ color: 0x4a2260 });
this.tintMats.push(skinMat, darkMat);
const abdomen = new THREE.Mesh(new THREE.SphereGeometry(1.6, 24, 18), skinMat);
abdomen.scale.set(1, 1.05, 1.35);
abdomen.position.set(0, 2.0, -1.7);
abdomen.castShadow = true;
this.registerFadeMesh(abdomen);
this.model.add(abdomen);
const cephalo = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 16), skinMat);
cephalo.scale.set(1, 0.85, 1.1);
cephalo.position.set(0, 1.9, 0.6);
cephalo.castShadow = true;
this.registerFadeMesh(cephalo);
this.model.add(cephalo);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.6, 16, 12), darkMat);
head.scale.set(1, 0.8, 0.9);
head.position.set(0, 2.0, 1.5);
this.registerFadeMesh(head);
this.model.add(head);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff3333 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), eyeMat);
eye.position.set(side * 0.32, 2.15, 1.85);
this.model.add(eye);
}
const legMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a });
this.tintMats.push(legMat);
for (let i = 0; i < 8; i++) {
const side = i < 4 ? -1 : 1;
const back = i % 2 === 0;
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 1.1, 6), legMat);
leg.position.set(side * (back ? 1.0 : 0.9), 0.6, back ? -0.9 : 0.4);
leg.rotation.z = side * 1.1;
this.registerFadeMesh(leg);
this.model.add(leg);
}
}
}
playAnim(name: string, loop = true, speed = 1) {
if (!this.clips.has(name) || this.currentAnim === name) return;
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.play();
}
updateAnimation(dt: number) {
if (this.mixer) this.mixer.update(dt);
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback: Materialien kurz weiß aufblitzen
if (this.hitFlash > 0) {
this.hitFlash -= dt;
for (const mat of this.tintMats) mat.color.set(0xffffff);
this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05);
} else {
for (const mat of this.tintMats) mat.color.set(this.enraged ? 0xff6644 : 0x9a5ac8);
this.model.scale.setScalar(1);
}
// Zum Spieler drehen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
if (toPlayer.length() > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
// Enrage unter 40% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
playSound('damage', 0.8);
game.triggerCameraShake(0.35, 0.4);
}
// Angriffe
this.webTimer -= dt;
if (this.webTimer <= 0) {
this.webTimer = this.enraged ? WEB_COOLDOWN * 0.65 : WEB_COOLDOWN;
this.spitWeb(game);
}
this.screamTimer -= dt;
if (this.screamTimer <= 0) {
this.screamTimer = this.enraged ? SCREAM_COOLDOWN * 0.6 : SCREAM_COOLDOWN;
this.scream(game);
}
this.spitTimer -= dt;
if (this.spitTimer <= 0) {
this.spitTimer = this.enraged ? SPIT_COOLDOWN * 0.7 : SPIT_COOLDOWN;
this.spitAcid(game);
}
// Netz-Bälle aktualisieren
for (let i = this.webBlobs.length - 1; i >= 0; i--) {
const blob = this.webBlobs[i];
if (!blob.update(dt, game)) {
this.webBlobs.splice(i, 1);
const patch = new WebPatch(
blob.body.position,
this.enraged ? WEB_RADIUS + 0.4 : WEB_RADIUS,
this.enraged ? WEB_DURATION * 1.5 : WEB_DURATION
);
game.scene.add(patch.body);
this.webs.push(patch);
blob.dispose();
playSound('explosion', 0.4);
}
}
// Netze aktualisieren
for (let i = this.webs.length - 1; i >= 0; i--) {
const web = this.webs[i];
if (!web.update(dt, game)) {
this.webs.splice(i, 1);
web.dispose();
}
}
}
private spitWeb(game: Game) {
const from = new THREE.Vector3(this.body.position.x, 2.2, this.body.position.z);
const target = game.player.body.position.clone();
target.y = 0;
const blob = new WebBlob(from, target);
game.scene.add(blob.body);
this.webBlobs.push(blob);
playSound('spawn', 0.5);
}
private scream(game: Game) {
// Nicht endlos viele Spinnlinge anhäufen
let alive = 0;
for (const e of game.enemies) {
if (e instanceof Spiderling && !e.dead) alive++;
}
const count = Math.min(
this.enraged ? SPIDERLING_COUNT_2 : SPIDERLING_COUNT,
SPIDERLING_MAX_ALIVE - alive
);
if (count <= 0) return;
for (let i = 0; i < count; i++) {
const mini = new Spiderling();
const a = Math.random() * Math.PI * 2;
const r = 4 + Math.random() * 4;
game.spawnEnemyInstance(
mini,
this.body.position.x + Math.cos(a) * r,
this.body.position.z + Math.sin(a) * r,
0.1
);
}
playSound('spawn', 0.7);
}
private spitAcid(game: Game) {
const from = new THREE.Vector3(this.body.position.x, 2.4, this.body.position.z);
const target = game.player.body.position.clone();
const count = this.enraged ? SPIT_COUNT_2 : SPIT_COUNT;
const spread = 0.35;
const baseDir = new THREE.Vector3().subVectors(target, from);
baseDir.y = 0;
baseDir.normalize();
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
for (let i = 0; i < count; i++) {
const off = (i / (count - 1) - 0.5) * 2 * spread;
const dir = new THREE.Vector3()
.addScaledVector(baseDir, 1)
.addScaledVector(perp, off)
.normalize();
const t = from.clone().add(dir.multiplyScalar(20));
t.y = 0;
game.spawnEnemyProjectile(from, t, SPIT_DAMAGE);
}
playSound('spawn', 0.6);
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
for (const blob of this.webBlobs) blob.dispose();
this.webBlobs = [];
for (const web of this.webs) web.dispose();
this.webs = [];
for (const mat of this.tintMats) mat.color.set(0x9a5ac8);
this.model.scale.setScalar(1);
}
}
// Spinnling: wie die normale Spinne, stirbt aber sichtbar (schrumpft + sinkt),
// damit keine stehenden Leichen in der Arena zurückbleiben.
export class Spiderling extends Spider {
private fadeDying = false;
private fadeDieTime = 0;
constructor() {
super();
this.health = 25;
this.speed = 4.2;
this.xp = 5;
this.contactDps = 12;
this.contactRadius = 1.5;
// Kein grace: Spider dekrementiert grace nie, sonst wären die Spinnlinge unverwundbar.
this.body.scale.setScalar(0.5);
}
protected onDeath(_game: Game) {
this.fadeDying = true;
}
updateAnimation(dt: number) {
super.updateAnimation(dt);
if (this.fadeDying) {
this.fadeDieTime += dt;
const t = Math.min(this.fadeDieTime / 0.5, 1);
this.body.scale.setScalar(0.5 * (1 - t));
this.body.position.y -= dt * 1.2;
}
}
}
// Netz-Ball: fliegt zum Spieler und hinterlässt beim Auftreffen ein WebPatch
class WebBlob {
body: THREE.Group;
target: THREE.Vector3;
private vel: THREE.Vector3;
private life = 6;
dead = false;
constructor(from: THREE.Vector3, target: THREE.Vector3) {
this.target = target.clone();
this.body = new THREE.Group();
this.body.position.copy(from);
this.vel = new THREE.Vector3().subVectors(target, from);
this.vel.y = 0;
const dist = this.vel.length();
if (dist > 0.01) {
this.vel.normalize().multiplyScalar(WEB_BLOB_SPEED);
}
const glowMat = new THREE.MeshBasicMaterial({
color: 0xddddff,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 12), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshToonMaterial({ color: 0xf0f0ff });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 10), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
this.body.position.x += this.vel.x * dt;
this.body.position.z += this.vel.z * dt;
this.body.position.y = 1.4 + Math.sin(this.life * 4) * 0.1;
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < WEB_BLOB_PROXIMITY && game.player.rootCooldown <= 0) {
game.player.rootTime = WEB_DIRECT_ROOT;
game.player.rootCooldown = 1.0;
game.player.damage(WEB_BLOB_DAMAGE, this.body.position);
}
}
const dx = this.target.x - this.body.position.x;
const dz = this.target.z - this.body.position.z;
if (this.life <= 0 || dx * dx + dz * dz < 0.25) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+314
View File
@@ -0,0 +1,314 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const SHOOT_INTERVAL = 2.5;
const SHOOT_RANGE = 18;
const BOLT_SPEED = 9;
const BOLT_DAMAGE = 12;
const BOLT_GRAVITY = 10;
const CLOUD_RADIUS = 2.4;
const CLOUD_DURATION = 3;
const CLOUD_TICK = 0.7;
const CLOUD_TICK_DAMAGE = 4;
const HIT_FLASH_TIME = 0.14;
// Sporen-Pilz: stationär, schießt Bogen-Sporen; am Einschlag entsteht eine Sporenwolke (DoT)
export class SporeMushroom extends Enemy {
private model: THREE.Group;
private stalkMat!: THREE.MeshToonMaterial;
private capMat!: THREE.MeshToonMaterial;
private glowMat!: THREE.MeshBasicMaterial;
private cap!: THREE.Mesh;
private shootTimer = 1 + Math.random();
private swayTime = Math.random() * Math.PI * 2;
private hitFlash = 0;
private bolts: SporeBolt[] = [];
private clouds: SporeCloud[] = [];
constructor() {
super();
this.health = 70;
this.speed = 0;
this.xp = 25;
this.contactDps = 0;
this.immovable = true;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.stalkMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
this.capMat = new THREE.MeshToonMaterial({ color: 0x55aa44 });
this.glowMat = new THREE.MeshBasicMaterial({ color: 0x88ff66 });
// Stiel
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.32, 0.9, 10), this.stalkMat);
stalk.position.y = 0.45;
stalk.castShadow = true;
this.registerFadeMesh(stalk);
this.model.add(stalk);
// Hut
this.cap = new THREE.Mesh(new THREE.SphereGeometry(0.6, 18, 14), this.capMat);
this.cap.scale.set(1, 0.7, 1);
this.cap.position.y = 1.05;
this.cap.castShadow = true;
this.registerFadeMesh(this.cap);
this.model.add(this.cap);
// Glüh-Flecken
for (const [dx, dz] of [[0, 0], [0.3, 0.12], [-0.28, 0.18], [0.05, -0.32]] as const) {
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.glowMat);
spot.scale.set(1, 0.4, 1);
spot.position.set(dx, 1.32, dz);
this.model.add(spot);
}
// Augen am Stiel (+Z)
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x224422 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 5), eyeMat);
eye.position.set(side * 0.16, 0.6, 0.3);
this.model.add(eye);
}
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.9, sink: 0.3 });
}
update(dt: number, game: Game) {
this.swayTime += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.capMat.color.set(0xffffff);
this.stalkMat.color.set(0xffffff);
} else {
this.capMat.color.set(0x55aa44);
this.stalkMat.color.set(0xe8dcc0);
}
// Leichtes Schwanken + Puls
this.model.rotation.z = Math.sin(this.swayTime * 1.5) * 0.05;
const pulse = 1 + Math.sin(this.swayTime * 4) * 0.05;
this.cap.scale.set(1, 0.7 * pulse, 1);
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
this.shootTimer -= dt;
if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) {
this.shootTimer = SHOOT_INTERVAL + Math.random() * 0.5;
const from = new THREE.Vector3(this.body.position.x, 1.3, this.body.position.z);
const target = playerPos.clone();
const bolt = new SporeBolt(from, target);
game.scene.add(bolt.body);
this.bolts.push(bolt);
playSound('spawn', 0.3);
}
for (let i = this.bolts.length - 1; i >= 0; i--) {
const bolt = this.bolts[i];
if (!bolt.update(dt, game)) {
this.bolts.splice(i, 1);
if (bolt.landed) {
const cloud = new SporeCloud(bolt.body.position, CLOUD_RADIUS, CLOUD_DURATION);
game.scene.add(cloud.body);
this.clouds.push(cloud);
}
bolt.dispose();
}
}
for (let i = this.clouds.length - 1; i >= 0; i--) {
const cloud = this.clouds[i];
if (!cloud.update(dt, game)) {
this.clouds.splice(i, 1);
cloud.dispose();
}
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
for (const bolt of this.bolts) bolt.dispose();
this.bolts = [];
for (const cloud of this.clouds) cloud.dispose();
this.clouds = [];
}
}
// Bogen-Spore: ballistisches Projektil (parabelförmig zum Zielpunkt)
class SporeBolt {
body: THREE.Group;
private vel: THREE.Vector3;
private life = 5;
dead = false;
landed = false;
constructor(from: THREE.Vector3, target: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.copy(from);
// Ballistik: Flugzeit aus horizontaler Distanz, Abwärts-Geschwindigkeit passend
const dx = target.x - from.x;
const dz = target.z - from.z;
const horiz = Math.hypot(dx, dz);
const t = Math.max(0.35, horiz / BOLT_SPEED);
const vy = (target.y - from.y + 0.5 * BOLT_GRAVITY * t * t) / t;
this.vel = new THREE.Vector3(dx / t, vy, dz / t);
const glowMat = new THREE.MeshBasicMaterial({
color: 0x88ff66,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.24, 8, 6), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshToonMaterial({ color: 0x3a8a2a });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.13, 6, 5), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
this.vel.y -= BOLT_GRAVITY * dt;
this.body.position.addScaledVector(this.vel, dt);
if (this.body.position.y <= 0.1) {
this.body.position.y = 0.1;
this.landed = true;
return false;
}
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < 1.0) {
game.player.damage(BOLT_DAMAGE, this.body.position);
this.landed = true;
return false;
}
}
if (this.life <= 0) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
// Sporenwolke: Boden-DoT wie die Tintenpfütze, aber grün
class SporeCloud {
body: THREE.Group;
private life: number;
private tickTimer = 0;
private puffs: THREE.Mesh[] = [];
constructor(position: THREE.Vector3, radius: number, duration: number) {
this.life = duration;
this.body = new THREE.Group();
this.body.position.set(position.x, 0.1, position.z);
const puffGeom = new THREE.SphereGeometry(1, 10, 8);
const puffMat = new THREE.MeshToonMaterial({
color: 0x55cc44,
transparent: true,
opacity: 0.32,
depthWrite: false,
});
for (let i = 0; i < 6; i++) {
const puff = new THREE.Mesh(puffGeom, puffMat);
const a = (i / 6) * Math.PI * 2;
puff.position.set(
Math.cos(a) * radius * 0.4,
0.35 + Math.random() * 0.3,
Math.sin(a) * radius * 0.4
);
puff.scale.setScalar(radius * (0.2 + Math.random() * 0.12));
this.body.add(puff);
this.puffs.push(puff);
}
}
update(dt: number, game: Game): boolean {
this.life -= dt;
if (this.life <= 0) return false;
const fade = Math.min(1, this.life / 0.6);
for (const puff of this.puffs) {
(puff.material as THREE.MeshToonMaterial).opacity = 0.32 * fade;
puff.rotation.y += dt * 0.3;
}
if (game.player.health > 0) {
const dist = Math.hypot(
game.player.body.position.x - this.body.position.x,
game.player.body.position.z - this.body.position.z
);
if (dist < 2.4) {
this.tickTimer -= dt;
if (this.tickTimer <= 0) {
this.tickTimer = CLOUD_TICK;
game.player.damage(CLOUD_TICK_DAMAGE, this.body.position);
}
}
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const puff of this.puffs) {
puff.geometry.dispose();
(puff.material as THREE.Material).dispose();
}
}
}
+108
View File
@@ -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();
}
}
}
}
+38
View File
@@ -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);
}
}
+1
View File
@@ -17,6 +17,7 @@ export class Turret extends Enemy {
this.speed = 0; this.speed = 0;
this.xp = 25; this.xp = 25;
this.contactDps = 0; this.contactDps = 0;
this.immovable = true;
this.model = new THREE.Group(); this.model = new THREE.Group();
this.fadeOutModel = this.model; this.fadeOutModel = this.model;
+230
View File
@@ -0,0 +1,230 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const STRAFE_DIST = 3.2;
const SHELL_IN = 0.4;
const SHELL_TIME = 2.0;
const SHELL_OUT = 0.4;
const SHELL_CYCLE = 7;
const HIT_FLASH_TIME = 0.14;
type State = 'strafe' | 'shellin' | 'shell' | 'shellout';
// Schildkröte: Frontpanzerung blockt Schaden komplett, zieht sich periodisch in den Panzer zurück
export class Turtle extends Enemy {
private model: THREE.Group;
private shellMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private skinMat!: THREE.MeshToonMaterial;
private shell!: THREE.Mesh;
private headMesh!: THREE.Mesh;
private legs: THREE.Mesh[] = [];
private eyeMat!: THREE.MeshBasicMaterial;
private state: State = 'strafe';
private stateTimer = 0;
private shellTimer = SHELL_CYCLE * 0.6;
private side = Math.random() < 0.5 ? 1 : -1;
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
constructor() {
super();
this.health = 160;
this.speed = 2.8;
this.xp = 30;
this.contactDps = 18;
this.contactRadius = 1.8;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.shellMat = new THREE.MeshToonMaterial({ color: 0x55aa55 });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x2f5c2f });
this.skinMat = new THREE.MeshToonMaterial({ color: 0x99bb77 });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0x223322 });
// Panzer (Kuppel)
this.shell = new THREE.Mesh(new THREE.SphereGeometry(0.85, 18, 12, 0, Math.PI * 2, 0, Math.PI / 2), this.shellMat);
this.shell.position.y = 0.6;
this.shell.castShadow = true;
this.registerFadeMesh(this.shell);
this.model.add(this.shell);
// Panzer-Platten
for (let i = 0; i < 4; i++) {
const plate = new THREE.Mesh(
new THREE.BoxGeometry(0.5, 0.08, 0.3),
this.darkMat
);
const a = (i / 4) * Math.PI * 2;
plate.position.set(Math.cos(a) * 0.55, 0.85, Math.sin(a) * 0.55);
plate.rotation.y = -a;
plate.scale.set(1.3, 1, 1);
this.registerFadeMesh(plate);
this.model.add(plate);
}
// Beine
for (const side of [-1, 1]) {
for (const back of [true, false]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 0.25, 6), this.skinMat);
leg.position.set(side * 0.55, 0.15, back ? -0.45 : 0.5);
this.registerFadeMesh(leg);
this.model.add(leg);
this.legs.push(leg);
}
}
// Kopf (+Z)
this.headMesh = new THREE.Mesh(new THREE.SphereGeometry(0.22, 10, 8), this.skinMat);
this.headMesh.scale.set(1, 0.8, 1.3);
this.headMesh.position.set(0, 0.35, 0.9);
this.registerFadeMesh(this.headMesh);
this.model.add(this.headMesh);
// Augen (+Z)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), this.eyeMat);
eye.position.set(side * 0.12, 0.45, 1.05);
this.model.add(eye);
}
// Schwanzstummel (-Z)
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.3, 6), this.skinMat);
tail.rotation.x = Math.PI / 2;
tail.position.set(0, 0.2, -0.95);
this.registerFadeMesh(tail);
this.model.add(tail);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.0, sink: 0.4 });
}
update(dt: number, game: Game) {
this.time += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.shellMat.color.set(0xffffff);
this.skinMat.color.set(0xffffff);
} else {
this.shellMat.color.set(0x55aa55);
this.skinMat.color.set(0x99bb77);
}
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
switch (this.state) {
case 'strafe':
this.stateTimer -= dt;
this.shellTimer -= dt;
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
if (this.knockback > 0) {
this.knockback -= dt;
const away = dir.clone().multiplyScalar(-this.speed);
this.body.position.x += away.x * dt;
this.body.position.z += away.z * dt;
} else {
// Seitwärts kreisen (Panzerfront zeigt zum Spieler)
if (Math.random() < dt * 0.25) this.side *= -1;
const perp = new THREE.Vector3(-dir.z, 0, dir.x);
const move = perp.multiplyScalar(this.side * this.speed);
this.body.position.x += move.x * dt;
this.body.position.z += move.z * dt;
}
game.clampToArena(this.body.position);
}
if (this.shellTimer <= 0) {
this.shellTimer = SHELL_CYCLE;
this.state = 'shellin';
this.stateTimer = SHELL_IN;
playSound('spawn', 0.4);
}
break;
case 'shellin':
this.stateTimer -= dt;
this.shell.scale.y = THREE.MathUtils.lerp(this.shell.scale.y, 1.25, 0.25);
this.headMesh.visible = this.shell.scale.y < 1.1;
if (this.stateTimer <= 0) {
this.state = 'shell';
this.stateTimer = SHELL_TIME;
this.shell.scale.y = 1.25;
this.headMesh.visible = false;
for (const leg of this.legs) leg.visible = false;
}
break;
case 'shell':
this.stateTimer -= dt;
if (this.stateTimer <= 0) {
this.state = 'shellout';
this.stateTimer = SHELL_OUT;
this.headMesh.visible = true;
for (const leg of this.legs) leg.visible = true;
}
break;
case 'shellout':
this.stateTimer -= dt;
this.shell.scale.y = THREE.MathUtils.lerp(this.shell.scale.y, 1, 0.25);
if (this.stateTimer <= 0) {
this.state = 'strafe';
this.shell.scale.y = 1;
}
break;
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
// Im Panzer komplett unverwundbar
if (this.state === 'shell' || this.state === 'shellin') return;
const src = sourcePos ?? game.player.body.position;
const front = new THREE.Vector3(0, 0, 1).applyQuaternion(this.body.quaternion);
front.y = 0;
front.normalize();
const incoming = new THREE.Vector3().subVectors(src, this.body.position);
incoming.y = 0;
if (incoming.lengthSq() < 0.0001) incoming.copy(front);
incoming.normalize();
// Frontpanzerung blockt kompletten Schaden
if (front.dot(incoming) > 0.3) {
game.spawnDamageText('PANZER!', this.body.position, '#88cc66');
playSound('sword_hit1', 0.5);
return;
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.beginFadeDeath();
}
}
+105
View File
@@ -0,0 +1,105 @@
import * as THREE from 'three';
import type { Game } from './Game';
const ROOT_DURATION = 0.35;
const ROOT_TICK = 1.0;
const ROOT_COOLDOWN = 1.0;
const FADE_TIME = 0.6;
export class WebPatch {
body: THREE.Group;
private radius: number;
private life: number;
private rootTick = 0;
private meshes: THREE.Mesh[] = [];
constructor(position: THREE.Vector3, radius = 3, duration = 6) {
this.radius = radius;
this.life = duration;
this.body = new THREE.Group();
this.body.position.set(position.x, 0.08, position.z);
const mat = new THREE.MeshToonMaterial({
color: 0xdfe6ff,
transparent: true,
opacity: 0.55,
depthWrite: false,
});
const disc = new THREE.Mesh(new THREE.CircleGeometry(radius, 24), mat);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.01;
this.body.add(disc);
this.meshes.push(disc);
// Radiale Fäden
const threadMat = new THREE.MeshToonMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const thread = new THREE.Mesh(
new THREE.BoxGeometry(radius * 2, 0.03, 0.05),
threadMat
);
thread.rotation.y = -a;
thread.position.y = 0.02;
this.body.add(thread);
this.meshes.push(thread);
}
// Konzentrische Ringe
for (let r = 1; r <= 3; r++) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(r * radius / 4 - 0.04, r * radius / 4 + 0.04, 24),
threadMat
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.025;
this.body.add(ring);
this.meshes.push(ring);
}
}
update(dt: number, game: Game): boolean {
this.life -= dt;
if (this.life <= 0) return false;
const fade = Math.min(1, this.life / FADE_TIME);
for (const mesh of this.meshes) {
(mesh.material as THREE.MeshToonMaterial).opacity =
(mesh === this.meshes[0] ? 0.55 : 0.7) * fade;
}
if (game.player.health > 0) {
const dist = Math.hypot(
game.player.body.position.x - this.body.position.x,
game.player.body.position.z - this.body.position.z
);
if (dist < this.radius) {
// Gepulste Fessel: kurzer Root, dann Lücke zum Rauslaufen.
// rootCooldown verhindert Endlos-Fessel durch überlappende Netze.
this.rootTick -= dt;
if (this.rootTick <= 0 && game.player.rootCooldown <= 0) {
game.player.rootTime = ROOT_DURATION;
game.player.rootCooldown = ROOT_COOLDOWN;
this.rootTick = ROOT_TICK;
}
} else {
this.rootTick = 0;
}
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const mesh of this.meshes) {
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
}
}
}
+257
View File
@@ -0,0 +1,257 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const IDLE_DIST_MIN = 7;
const IDLE_DIST_MAX = 11;
const SPORE_COOLDOWN = 3;
const SPORE_SPEED = 5;
const SPORE_HOMING = 1.6;
const SPORE_DAMAGE = 8;
const SPORE_SLOW = 1.5;
const SPORE_HIT_RADIUS = 1.0;
const SPORE_LIFE = 4;
const FLOAT_HEIGHT = 1.2;
const HIT_FLASH_TIME = 0.14;
// Irrlicht: schwebender Caster, schießt langsame Homing-Sporen (Slow)
export class Wisp extends Enemy {
private model: THREE.Group;
private glowMat!: THREE.MeshBasicMaterial;
private coreMat!: THREE.MeshToonMaterial;
private tailMat!: THREE.MeshBasicMaterial;
private tail!: THREE.Mesh;
private shadow!: THREE.Mesh;
private shadowMat!: THREE.MeshBasicMaterial;
private sporeTimer = 1.5 + Math.random();
private time = Math.random() * Math.PI * 2;
private hitFlash = 0;
private spores: WispSpore[] = [];
constructor() {
super();
this.health = 35;
this.speed = 3.8;
this.xp = 18;
this.contactDps = 8;
this.contactRadius = 1.6;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
this.glowMat = new THREE.MeshBasicMaterial({
color: 0xaaffcc,
transparent: true,
opacity: 0.55,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.45, 12, 10), this.glowMat);
this.model.add(glow);
this.coreMat = new THREE.MeshToonMaterial({ color: 0x66ffaa, emissive: 0x114422 });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.28, 10, 8), this.coreMat);
this.model.add(core);
// Schweif (verjüngter Kegel, wackelt)
this.tailMat = new THREE.MeshBasicMaterial({
color: 0x88ffbb,
transparent: true,
opacity: 0.4,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
this.tail = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.7, 6), this.tailMat);
this.tail.rotation.x = Math.PI / 2;
this.tail.position.set(0, 0, -0.55);
this.model.add(this.tail);
// Boden-Schatten
this.shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.22,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.4, 16), this.shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.renderOrder = 998;
this.body.add(this.shadow);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 0.8, shrink: 0.7 });
}
update(dt: number, game: Game) {
this.time += dt;
// Schweben + Pulsieren
this.body.position.y = FLOAT_HEIGHT + Math.sin(this.time * 2.4) * 0.25;
this.shadow.position.y = 0.06 - this.body.position.y;
const pulse = 1 + Math.sin(this.time * 5) * 0.15;
this.model.scale.setScalar(pulse);
this.glowMat.opacity = 0.4 + Math.abs(Math.sin(this.time * 5)) * 0.2;
this.tail.rotation.z = Math.sin(this.time * 3) * 0.3;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.coreMat.color.set(0xffffff);
} else {
this.coreMat.color.set(0x66ffaa);
}
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
// Abstand halten: schwebt in mittlerer Reichweite
let move = new THREE.Vector3();
if (dist < IDLE_DIST_MIN) {
move.copy(dir).multiplyScalar(-1);
} else if (dist > IDLE_DIST_MAX) {
move.copy(dir);
}
if (this.knockback > 0) {
move = dir.clone().multiplyScalar(-1);
this.knockback -= dt;
}
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar(this.speed);
this.body.position.x += move.x * dt;
this.body.position.z += move.z * dt;
game.clampToArena(this.body.position);
}
}
// Spore schießen
this.sporeTimer -= dt;
if (this.sporeTimer <= 0 && game.player.health > 0) {
this.sporeTimer = SPORE_COOLDOWN + Math.random() * 0.5;
const from = new THREE.Vector3(this.body.position.x, this.body.position.y, this.body.position.z);
const spore = new WispSpore(from);
game.scene.add(spore.body);
this.spores.push(spore);
playSound('spawn', 0.35);
}
// Sporen aktualisieren
for (let i = this.spores.length - 1; i >= 0; i--) {
if (!this.spores[i].update(dt, game)) {
this.spores[i].dispose();
this.spores.splice(i, 1);
}
}
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupSpores();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupSpores();
}
private cleanupSpores() {
for (const s of this.spores) s.dispose();
this.spores = [];
}
}
// Spore: langsames Homing-Projektil, verlangsamt beim Treffen
class WispSpore {
body: THREE.Group;
private vel: THREE.Vector3;
private life = SPORE_LIFE;
dead = false;
constructor(from: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.copy(from);
this.vel = new THREE.Vector3(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
);
const glowMat = new THREE.MeshBasicMaterial({
color: 0x88ffbb,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 8), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshToonMaterial({ color: 0x44cc77 });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
// Sanftes Homing auf den Spieler
if (game.player.health > 0) {
const toPlayer = new THREE.Vector3()
.subVectors(game.player.body.position, this.body.position)
.normalize();
const desired = toPlayer.multiplyScalar(SPORE_SPEED);
this.vel.lerp(desired, Math.min(1, dt * SPORE_HOMING));
}
this.body.position.addScaledVector(this.vel, dt);
this.body.position.y = Math.max(0.3, this.body.position.y);
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < SPORE_HIT_RADIUS) {
game.player.damage(SPORE_DAMAGE, this.body.position);
game.player.slowTimer = SPORE_SLOW;
this.dead = true;
return false;
}
}
if (this.life <= 0) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
// Modell-Viewer: rendert Gegner-/Boss-Modelle isoliert auf einer Bühne mit
// heller Beleuchtung und Turntable-Kamera — fuer visuelle Modell-Checks ohne
// Spiel-UI, Cheats oder Bosskaempfe. Siehe AGENTS.md "Modell-Viewer".
//
// URL: viewer.html?m=<id> Modell waehlen
// viewer.html?m=<id>&still=1 statische Pose (nur updateAnimation, kein update)
import * as THREE from 'three';
import type { Game } from './Game';
import type { Enemy } from './Enemy';
import { Spider, initSpiderModel } from './Spider';
import { Ghost } from './Ghost';
import { MazeGuard } from './MazeGuard';
import { Frog } from './Frog';
import { Bat } from './Bat';
import { BeeSwarm } from './BeeSwarm';
import { Slime } from './Slime';
import { Brute } from './Brute';
import { Golem } from './Golem';
import { Crab } from './Crab';
import { Turret } from './Turret';
import { Shaman } from './Shaman';
import { Archer } from './Archer';
import { Bomber } from './Bomber';
import { Wisp } from './Wisp';
import { Turtle } from './Turtle';
import { SporeMushroom } from './SporeMushroom';
import { Krake } from './Krake';
import { Minotaurus } from './Minotaurus';
import { SpiderQueen } from './SpiderQueen';
import { FrostGiant } from './FrostGiant';
import { Necromancer } from './Necromancer';
import { Dragon } from './Dragon';
import { Gnome } from './Gnome';
const VIEWER_MODELS: { id: string; create: () => Enemy | Promise<Enemy> }[] = [
{ id: 'spider', create: async () => { await initSpiderModel(); return new Spider(); } },
{ id: 'ghost', create: () => new Ghost() },
{ id: 'frog', create: () => new Frog() },
{ id: 'bat', create: () => new Bat() },
{ id: 'bee', create: () => new BeeSwarm() },
{ id: 'slime', create: () => new Slime() },
{ id: 'brute', create: () => new Brute() },
{ id: 'crab', create: () => new Crab() },
{ id: 'turret', create: () => new Turret() },
{ id: 'shaman', create: () => new Shaman() },
{ id: 'archer', create: () => new Archer() },
{ id: 'bomber', create: () => new Bomber() },
{ id: 'wisp', create: () => new Wisp() },
{ id: 'turtle', create: () => new Turtle() },
{ id: 'spore', create: () => new SporeMushroom() },
{ id: 'golem', create: () => new Golem() },
{ id: 'krake', create: () => new Krake() },
{ id: 'minotaurus', create: () => new Minotaurus() },
{ id: 'spiderqueen', create: async () => { await initSpiderModel(); return new SpiderQueen(); } },
{ id: 'frostgiant', create: () => new FrostGiant() },
{ id: 'necromancer', create: () => new Necromancer() },
{ id: 'dragon', create: () => new Dragon() },
{ id: 'gnome', create: () => new Gnome(250, 90) },
{ id: 'mazeguard', create: () => new MazeGuard() },
];
// --- Buehne ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a2030);
const camera = new THREE.PerspectiveCamera(
50, window.innerWidth / window.innerHeight, 0.1, 300
);
// Bewusst heller als das Spiel, damit Formen gut sichtbar sind
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(8, 14, 6);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.left = -16;
dirLight.shadow.camera.right = 16;
dirLight.shadow.camera.top = 16;
dirLight.shadow.camera.bottom = -16;
scene.add(dirLight);
const ground = new THREE.Mesh(
new THREE.CircleGeometry(40, 48),
new THREE.MeshLambertMaterial({ color: 0x2a3a2a })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(80, 80, 0x44608a, 0x334455);
grid.position.y = 0.02;
scene.add(grid);
// --- Stub-Spieler (8 m entfernt: Blick-/Angriffsziel) ---
// Nur ein flacher Boden-Ring als Markierung — eine Kugel wuerde die Sicht
// auf Modelle verdecken, wenn Mobs hineingelaufen kommen.
const stubPlayer = {
body: new THREE.Group(),
health: 1000,
pullTime: 0,
pullDir: new THREE.Vector3(),
stunTime: 0,
rootTime: 0,
rootCooldown: 0,
slowTimer: 0,
damage: () => {},
};
stubPlayer.body.position.set(0, 0, 8);
const playerMarker = new THREE.Mesh(
new THREE.RingGeometry(0.5, 0.8, 24),
new THREE.MeshBasicMaterial({ color: 0x44aa44, side: THREE.DoubleSide })
);
playerMarker.rotation.x = -Math.PI / 2;
playerMarker.position.y = 0.05;
stubPlayer.body.add(playerMarker);
scene.add(stubPlayer.body);
// --- Stub-Game: deckt die game.*-Oberflaeche aller Gegner ab (per grep ermittelt) ---
const stubGame = {
scene,
player: stubPlayer,
enemies: [],
traps: [],
gasClouds: [],
fireballs: [],
slashEffects: [],
maze: null,
groundPoint: stubPlayer.body.position,
clampToArena: (v: THREE.Vector3) => v,
triggerCameraShake: () => {},
triggerInkVignette: () => {},
spawnDamageText: () => {},
spawnEnemyProjectile: () => {},
spawnEnemyInstance: () => {},
removeEnemy: () => {},
onDamageDealt: () => {},
onPlayerHurt: () => {},
destroyProjectilesInArc: () => {},
} as unknown as Game;
// --- Modell-Wechsel + Turntable ---
const params = new URLSearchParams(location.search);
const still = params.has('still');
let current: Enemy | null = null;
let orbitAngle = 0;
let fitRadius = 8;
let fitHeight = 2;
let fitCenterY = 1;
let fitTimer = 0;
let frame = 0;
const nameEl = document.getElementById('name')!;
const uiEl = document.getElementById('ui')!;
function refit() {
if (!current) return;
// Auto-Fit nur ueber Modell-Meshes: grosse Effekt-Geometrien (Heil-Ring,
// Telegrafen) wuerden sonst die Kamera-Distanz dominieren
const box = new THREE.Box3();
const tmp = new THREE.Box3();
current.body.updateMatrixWorld(true);
current.body.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
if (!obj.geometry.boundingSphere) obj.geometry.computeBoundingSphere();
if ((obj.geometry.boundingSphere?.radius ?? 0) < 5) {
tmp.setFromObject(obj);
box.union(tmp);
}
}
});
if (box.isEmpty()) return;
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z, 2);
fitRadius = maxDim * 1.15 + 3;
// deutlich erhoehte Kamera, damit Fluegel/Flaechen nicht kantenzeitig wirken
fitHeight = center.y + maxDim * 1.0;
fitCenterY = center.y;
}
async function show(id: string) {
const entry = VIEWER_MODELS.find((m) => m.id === id) ?? VIEWER_MODELS[0];
if (current) {
try {
current.dispose();
} catch {
// dispose ist nur Kosmetik
}
scene.remove(current.body);
current = null;
}
current = await entry.create();
scene.add(current.body);
refit();
nameEl.textContent = current.displayName || entry.id;
history.replaceState(null, '', `?m=${entry.id}${still ? '&still=1' : ''}`);
for (const btn of uiEl.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.id === entry.id);
}
}
for (const m of VIEWER_MODELS) {
const btn = document.createElement('button');
btn.textContent = m.id;
btn.dataset.id = m.id;
btn.addEventListener('click', () => show(m.id));
uiEl.appendChild(btn);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
if (current) {
if (!still) current.update(dt, stubGame);
current.updateAnimation(dt);
// Auto-Fit alle 30 Frames nachjustieren (States aendern die Ausdehnung)
fitTimer += dt;
if (frame % 30 === 0 || fitTimer > 2) {
fitTimer = 0;
refit();
}
orbitAngle += dt * 0.35;
const c = current.body.position;
camera.position.set(
c.x + Math.sin(orbitAngle) * fitRadius,
fitHeight,
c.z + Math.cos(orbitAngle) * fitRadius
);
camera.lookAt(c.x, fitCenterY * 0.85, c.z);
}
frame++;
renderer.render(scene, camera);
}
show(params.get('m') ?? 'dragon');
animate();
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wackelpeter Modell-Viewer</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1a2030; }
canvas { display: block; }
#ui {
position: absolute; top: 10px; left: 10px; max-width: 320px;
display: flex; flex-wrap: wrap; gap: 4px; align-content: flex-start;
font-family: monospace; pointer-events: none;
}
#name {
width: 100%; color: #ffd54a; font-size: 16px; margin: 0 0 6px;
text-shadow: 0 1px 2px #000;
}
#ui button {
pointer-events: auto; cursor: pointer; font-family: monospace; font-size: 11px;
color: #dfe8ff; background: #2a3550cc; border: 1px solid #4a5a80;
padding: 3px 8px; border-radius: 3px;
}
#ui button:hover { background: #3a4a70; }
#ui button.active { background: #6a5a20; border-color: #ffd54a; }
#hint { width: 100%; color: #8899bb; font-size: 11px; margin-top: 6px; }
</style>
</head>
<body>
<div id="ui">
<h1 id="name"></h1>
<div id="hint">?m=&lt;id&gt; Modell · &amp;still=1 statische Pose</div>
</div>
<script type="module" src="/src/viewer.ts"></script>
</body>
</html>
+6
View File
@@ -5,6 +5,12 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
assetsDir: 'assets', assetsDir: 'assets',
rollupOptions: {
input: {
main: 'index.html',
viewer: 'viewer.html',
},
},
}, },
server: { server: {
host: true, host: true,