Compare commits
12
Commits
6f207ba1a0
...
151b07cb4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151b07cb4e | ||
|
|
1f67aaa982 | ||
|
|
7d41f6db6c | ||
|
|
496c4af50e | ||
|
|
1a4cccda6e | ||
|
|
5ceb37fdb4 | ||
|
|
18d8654d9d | ||
|
|
110a8596a5 | ||
|
|
64d473de6d | ||
|
|
fd57ca9740 | ||
|
|
3fde0925c5 | ||
|
|
9e3e5c70e0 |
@@ -5,3 +5,52 @@
|
|||||||
- 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.
|
||||||
|
|
||||||
|
## 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` | Bosse (Welle 10 / 20 / 30), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
|
||||||
|
| `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf |
|
||||||
|
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` |
|
||||||
|
| `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).
|
||||||
|
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung) und `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf). Alles wird in `Player.reset()` zurückgesetzt.
|
||||||
|
- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig).
|
||||||
|
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
|
||||||
|
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
# ── Wackelpeter container build/deploy ──────────────────────────────────────
|
# ── 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) .
|
||||||
|
|||||||
@@ -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/1–4 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, 1–4
|
||||||
|
verteilt; Cooldown-Anzeigen zeigen Taste + Name, Level-up-Karten die
|
||||||
|
künftige Taste.
|
||||||
|
- **Schadens-Feedback**: Blutpartikel, roter Vignette-Flash, Blob blitzt rot
|
||||||
|
auf, ab <30 % HP pulsierende rote Vignette.
|
||||||
|
|
||||||
## Verzeichnisse
|
## 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`)
|
||||||
|
|||||||
+153
-9
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -77,7 +129,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 +166,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 +233,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 +251,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 +261,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 +283,13 @@
|
|||||||
</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="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 +326,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 +351,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 +374,51 @@
|
|||||||
<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, 1–4: 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">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 |
+101
@@ -0,0 +1,101 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { auraRadius, auraDamage, auraPush } from './Skills';
|
||||||
|
|
||||||
|
const TICK = 0.5;
|
||||||
|
|
||||||
|
export class Aura {
|
||||||
|
private group: THREE.Group;
|
||||||
|
private fill: THREE.Mesh;
|
||||||
|
private ring: THREE.Mesh;
|
||||||
|
private tickTimer = 0;
|
||||||
|
private pulse = 0;
|
||||||
|
|
||||||
|
constructor(scene: THREE.Scene) {
|
||||||
|
this.group = new THREE.Group();
|
||||||
|
|
||||||
|
this.fill = new THREE.Mesh(
|
||||||
|
new THREE.CircleGeometry(1, 48),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x66ccff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.12,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.fill.rotation.x = -Math.PI / 2;
|
||||||
|
this.fill.position.y = 0.05;
|
||||||
|
this.fill.renderOrder = 998;
|
||||||
|
this.group.add(this.fill);
|
||||||
|
|
||||||
|
this.ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(0.93, 1, 48),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x88ddff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.5,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.ring.rotation.x = -Math.PI / 2;
|
||||||
|
this.ring.position.y = 0.06;
|
||||||
|
this.ring.renderOrder = 999;
|
||||||
|
this.group.add(this.ring);
|
||||||
|
|
||||||
|
scene.add(this.group);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
const level = game.skillSystem.getLevel('aura');
|
||||||
|
if (level <= 0) return;
|
||||||
|
|
||||||
|
const radius = auraRadius(level);
|
||||||
|
this.group.position.x = game.player.body.position.x;
|
||||||
|
this.group.position.z = game.player.body.position.z;
|
||||||
|
this.group.scale.setScalar(radius);
|
||||||
|
|
||||||
|
if (this.pulse > 0) {
|
||||||
|
this.pulse -= dt * 2.5;
|
||||||
|
const p = Math.max(0, this.pulse);
|
||||||
|
const s = 1 + p * 0.12;
|
||||||
|
this.fill.scale.setScalar(s);
|
||||||
|
this.ring.scale.setScalar(s);
|
||||||
|
(this.ring.material as THREE.MeshBasicMaterial).opacity = 0.5 + p * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tickTimer -= dt;
|
||||||
|
if (this.tickTimer > 0) return;
|
||||||
|
this.tickTimer = TICK;
|
||||||
|
|
||||||
|
const damage = auraDamage(level);
|
||||||
|
const push = auraPush(level);
|
||||||
|
const center = game.player.body.position;
|
||||||
|
for (const enemy of game.enemies) {
|
||||||
|
if (enemy.dead) continue;
|
||||||
|
const dist = enemy.body.position.distanceTo(center);
|
||||||
|
if (dist > radius + 0.4) continue;
|
||||||
|
enemy.takeDamage(damage, game, false, center);
|
||||||
|
// Stationaere Gegner (Bosse, Tuerme) werden nur geschaedigt, nicht geschoben
|
||||||
|
if (enemy.immovable) continue;
|
||||||
|
const dir = new THREE.Vector3()
|
||||||
|
.subVectors(enemy.body.position, center);
|
||||||
|
dir.y = 0;
|
||||||
|
if (dir.lengthSq() < 0.01) {
|
||||||
|
dir.set(Math.random() - 0.5, 0, Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
dir.normalize();
|
||||||
|
enemy.body.position.addScaledVector(dir, push);
|
||||||
|
game.clampToArena(enemy.body.position);
|
||||||
|
}
|
||||||
|
this.pulse = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(scene: THREE.Scene) {
|
||||||
|
scene.remove(this.group);
|
||||||
|
this.fill.geometry.dispose();
|
||||||
|
(this.fill.material as THREE.Material).dispose();
|
||||||
|
this.ring.geometry.dispose();
|
||||||
|
(this.ring.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -28,15 +28,15 @@ export class Bat extends Enemy {
|
|||||||
this.registerFadeMesh(bodyMesh);
|
this.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -53,11 +53,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
|
||||||
|
const TELEGRAPH_TIME = 0.9;
|
||||||
|
const IMPACT_RADIUS = 1.8;
|
||||||
|
const ROCK_DAMAGE = 30;
|
||||||
|
const DUST_TIME = 0.4;
|
||||||
|
|
||||||
|
export class FallingRock {
|
||||||
|
body: THREE.Group;
|
||||||
|
private shadow: THREE.Mesh;
|
||||||
|
private rock: THREE.Mesh;
|
||||||
|
private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph';
|
||||||
|
private timer = TELEGRAPH_TIME;
|
||||||
|
private dust: THREE.Mesh[] = [];
|
||||||
|
private dustDirs: THREE.Vector3[] = [];
|
||||||
|
private dustMat: THREE.MeshBasicMaterial;
|
||||||
|
|
||||||
|
constructor(pos: THREE.Vector3) {
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.set(pos.x, 0, pos.z);
|
||||||
|
|
||||||
|
// Schatten-Telegraf (waechst kurz vor dem Aufprall)
|
||||||
|
const shadowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x000000,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.3,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), shadowMat);
|
||||||
|
this.shadow.rotation.x = -Math.PI / 2;
|
||||||
|
this.shadow.position.y = 0.06;
|
||||||
|
this.shadow.scale.setScalar(0.4);
|
||||||
|
this.body.add(this.shadow);
|
||||||
|
|
||||||
|
// Fels von der Decke
|
||||||
|
const rockMat = new THREE.MeshToonMaterial({ color: 0x777788 });
|
||||||
|
this.rock = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.9, 0.9), rockMat);
|
||||||
|
this.rock.position.y = 16;
|
||||||
|
this.rock.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
|
||||||
|
this.body.add(this.rock);
|
||||||
|
|
||||||
|
this.dustMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x888899,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.timer -= dt;
|
||||||
|
|
||||||
|
if (this.phase === 'telegraph') {
|
||||||
|
const t = 1 - this.timer / TELEGRAPH_TIME;
|
||||||
|
this.shadow.scale.setScalar(0.4 + t * 0.6);
|
||||||
|
(this.shadow.material as THREE.MeshBasicMaterial).opacity = 0.2 + t * 0.2;
|
||||||
|
if (this.timer <= 0) {
|
||||||
|
this.phase = 'fall';
|
||||||
|
this.timer = 2;
|
||||||
|
}
|
||||||
|
} else if (this.phase === 'fall') {
|
||||||
|
this.rock.position.y -= 26 * dt;
|
||||||
|
this.rock.rotation.x += dt * 3;
|
||||||
|
this.rock.rotation.z += dt * 2;
|
||||||
|
if (this.rock.position.y <= 0.15) {
|
||||||
|
this.rock.position.y = 0.15;
|
||||||
|
this.rock.visible = false;
|
||||||
|
this.shadow.visible = false;
|
||||||
|
|
||||||
|
if (game.player.health > 0) {
|
||||||
|
const dx = game.player.body.position.x - this.body.position.x;
|
||||||
|
const dz = game.player.body.position.z - this.body.position.z;
|
||||||
|
if (Math.hypot(dx, dz) < IMPACT_RADIUS) {
|
||||||
|
game.player.damage(ROCK_DAMAGE, this.body.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staubwolke
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const a = (i / 5) * Math.PI * 2;
|
||||||
|
const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a));
|
||||||
|
const puff = new THREE.Mesh(new THREE.SphereGeometry(0.25, 6, 5), this.dustMat);
|
||||||
|
this.body.add(puff);
|
||||||
|
this.dust.push(puff);
|
||||||
|
this.dustDirs.push(dir);
|
||||||
|
}
|
||||||
|
this.phase = 'dust';
|
||||||
|
this.timer = DUST_TIME;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const t = 1 - this.timer / DUST_TIME;
|
||||||
|
for (let i = 0; i < this.dust.length; i++) {
|
||||||
|
this.dust[i].position.copy(this.dustDirs[i]).multiplyScalar(0.5 + t * 1.8);
|
||||||
|
this.dust[i].position.y = 0.2 + t * 0.8;
|
||||||
|
}
|
||||||
|
this.dustMat.opacity = 0.5 * (1 - t);
|
||||||
|
if (this.timer <= 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.body.removeFromParent();
|
||||||
|
this.body.traverse((obj) => {
|
||||||
|
if (obj instanceof THREE.Mesh) {
|
||||||
|
obj.geometry.dispose();
|
||||||
|
(obj.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -52,18 +52,18 @@ export class Frog extends Enemy {
|
|||||||
// Belly
|
// 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);
|
||||||
|
|||||||
+582
-60
@@ -8,7 +8,9 @@ import { Bat } from './Bat';
|
|||||||
import { BeeSwarm } from './BeeSwarm';
|
import { BeeSwarm } from './BeeSwarm';
|
||||||
import { Slime } from './Slime';
|
import { Slime } from './Slime';
|
||||||
import { Brute } from './Brute';
|
import { Brute } from './Brute';
|
||||||
import { Boss } from './Boss';
|
import { Golem } from './Golem';
|
||||||
|
import { Krake } from './Krake';
|
||||||
|
import { Minotaurus } from './Minotaurus';
|
||||||
import { Crab } from './Crab';
|
import { Crab } from './Crab';
|
||||||
import { Turret } from './Turret';
|
import { Turret } from './Turret';
|
||||||
import { EnemyProjectile } from './EnemyProjectile';
|
import { EnemyProjectile } from './EnemyProjectile';
|
||||||
@@ -18,11 +20,21 @@ import { Fireball } from './Fireball';
|
|||||||
import { Cross, initCrossModel } from './Cross';
|
import { Cross, initCrossModel } from './Cross';
|
||||||
import { Arena } from './Arena';
|
import { Arena } from './Arena';
|
||||||
import { SlashEffect, isInSlashArc } from './SwordTrail';
|
import { SlashEffect, isInSlashArc } from './SwordTrail';
|
||||||
import { SkillSystem, getSkill, type SkillOffer } from './Skills';
|
import { SkillSystem, getSkill, HOTBAR_SKILLS, type SkillOffer } from './Skills';
|
||||||
|
import { skillIcon } from './SkillIcons';
|
||||||
import { initSounds, playSound, resumeContext } from './SoundManager';
|
import { initSounds, playSound, resumeContext } from './SoundManager';
|
||||||
|
import { Aura } from './Aura';
|
||||||
|
import { Boomerang } from './Boomerang';
|
||||||
|
import { Trap } from './Trap';
|
||||||
|
import { GasCloud } from './GasCloud';
|
||||||
|
import { Trapper } from './Trapper';
|
||||||
|
import { DamageNumber } from './DamageNumber';
|
||||||
|
|
||||||
const SPAWN_TIME = 10;
|
const SPAWN_TIME = 10;
|
||||||
|
|
||||||
|
const HOTBAR_KEY_CODES = ['KeyQ', 'KeyE', 'Digit1', 'Digit2', 'Digit3', 'Digit4'];
|
||||||
|
const HOTBAR_KEY_LABELS = ['Q', 'E', '1', '2', '3', '4'];
|
||||||
|
|
||||||
type GameState = 'menu' | 'playing' | 'gameover' | 'levelup';
|
type GameState = 'menu' | 'playing' | 'gameover' | 'levelup';
|
||||||
|
|
||||||
export class Game {
|
export class Game {
|
||||||
@@ -30,6 +42,7 @@ export class Game {
|
|||||||
camera: THREE.PerspectiveCamera;
|
camera: THREE.PerspectiveCamera;
|
||||||
renderer: THREE.WebGLRenderer;
|
renderer: THREE.WebGLRenderer;
|
||||||
clock: THREE.Clock;
|
clock: THREE.Clock;
|
||||||
|
private playerLight!: THREE.PointLight;
|
||||||
|
|
||||||
player!: Player;
|
player!: Player;
|
||||||
enemies: Enemy[] = [];
|
enemies: Enemy[] = [];
|
||||||
@@ -37,8 +50,16 @@ export class Game {
|
|||||||
crosses: Cross[] = [];
|
crosses: Cross[] = [];
|
||||||
slashEffects: SlashEffect[] = [];
|
slashEffects: SlashEffect[] = [];
|
||||||
projectiles: EnemyProjectile[] = [];
|
projectiles: EnemyProjectile[] = [];
|
||||||
|
traps: Trap[] = [];
|
||||||
|
gasClouds: GasCloud[] = [];
|
||||||
|
private damageNumbers: DamageNumber[] = [];
|
||||||
|
private aura: Aura | null = null;
|
||||||
|
private boomerang: Boomerang | null = null;
|
||||||
|
private trapper = new Trapper();
|
||||||
|
private hotbar: (string | null)[] = Array(HOTBAR_KEY_CODES.length).fill(null);
|
||||||
|
|
||||||
state: GameState = 'menu';
|
state: GameState = 'menu';
|
||||||
|
private paused = false;
|
||||||
skillSystem: SkillSystem = new SkillSystem(0);
|
skillSystem: SkillSystem = new SkillSystem(0);
|
||||||
private seed: number | null = null;
|
private seed: number | null = null;
|
||||||
private offers: SkillOffer[] = [];
|
private offers: SkillOffer[] = [];
|
||||||
@@ -57,6 +78,16 @@ export class Game {
|
|||||||
spawns = 0;
|
spawns = 0;
|
||||||
killed = 0;
|
killed = 0;
|
||||||
|
|
||||||
|
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
|
||||||
|
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
||||||
|
[10, { name: 'Golem', spawn: () => new Golem() }],
|
||||||
|
[20, { name: 'Krake', spawn: () => new Krake() }],
|
||||||
|
[30, { name: 'Minotaurus', spawn: () => new Minotaurus() }],
|
||||||
|
]);
|
||||||
|
private bossFightActive = false;
|
||||||
|
private shakeTime = 0;
|
||||||
|
private shakeAmp = 0;
|
||||||
|
|
||||||
// Debug hitzone visualization
|
// Debug hitzone visualization
|
||||||
private showHitzones = false;
|
private showHitzones = false;
|
||||||
private playerHitRing!: THREE.Mesh;
|
private playerHitRing!: THREE.Mesh;
|
||||||
@@ -83,6 +114,10 @@ export class Game {
|
|||||||
private uiGameOverStats!: HTMLElement;
|
private uiGameOverStats!: HTMLElement;
|
||||||
private uiMainMenu!: HTMLElement;
|
private uiMainMenu!: HTMLElement;
|
||||||
private uiSeedInput!: HTMLInputElement;
|
private uiSeedInput!: HTMLInputElement;
|
||||||
|
private uiBossMode!: HTMLElement;
|
||||||
|
private uiBossModeButtons!: HTMLElement;
|
||||||
|
private uiChangelogPanel!: HTMLElement;
|
||||||
|
private cheatBuffer = '';
|
||||||
private uiLevelLabel!: HTMLElement;
|
private uiLevelLabel!: HTMLElement;
|
||||||
private uiSeedLabel!: HTMLElement;
|
private uiSeedLabel!: HTMLElement;
|
||||||
private uiXpBarFill!: HTMLElement;
|
private uiXpBarFill!: HTMLElement;
|
||||||
@@ -92,10 +127,33 @@ export class Game {
|
|||||||
private uiShieldText!: HTMLElement;
|
private uiShieldText!: HTMLElement;
|
||||||
private uiLevelUp!: HTMLElement;
|
private uiLevelUp!: HTMLElement;
|
||||||
private uiConfirmBtn!: HTMLButtonElement;
|
private uiConfirmBtn!: HTMLButtonElement;
|
||||||
|
private uiOfferIcons: HTMLElement[] = [];
|
||||||
private uiOfferTitles: HTMLElement[] = [];
|
private uiOfferTitles: HTMLElement[] = [];
|
||||||
private uiOfferDescs: HTMLElement[] = [];
|
private uiOfferDescs: HTMLElement[] = [];
|
||||||
|
private uiDamageVignette!: HTMLElement;
|
||||||
|
private uiLowHpVignette!: HTMLElement;
|
||||||
|
private uiInkVignette!: HTMLElement;
|
||||||
|
private uiBossBar!: HTMLElement;
|
||||||
|
private uiBossBarFill!: HTMLElement;
|
||||||
|
private uiBossBarLabel!: HTMLElement;
|
||||||
|
private uiTrapContainer!: HTMLElement;
|
||||||
|
private uiTrapCD!: HTMLElement;
|
||||||
|
private uiGasContainer!: HTMLElement;
|
||||||
|
private uiGasCD!: HTMLElement;
|
||||||
|
private uiLightningLabel!: HTMLElement;
|
||||||
|
private uiTeleportLabel!: HTMLElement;
|
||||||
|
private uiTrapLabel!: HTMLElement;
|
||||||
|
private uiGasLabel!: HTMLElement;
|
||||||
|
private uiPauseOverlay!: HTMLElement;
|
||||||
private mouseOnCanvas = false;
|
private mouseOnCanvas = false;
|
||||||
|
|
||||||
|
private bloodParticles: {
|
||||||
|
mesh: THREE.Mesh;
|
||||||
|
vel: THREE.Vector3;
|
||||||
|
life: number;
|
||||||
|
mat: THREE.MeshBasicMaterial;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
this.scene.background = new THREE.Color(0x0d1117);
|
this.scene.background = new THREE.Color(0x0d1117);
|
||||||
@@ -176,17 +234,41 @@ export class Game {
|
|||||||
this.uiLevelUp = document.getElementById('level-up')!;
|
this.uiLevelUp = document.getElementById('level-up')!;
|
||||||
this.uiConfirmBtn = document.getElementById('btn-confirm') as HTMLButtonElement;
|
this.uiConfirmBtn = document.getElementById('btn-confirm') as HTMLButtonElement;
|
||||||
this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer());
|
this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer());
|
||||||
|
this.uiDamageVignette = document.getElementById('damage-vignette')!;
|
||||||
|
this.uiLowHpVignette = document.getElementById('low-hp-vignette')!;
|
||||||
|
this.uiInkVignette = document.getElementById('ink-vignette')!;
|
||||||
|
this.uiBossBar = document.getElementById('boss-bar')!;
|
||||||
|
this.uiBossBarFill = document.getElementById('boss-bar-fill')!;
|
||||||
|
this.uiBossBarLabel = document.getElementById('boss-bar-label')!;
|
||||||
|
this.uiBossMode = document.getElementById('boss-mode')!;
|
||||||
|
this.uiBossModeButtons = document.getElementById('boss-mode-buttons')!;
|
||||||
|
this.setupBossModeUI();
|
||||||
|
this.uiTrapContainer = document.getElementById('trap-cooldown')!;
|
||||||
|
this.uiTrapCD = document.querySelector('#trap-cooldown .cooldown-fill')!;
|
||||||
|
this.uiGasContainer = document.getElementById('gas-cooldown')!;
|
||||||
|
this.uiGasCD = document.querySelector('#gas-cooldown .cooldown-fill')!;
|
||||||
|
this.uiLightningLabel = document.querySelector('#lightning-cooldown .cooldown-label')!;
|
||||||
|
this.uiTeleportLabel = document.querySelector('#teleport-cooldown .cooldown-label')!;
|
||||||
|
this.uiTrapLabel = document.querySelector('#trap-cooldown .cooldown-label')!;
|
||||||
|
this.uiGasLabel = document.querySelector('#gas-cooldown .cooldown-label')!;
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const card = document.getElementById(`offer-${i}`)!;
|
const card = document.getElementById(`offer-${i}`)!;
|
||||||
card.addEventListener('click', () => this.selectOffer(i));
|
card.addEventListener('click', () => this.selectOffer(i));
|
||||||
|
this.uiOfferIcons.push(document.getElementById(`offer-icon-${i}`)!);
|
||||||
this.uiOfferTitles.push(document.getElementById(`offer-title-${i}`)!);
|
this.uiOfferTitles.push(document.getElementById(`offer-title-${i}`)!);
|
||||||
this.uiOfferDescs.push(document.getElementById(`offer-desc-${i}`)!);
|
this.uiOfferDescs.push(document.getElementById(`offer-desc-${i}`)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('btn-start')!.addEventListener('click', () => this.startGame());
|
document.getElementById('btn-start')!.addEventListener('click', () => this.startGame());
|
||||||
|
document.getElementById('btn-changelog')!.addEventListener('click', () => this.showChangelog());
|
||||||
|
document.getElementById('btn-changelog-close')!.addEventListener('click', () => this.hideChangelog());
|
||||||
|
this.uiChangelogPanel = document.getElementById('changelog-panel')!;
|
||||||
document.getElementById('btn-restart')!.addEventListener('click', () => this.restart());
|
document.getElementById('btn-restart')!.addEventListener('click', () => this.restart());
|
||||||
document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu());
|
document.getElementById('btn-menu')!.addEventListener('click', () => this.showMenu());
|
||||||
|
this.uiPauseOverlay = document.getElementById('pause-overlay')!;
|
||||||
|
document.getElementById('btn-resume')!.addEventListener('click', () => this.resume());
|
||||||
|
document.getElementById('btn-pause-menu')!.addEventListener('click', () => this.showMenu());
|
||||||
|
|
||||||
// Diagnostics: detect a full page reload that wiped a running game
|
// Diagnostics: detect a full page reload that wiped a running game
|
||||||
if (sessionStorage.getItem('wackelpeter-run') === '1') {
|
if (sessionStorage.getItem('wackelpeter-run') === '1') {
|
||||||
@@ -210,15 +292,46 @@ export class Game {
|
|||||||
private setupInput() {
|
private setupInput() {
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
this.keys.add(e.code);
|
this.keys.add(e.code);
|
||||||
if (e.code === 'Space' && this.state === 'playing') {
|
if (this.paused) {
|
||||||
|
if (e.code === 'Escape') this.resume();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === 'Escape') {
|
||||||
|
if (this.state === 'playing') {
|
||||||
|
this.pause();
|
||||||
|
} else if (this.state === 'gameover') {
|
||||||
|
this.showMenu();
|
||||||
|
} else if (this.state === 'menu' && this.uiChangelogPanel.style.visibility === 'visible') {
|
||||||
|
this.hideChangelog();
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === 'Space' && this.state === 'playing'
|
||||||
|
&& this.player.pullTime <= 0 && this.player.stunTime <= 0) {
|
||||||
this.player.jump();
|
this.player.jump();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
if (e.code === 'KeyH') {
|
if (e.code === 'KeyH') {
|
||||||
this.showHitzones = !this.showHitzones;
|
this.showHitzones = !this.showHitzones;
|
||||||
}
|
}
|
||||||
if (e.code === 'KeyQ' && this.state === 'playing') {
|
// Cheat-Code "idkfa" im Hauptmenue: Boss-Modus freischalten
|
||||||
this.player.skillLightning.skill1(this.groundPoint, this.player);
|
if (this.state === 'menu') {
|
||||||
|
const active = document.activeElement;
|
||||||
|
const typing = active instanceof HTMLInputElement
|
||||||
|
|| active instanceof HTMLTextAreaElement;
|
||||||
|
if (!typing) {
|
||||||
|
this.cheatBuffer = (this.cheatBuffer + e.key).slice(-5).toLowerCase();
|
||||||
|
if (this.cheatBuffer === 'idkfa') {
|
||||||
|
this.cheatBuffer = '';
|
||||||
|
this.showBossMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const slotIdx = HOTBAR_KEY_CODES.indexOf(e.code);
|
||||||
|
if (slotIdx >= 0 && this.state === 'playing' && this.hotbar[slotIdx]) {
|
||||||
|
this.triggerHotbarSkill(this.hotbar[slotIdx]!);
|
||||||
}
|
}
|
||||||
if (this.state === 'levelup') {
|
if (this.state === 'levelup') {
|
||||||
if (e.code === 'Digit1' || e.code === 'Numpad1') this.selectOffer(0);
|
if (e.code === 'Digit1' || e.code === 'Numpad1') this.selectOffer(0);
|
||||||
@@ -229,7 +342,9 @@ export class Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.code === 'Enter' || e.code === 'Space') {
|
if (e.code === 'Enter' || e.code === 'Space') {
|
||||||
if (this.state === 'menu') this.startGame();
|
if (this.state === 'menu' && this.uiChangelogPanel.style.visibility !== 'visible') {
|
||||||
|
this.startGame();
|
||||||
|
}
|
||||||
else if (
|
else if (
|
||||||
this.state === 'gameover' &&
|
this.state === 'gameover' &&
|
||||||
performance.now() - this.gameOverAt > 1500
|
performance.now() - this.gameOverAt > 1500
|
||||||
@@ -240,9 +355,6 @@ export class Game {
|
|||||||
});
|
});
|
||||||
window.addEventListener('keyup', (e) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
this.keys.delete(e.code);
|
this.keys.delete(e.code);
|
||||||
if (e.code === 'KeyE' && this.state === 'playing') {
|
|
||||||
this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('mousedown', (e) => {
|
window.addEventListener('mousedown', (e) => {
|
||||||
@@ -262,7 +374,7 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMouseRelease(button: number) {
|
private handleMouseRelease(button: number) {
|
||||||
if (this.state !== 'playing') return;
|
if (this.state !== 'playing' || this.paused) return;
|
||||||
if (button === 0) {
|
if (button === 0) {
|
||||||
this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
|
this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
|
||||||
} else if (button === 2) {
|
} else if (button === 2) {
|
||||||
@@ -270,11 +382,38 @@ export class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private triggerHotbarSkill(id: string) {
|
||||||
|
switch (id) {
|
||||||
|
case 'chainlightning':
|
||||||
|
this.player.skillLightning.skill1(this.groundPoint, this.player);
|
||||||
|
break;
|
||||||
|
case 'teleport':
|
||||||
|
this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
|
||||||
|
break;
|
||||||
|
case 'trap':
|
||||||
|
this.trapper.skill1(this.groundPoint, this.player);
|
||||||
|
break;
|
||||||
|
case 'gascloud':
|
||||||
|
this.trapper.skill2(this.groundPoint, this.player);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hotbarKeyLabel(skillId: string): string {
|
||||||
|
const idx = this.hotbar.indexOf(skillId);
|
||||||
|
return idx >= 0 ? HOTBAR_KEY_LABELS[idx] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextFreeHotbarLabel(): string {
|
||||||
|
const idx = this.hotbar.indexOf(null);
|
||||||
|
return idx >= 0 ? HOTBAR_KEY_LABELS[idx] : '';
|
||||||
|
}
|
||||||
|
|
||||||
private setupLighting() {
|
private setupLighting() {
|
||||||
const ambient = new THREE.AmbientLight(0x556688, 0.35);
|
const ambient = new THREE.AmbientLight(0x556688, 0.08);
|
||||||
this.scene.add(ambient);
|
this.scene.add(ambient);
|
||||||
|
|
||||||
const dirLight = new THREE.DirectionalLight(0xccddff, 0.55);
|
const dirLight = new THREE.DirectionalLight(0xccddff, 0.15);
|
||||||
dirLight.position.set(30, 40, 20);
|
dirLight.position.set(30, 40, 20);
|
||||||
dirLight.castShadow = true;
|
dirLight.castShadow = true;
|
||||||
dirLight.shadow.mapSize.width = 1024;
|
dirLight.shadow.mapSize.width = 1024;
|
||||||
@@ -286,6 +425,11 @@ export class Game {
|
|||||||
dirLight.shadow.camera.top = 60;
|
dirLight.shadow.camera.top = 60;
|
||||||
dirLight.shadow.camera.bottom = -60;
|
dirLight.shadow.camera.bottom = -60;
|
||||||
this.scene.add(dirLight);
|
this.scene.add(dirLight);
|
||||||
|
|
||||||
|
// Lichtschein um den Spieler: Radius erhellt Umgebung, außerhalb wird es dunkler
|
||||||
|
this.playerLight = new THREE.PointLight(0xffeebb, 90, 20, 2);
|
||||||
|
this.playerLight.position.set(0, 6, 0);
|
||||||
|
this.scene.add(this.playerLight);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupArena() {
|
private setupArena() {
|
||||||
@@ -400,18 +544,86 @@ export class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.resetWorld();
|
this.resetWorld();
|
||||||
this.logState('playing', 'startGame');
|
this.beginRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginRun() {
|
||||||
|
this.logState('playing', 'beginRun');
|
||||||
this.state = 'playing';
|
this.state = 'playing';
|
||||||
|
this.paused = false;
|
||||||
|
this.hidePauseOverlay();
|
||||||
sessionStorage.setItem('wackelpeter-run', '1');
|
sessionStorage.setItem('wackelpeter-run', '1');
|
||||||
this.uiMainMenu.style.opacity = '0';
|
this.uiMainMenu.style.opacity = '0';
|
||||||
this.uiMainMenu.style.pointerEvents = 'none';
|
this.uiMainMenu.style.pointerEvents = 'none';
|
||||||
|
this.uiMainMenu.style.visibility = 'hidden';
|
||||||
|
this.hideChangelog();
|
||||||
this.uiGameOver.style.opacity = '0';
|
this.uiGameOver.style.opacity = '0';
|
||||||
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
|
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
|
||||||
this.uiGameOverPanel.style.opacity = '0';
|
this.uiGameOverPanel.style.opacity = '0';
|
||||||
this.uiGameOverPanel.style.pointerEvents = 'none';
|
this.uiGameOverPanel.style.pointerEvents = 'none';
|
||||||
|
this.uiGameOverPanel.style.visibility = 'hidden';
|
||||||
this.blurFocus();
|
this.blurFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setupBossModeUI() {
|
||||||
|
this.uiBossModeButtons.innerHTML = '';
|
||||||
|
for (const [wave, entry] of this.bossWaves) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'menu-button';
|
||||||
|
btn.textContent = `${entry.name} (Welle ${wave})`;
|
||||||
|
btn.style.fontSize = '14px';
|
||||||
|
btn.style.padding = '8px 20px';
|
||||||
|
btn.addEventListener('click', () => this.startBossFight(wave));
|
||||||
|
this.uiBossModeButtons.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showBossMode() {
|
||||||
|
this.uiBossMode.style.opacity = '1';
|
||||||
|
this.uiBossMode.style.pointerEvents = 'auto';
|
||||||
|
this.uiBossMode.style.visibility = 'visible';
|
||||||
|
playSound('spawn', 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showChangelog() {
|
||||||
|
this.uiChangelogPanel.style.opacity = '1';
|
||||||
|
this.uiChangelogPanel.style.pointerEvents = 'auto';
|
||||||
|
this.uiChangelogPanel.style.visibility = 'visible';
|
||||||
|
this.blurFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideChangelog() {
|
||||||
|
this.uiChangelogPanel.style.opacity = '0';
|
||||||
|
this.uiChangelogPanel.style.pointerEvents = 'none';
|
||||||
|
this.uiChangelogPanel.style.visibility = 'hidden';
|
||||||
|
this.blurFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
startBossFight(wave: number) {
|
||||||
|
if (this.state === 'playing' || this.state === 'levelup') return;
|
||||||
|
this.seed = Math.floor(Math.random() * 1000000);
|
||||||
|
this.resetWorld();
|
||||||
|
this.spawns = wave;
|
||||||
|
this.grantRandomSkills(wave);
|
||||||
|
this.spawnBossForWave(wave);
|
||||||
|
this.bossFightActive = true;
|
||||||
|
this.spawnTimer = 0;
|
||||||
|
this.beginRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
private grantRandomSkills(wave: number) {
|
||||||
|
const picks = Math.round(wave * 0.7);
|
||||||
|
for (let i = 0; i < picks; i++) {
|
||||||
|
const offers = this.skillSystem.rollOffers();
|
||||||
|
if (offers.length === 0) break;
|
||||||
|
const pick = offers[Math.floor(Math.random() * offers.length)];
|
||||||
|
this.skillSystem.apply(pick.id);
|
||||||
|
this.assignHotbarSkill(pick.id);
|
||||||
|
}
|
||||||
|
this.player.applySkillSystem(this.skillSystem);
|
||||||
|
this.syncSkillVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
private blurFocus() {
|
private blurFocus() {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
if (el instanceof HTMLElement) el.blur();
|
if (el instanceof HTMLElement) el.blur();
|
||||||
@@ -421,7 +633,34 @@ export class Game {
|
|||||||
this.startGame();
|
this.startGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pause() {
|
||||||
|
if (this.state !== 'playing' || this.paused) return;
|
||||||
|
this.paused = true;
|
||||||
|
this.uiPauseOverlay.style.visibility = 'visible';
|
||||||
|
this.uiPauseOverlay.style.opacity = '1';
|
||||||
|
this.uiPauseOverlay.style.pointerEvents = 'auto';
|
||||||
|
this.blurFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resume() {
|
||||||
|
if (!this.paused) return;
|
||||||
|
this.paused = false;
|
||||||
|
this.hidePauseOverlay();
|
||||||
|
this.blurFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hidePauseOverlay() {
|
||||||
|
this.uiPauseOverlay.style.opacity = '0';
|
||||||
|
this.uiPauseOverlay.style.pointerEvents = 'none';
|
||||||
|
this.uiPauseOverlay.style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
showMenu() {
|
showMenu() {
|
||||||
|
if ((this.state === 'playing' || this.state === 'levelup') && !this.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.paused = false;
|
||||||
|
this.hidePauseOverlay();
|
||||||
this.resetWorld();
|
this.resetWorld();
|
||||||
this.logState('menu', 'showMenu');
|
this.logState('menu', 'showMenu');
|
||||||
this.state = 'menu';
|
this.state = 'menu';
|
||||||
@@ -431,8 +670,10 @@ export class Game {
|
|||||||
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
|
this.uiGameOverPanel.style.transition = 'opacity 0.3s ease';
|
||||||
this.uiGameOverPanel.style.opacity = '0';
|
this.uiGameOverPanel.style.opacity = '0';
|
||||||
this.uiGameOverPanel.style.pointerEvents = 'none';
|
this.uiGameOverPanel.style.pointerEvents = 'none';
|
||||||
|
this.uiGameOverPanel.style.visibility = 'hidden';
|
||||||
this.uiMainMenu.style.opacity = '1';
|
this.uiMainMenu.style.opacity = '1';
|
||||||
this.uiMainMenu.style.pointerEvents = 'auto';
|
this.uiMainMenu.style.pointerEvents = 'auto';
|
||||||
|
this.uiMainMenu.style.visibility = 'visible';
|
||||||
this.blurFocus();
|
this.blurFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +688,7 @@ export class Game {
|
|||||||
this.uiGameOverPanel.style.transition = 'opacity 0.8s ease-in 1.5s';
|
this.uiGameOverPanel.style.transition = 'opacity 0.8s ease-in 1.5s';
|
||||||
this.uiGameOverPanel.style.opacity = '1';
|
this.uiGameOverPanel.style.opacity = '1';
|
||||||
this.uiGameOverPanel.style.pointerEvents = 'auto';
|
this.uiGameOverPanel.style.pointerEvents = 'auto';
|
||||||
|
this.uiGameOverPanel.style.visibility = 'visible';
|
||||||
playSound('gameover', 0.8);
|
playSound('gameover', 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +711,28 @@ export class Game {
|
|||||||
for (const p of this.projectiles) p.dispose();
|
for (const p of this.projectiles) p.dispose();
|
||||||
this.projectiles = [];
|
this.projectiles = [];
|
||||||
|
|
||||||
|
for (const trap of this.traps) trap.dispose();
|
||||||
|
this.traps = [];
|
||||||
|
|
||||||
|
for (const cloud of this.gasClouds) cloud.dispose();
|
||||||
|
this.gasClouds = [];
|
||||||
|
|
||||||
|
for (const num of this.damageNumbers) num.dispose(this.scene);
|
||||||
|
this.damageNumbers = [];
|
||||||
|
|
||||||
|
if (this.aura) {
|
||||||
|
this.aura.dispose(this.scene);
|
||||||
|
this.aura = null;
|
||||||
|
}
|
||||||
|
if (this.boomerang) {
|
||||||
|
this.boomerang.dispose(this.scene);
|
||||||
|
this.boomerang = null;
|
||||||
|
}
|
||||||
|
this.hotbar = Array(HOTBAR_KEY_CODES.length).fill(null);
|
||||||
|
this.trapper.cooldown1 = 0;
|
||||||
|
this.trapper.cooldown2 = 0;
|
||||||
|
this.trapper.cooldown3 = 0;
|
||||||
|
|
||||||
for (const [enemy, ring] of this.enemyHitRings) {
|
for (const [enemy, ring] of this.enemyHitRings) {
|
||||||
this.scene.remove(ring);
|
this.scene.remove(ring);
|
||||||
ring.geometry.dispose();
|
ring.geometry.dispose();
|
||||||
@@ -491,6 +755,8 @@ export class Game {
|
|||||||
this.spawnTimer = 0;
|
this.spawnTimer = 0;
|
||||||
this.spawns = 0;
|
this.spawns = 0;
|
||||||
this.killed = 0;
|
this.killed = 0;
|
||||||
|
this.bossFightActive = false;
|
||||||
|
this.shakeTime = 0;
|
||||||
this.pendingPicks = 0;
|
this.pendingPicks = 0;
|
||||||
this.hideLevelUp();
|
this.hideLevelUp();
|
||||||
this.skillSystem = new SkillSystem(this.seed ?? 0);
|
this.skillSystem = new SkillSystem(this.seed ?? 0);
|
||||||
@@ -511,10 +777,28 @@ export class Game {
|
|||||||
this.updateUI();
|
this.updateUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
onDamageDealt(damage: number) {
|
onDamageDealt(damage: number, target?: Enemy) {
|
||||||
if (this.state === 'playing' && this.player.stats.lifesteal > 0) {
|
if (this.state === 'playing' && this.player.stats.lifesteal > 0) {
|
||||||
this.player.heal(damage * this.player.stats.lifesteal);
|
this.player.heal(damage * this.player.stats.lifesteal);
|
||||||
}
|
}
|
||||||
|
// Treffer-Feedback: Schadenszahl bei Bossen einblenden
|
||||||
|
if (target?.isBoss && !target.dead) {
|
||||||
|
this.spawnDamageText(`${Math.round(damage)}`, target.body.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnDamageText(text: string, pos: THREE.Vector3, color?: string) {
|
||||||
|
const num = new DamageNumber(this.scene, text, pos, color);
|
||||||
|
this.damageNumbers.push(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnDamageNumber(damage: number, pos: THREE.Vector3, color?: string) {
|
||||||
|
this.spawnDamageText(`${damage}`, pos, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerCameraShake(duration: number, amp: number) {
|
||||||
|
this.shakeTime = duration;
|
||||||
|
this.shakeAmp = amp;
|
||||||
}
|
}
|
||||||
|
|
||||||
private openLevelUp() {
|
private openLevelUp() {
|
||||||
@@ -540,8 +824,14 @@ export class Game {
|
|||||||
const offer = this.offers[i];
|
const offer = this.offers[i];
|
||||||
const skill = getSkill(offer.id);
|
const skill = getSkill(offer.id);
|
||||||
const isNew = offer.nextLevel === 1;
|
const isNew = offer.nextLevel === 1;
|
||||||
|
this.uiOfferIcons[i].innerHTML = skillIcon(offer.id);
|
||||||
title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`;
|
title.textContent = `${skill.name}${isNew ? ' (NEU)' : ` · Stufe ${offer.nextLevel}`}`;
|
||||||
desc.textContent = skill.describe(offer.nextLevel);
|
let descText = skill.describe(offer.nextLevel);
|
||||||
|
if (isNew && HOTBAR_SKILLS.includes(offer.id)) {
|
||||||
|
const key = this.nextFreeHotbarLabel();
|
||||||
|
if (key) descText += ` · Taste [${key}]`;
|
||||||
|
}
|
||||||
|
desc.textContent = descText;
|
||||||
card.style.display = '';
|
card.style.display = '';
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
} else {
|
} else {
|
||||||
@@ -549,6 +839,7 @@ export class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.uiConfirmBtn.disabled = true;
|
this.uiConfirmBtn.disabled = true;
|
||||||
|
this.uiLevelUp.style.visibility = 'visible';
|
||||||
this.uiLevelUp.style.opacity = '1';
|
this.uiLevelUp.style.opacity = '1';
|
||||||
this.uiLevelUp.style.pointerEvents = 'auto';
|
this.uiLevelUp.style.pointerEvents = 'auto';
|
||||||
}
|
}
|
||||||
@@ -556,6 +847,7 @@ export class Game {
|
|||||||
private hideLevelUp() {
|
private hideLevelUp() {
|
||||||
this.uiLevelUp.style.opacity = '0';
|
this.uiLevelUp.style.opacity = '0';
|
||||||
this.uiLevelUp.style.pointerEvents = 'none';
|
this.uiLevelUp.style.pointerEvents = 'none';
|
||||||
|
this.uiLevelUp.style.visibility = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectOffer(index: number) {
|
private selectOffer(index: number) {
|
||||||
@@ -568,11 +860,19 @@ export class Game {
|
|||||||
this.uiConfirmBtn.disabled = false;
|
this.uiConfirmBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assignHotbarSkill(skillId: string) {
|
||||||
|
if (HOTBAR_SKILLS.includes(skillId) && !this.hotbar.includes(skillId)) {
|
||||||
|
const free = this.hotbar.indexOf(null);
|
||||||
|
if (free >= 0) this.hotbar[free] = skillId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private confirmOffer() {
|
private confirmOffer() {
|
||||||
if (this.state !== 'levelup' || this.selectedOffer < 0) return;
|
if (this.state !== 'levelup' || this.selectedOffer < 0) return;
|
||||||
const offer = this.offers[this.selectedOffer];
|
const offer = this.offers[this.selectedOffer];
|
||||||
this.selectedOffer = -1;
|
this.selectedOffer = -1;
|
||||||
this.skillSystem.apply(offer.id);
|
this.skillSystem.apply(offer.id);
|
||||||
|
this.assignHotbarSkill(offer.id);
|
||||||
this.player.applySkillSystem(this.skillSystem);
|
this.player.applySkillSystem(this.skillSystem);
|
||||||
this.syncSkillVisibility();
|
this.syncSkillVisibility();
|
||||||
this.blurFocus();
|
this.blurFocus();
|
||||||
@@ -595,6 +895,30 @@ export class Game {
|
|||||||
this.uiFireballContainer.style.display = s.getLevel('fireball') > 0 ? '' : 'none';
|
this.uiFireballContainer.style.display = s.getLevel('fireball') > 0 ? '' : 'none';
|
||||||
this.uiTeleportContainer.style.display = s.getLevel('teleport') > 0 ? '' : 'none';
|
this.uiTeleportContainer.style.display = s.getLevel('teleport') > 0 ? '' : 'none';
|
||||||
this.uiLightningContainer.style.display = s.getLevel('chainlightning') > 0 ? '' : 'none';
|
this.uiLightningContainer.style.display = s.getLevel('chainlightning') > 0 ? '' : 'none';
|
||||||
|
this.uiTrapContainer.style.display = s.getLevel('trap') > 0 ? '' : 'none';
|
||||||
|
this.uiGasContainer.style.display = s.getLevel('gascloud') > 0 ? '' : 'none';
|
||||||
|
|
||||||
|
const auraLevel = s.getLevel('aura');
|
||||||
|
if (auraLevel > 0 && !this.aura) {
|
||||||
|
this.aura = new Aura(this.scene);
|
||||||
|
} else if (auraLevel <= 0 && this.aura) {
|
||||||
|
this.aura.dispose(this.scene);
|
||||||
|
this.aura = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boomerangLevel = s.getLevel('boomerang');
|
||||||
|
if (boomerangLevel > 0 && !this.boomerang) {
|
||||||
|
this.boomerang = new Boomerang(this.scene);
|
||||||
|
} else if (boomerangLevel <= 0 && this.boomerang) {
|
||||||
|
this.boomerang.dispose(this.scene);
|
||||||
|
this.boomerang = null;
|
||||||
|
}
|
||||||
|
if (this.boomerang) this.boomerang.setLevel(boomerangLevel);
|
||||||
|
|
||||||
|
this.uiLightningLabel.textContent = `${getSkill('chainlightning').name} [${this.hotbarKeyLabel('chainlightning')}]`;
|
||||||
|
this.uiTeleportLabel.textContent = `${getSkill('teleport').name} [${this.hotbarKeyLabel('teleport')}]`;
|
||||||
|
this.uiTrapLabel.textContent = `${getSkill('trap').name} [${this.hotbarKeyLabel('trap')}]`;
|
||||||
|
this.uiGasLabel.textContent = `${getSkill('gascloud').name} [${this.hotbarKeyLabel('gascloud')}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private spawnEnemy() {
|
private spawnEnemy() {
|
||||||
@@ -648,6 +972,20 @@ export class Game {
|
|||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isBossWave(wave: number): boolean {
|
||||||
|
return this.bossWaves.has(wave);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnBossForWave(wave: number) {
|
||||||
|
const entry = this.bossWaves.get(wave);
|
||||||
|
if (!entry) return;
|
||||||
|
const enemy = entry.spawn();
|
||||||
|
const x = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
||||||
|
const z = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
||||||
|
this.spawnEnemyInstance(enemy, x, z, 0.15);
|
||||||
|
playSound('spawn', 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
|
spawnEnemyInstance(enemy: Enemy, x: number, z: number, y: number) {
|
||||||
enemy.body.position.set(x, y, z);
|
enemy.body.position.set(x, y, z);
|
||||||
this.scene.add(enemy.body);
|
this.scene.add(enemy.body);
|
||||||
@@ -707,49 +1045,63 @@ export class Game {
|
|||||||
const camLeft = new THREE.Vector3();
|
const camLeft = new THREE.Vector3();
|
||||||
camLeft.crossVectors(new THREE.Vector3(0, 1, 0), camFwd).normalize();
|
camLeft.crossVectors(new THREE.Vector3(0, 1, 0), camFwd).normalize();
|
||||||
|
|
||||||
const moveDir = new THREE.Vector3();
|
const p = this.player;
|
||||||
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) moveDir.add(camFwd);
|
let speed = p.stats.moveSpeed;
|
||||||
if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) moveDir.sub(camFwd);
|
if (p.slowTimer > 0) speed *= p.slowFactor;
|
||||||
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft);
|
|
||||||
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft);
|
|
||||||
|
|
||||||
let speed = this.player.stats.moveSpeed;
|
|
||||||
let isMoving = false;
|
let isMoving = false;
|
||||||
if (moveDir.length() > 0) {
|
|
||||||
moveDir.normalize();
|
if (p.stunTime > 0) {
|
||||||
this.player.velocity.set(moveDir.x * speed, 0, moveDir.z * speed);
|
// Gestunnt (Stampf-Schockwelle): keine Eingaben
|
||||||
isMoving = true;
|
p.velocity.set(0, 0, 0);
|
||||||
|
} else if (p.pullTime > 0) {
|
||||||
|
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
|
||||||
|
p.pullTime -= dt;
|
||||||
|
p.velocity.copy(p.pullDir).multiplyScalar(20);
|
||||||
|
p.onGround = true;
|
||||||
|
p.body.position.y = 0.5;
|
||||||
} else {
|
} else {
|
||||||
this.player.velocity.set(0, 0, 0);
|
const moveDir = new THREE.Vector3();
|
||||||
|
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) moveDir.add(camFwd);
|
||||||
|
if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) moveDir.sub(camFwd);
|
||||||
|
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft);
|
||||||
|
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft);
|
||||||
|
|
||||||
|
if (moveDir.length() > 0) {
|
||||||
|
moveDir.normalize();
|
||||||
|
p.velocity.set(moveDir.x * speed, 0, moveDir.z * speed);
|
||||||
|
isMoving = true;
|
||||||
|
} else {
|
||||||
|
p.velocity.set(0, 0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play walk/stand animations
|
// Play walk/stand animations
|
||||||
if (isMoving && this.player.getCurrentAnimation() !== 'walk') {
|
if (isMoving && p.getCurrentAnimation() !== 'walk') {
|
||||||
this.player.playAnimation('walk');
|
p.playAnimation('walk');
|
||||||
} else if (!isMoving && this.player.getCurrentAnimation() !== 'stand') {
|
} else if (!isMoving && p.getCurrentAnimation() !== 'stand') {
|
||||||
this.player.playAnimation('stand');
|
p.playAnimation('stand');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jump / gravity
|
// Jump / gravity
|
||||||
if (!this.player.onGround) {
|
if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0) {
|
||||||
this.player.jumpVelocity -= 20 * dt;
|
p.jumpVelocity -= 20 * dt;
|
||||||
this.player.body.position.y += this.player.jumpVelocity * dt;
|
p.body.position.y += p.jumpVelocity * dt;
|
||||||
if (this.player.body.position.y <= 0.5) {
|
if (p.body.position.y <= 0.5) {
|
||||||
this.player.body.position.y = 0.5;
|
p.body.position.y = 0.5;
|
||||||
this.player.jumpVelocity = 0;
|
p.jumpVelocity = 0;
|
||||||
this.player.onGround = true;
|
p.onGround = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply horizontal movement
|
// Apply horizontal movement
|
||||||
this.player.body.position.x += this.player.velocity.x * dt;
|
p.body.position.x += p.velocity.x * dt;
|
||||||
this.player.body.position.z += this.player.velocity.z * dt;
|
p.body.position.z += p.velocity.z * dt;
|
||||||
this.clampToArena(this.player.body.position);
|
this.clampToArena(p.body.position);
|
||||||
|
|
||||||
// Look at ground point
|
// Look at ground point
|
||||||
this.player.body.lookAt(
|
p.body.lookAt(
|
||||||
this.groundPoint.x,
|
this.groundPoint.x,
|
||||||
this.player.body.position.y,
|
p.body.position.y,
|
||||||
this.groundPoint.z
|
this.groundPoint.z
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -765,6 +1117,11 @@ export class Game {
|
|||||||
enemy.updateAnimation(dt);
|
enemy.updateAnimation(dt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (enemy.rootTime > 0) {
|
||||||
|
enemy.rootTime -= dt;
|
||||||
|
enemy.updateAnimation(dt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
enemy.update(dt, this);
|
enemy.update(dt, this);
|
||||||
enemy.updateAnimation(dt);
|
enemy.updateAnimation(dt);
|
||||||
}
|
}
|
||||||
@@ -836,6 +1193,17 @@ export class Game {
|
|||||||
? ''
|
? ''
|
||||||
: `Seed: ${this.seed}`;
|
: `Seed: ${this.seed}`;
|
||||||
|
|
||||||
|
// Boss-Healthbar (sichtbar, solange ein Boss lebt)
|
||||||
|
const boss = this.enemies.find(e => e.isBoss && !e.dead);
|
||||||
|
if (boss) {
|
||||||
|
this.uiBossBar.style.display = 'block';
|
||||||
|
this.uiBossBarLabel.textContent = boss.displayName;
|
||||||
|
this.uiBossBarFill.style.width =
|
||||||
|
`${Math.max(0, (boss.health / boss.maxHealth) * 100)}%`;
|
||||||
|
} else {
|
||||||
|
this.uiBossBar.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
const xpPct = this.skillSystem.xpProgress();
|
const xpPct = this.skillSystem.xpProgress();
|
||||||
this.uiXpBarFill.style.width = `${Math.min(1, xpPct) * 100}%`;
|
this.uiXpBarFill.style.width = `${Math.min(1, xpPct) * 100}%`;
|
||||||
this.uiXpBarLabel.textContent = this.skillSystem.hasAnyUpgradeLeft()
|
this.uiXpBarLabel.textContent = this.skillSystem.hasAnyUpgradeLeft()
|
||||||
@@ -858,13 +1226,121 @@ export class Game {
|
|||||||
const lnPct = Math.max(0, (lightning.COOLDOWN1_TIME - lightning.cooldown1) / lightning.COOLDOWN1_TIME);
|
const lnPct = Math.max(0, (lightning.COOLDOWN1_TIME - lightning.cooldown1) / lightning.COOLDOWN1_TIME);
|
||||||
this.uiLightningCD.style.width = `${lnPct * 100}%`;
|
this.uiLightningCD.style.width = `${lnPct * 100}%`;
|
||||||
|
|
||||||
const wavePct = this.spawnTimer / SPAWN_TIME;
|
const trapTotal = this.trapper.COOLDOWN1_TIME || 1;
|
||||||
this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
|
const trapPct = Math.max(0, (trapTotal - this.trapper.cooldown1) / trapTotal);
|
||||||
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
|
this.uiTrapCD.style.width = `${trapPct * 100}%`;
|
||||||
|
|
||||||
|
const gasTotal = this.trapper.COOLDOWN2_TIME || 1;
|
||||||
|
const gasPct = Math.max(0, (gasTotal - this.trapper.cooldown2) / gasTotal);
|
||||||
|
this.uiGasCD.style.width = `${gasPct * 100}%`;
|
||||||
|
|
||||||
|
const wavePct = Math.min(1, this.spawnTimer / SPAWN_TIME);
|
||||||
|
if (this.bossFightActive) {
|
||||||
|
this.uiWaveBarFill.style.width = '100%';
|
||||||
|
this.uiWaveBarLabel.textContent = 'BOSS!';
|
||||||
|
} else if (this.spawnTimer >= SPAWN_TIME && this.isBossWave(this.spawns + 1)) {
|
||||||
|
this.uiWaveBarFill.style.width = '100%';
|
||||||
|
this.uiWaveBarLabel.textContent = 'Gegner räumen!';
|
||||||
|
} else {
|
||||||
|
this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
|
||||||
|
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.player.health <= 0 && this.state === 'playing') {
|
if (this.player.health <= 0 && this.state === 'playing') {
|
||||||
this.gameOver();
|
this.gameOver();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.uiLowHpVignette.classList.toggle(
|
||||||
|
'active', healthPct < 0.3 && healthPct > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerInkVignette() {
|
||||||
|
const el = this.uiInkVignette;
|
||||||
|
el.classList.remove('ink');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('ink');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlayerHurt(sourcePos: THREE.Vector3) {
|
||||||
|
const el = this.uiDamageVignette;
|
||||||
|
el.classList.remove('hurt');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('hurt');
|
||||||
|
|
||||||
|
const away = new THREE.Vector3()
|
||||||
|
.subVectors(this.player.body.position, sourcePos);
|
||||||
|
away.y = 0;
|
||||||
|
if (away.lengthSq() < 0.01) {
|
||||||
|
away.set(Math.random() - 0.5, 0, Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
away.normalize();
|
||||||
|
|
||||||
|
const geom = new THREE.SphereGeometry(0.07, 6, 6);
|
||||||
|
const count = 12;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xcc2222,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geom, mat);
|
||||||
|
mesh.position.copy(this.player.body.position);
|
||||||
|
mesh.position.y += 0.5;
|
||||||
|
this.scene.add(mesh);
|
||||||
|
this.bloodParticles.push({
|
||||||
|
mesh,
|
||||||
|
mat,
|
||||||
|
vel: away.clone()
|
||||||
|
.multiplyScalar(2 + Math.random() * 3)
|
||||||
|
.add(new THREE.Vector3(
|
||||||
|
(Math.random() - 0.5) * 1.5,
|
||||||
|
3 + Math.random() * 3,
|
||||||
|
(Math.random() - 0.5) * 1.5
|
||||||
|
)),
|
||||||
|
life: 0.5 + Math.random() * 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBloodParticles(dt: number) {
|
||||||
|
for (let i = this.bloodParticles.length - 1; i >= 0; i--) {
|
||||||
|
const p = this.bloodParticles[i];
|
||||||
|
p.life -= dt;
|
||||||
|
if (p.life <= 0) {
|
||||||
|
this.scene.remove(p.mesh);
|
||||||
|
p.mesh.geometry.dispose();
|
||||||
|
p.mat.dispose();
|
||||||
|
this.bloodParticles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
p.vel.y -= 14 * dt;
|
||||||
|
p.mesh.position.addScaledVector(p.vel, dt);
|
||||||
|
if (p.mesh.position.y < 0.06) {
|
||||||
|
p.mesh.position.y = 0.06;
|
||||||
|
p.vel.set(0, 0, 0);
|
||||||
|
}
|
||||||
|
p.mat.opacity = Math.max(0, p.life / 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTraps(dt: number) {
|
||||||
|
for (let i = this.traps.length - 1; i >= 0; i--) {
|
||||||
|
const trap = this.traps[i];
|
||||||
|
if (!trap.update(dt, this)) {
|
||||||
|
trap.dispose();
|
||||||
|
this.traps.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateGasClouds(dt: number) {
|
||||||
|
for (let i = this.gasClouds.length - 1; i >= 0; i--) {
|
||||||
|
const cloud = this.gasClouds[i];
|
||||||
|
if (!cloud.update(dt, this)) {
|
||||||
|
cloud.dispose();
|
||||||
|
this.gasClouds.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private animate() {
|
private animate() {
|
||||||
@@ -872,6 +1348,12 @@ export class Game {
|
|||||||
|
|
||||||
const dt = Math.min(this.clock.getDelta(), 0.1);
|
const dt = Math.min(this.clock.getDelta(), 0.1);
|
||||||
|
|
||||||
|
if (this.paused) {
|
||||||
|
this.updateUI();
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.updateGroundPoint();
|
this.updateGroundPoint();
|
||||||
this.updatePlayerMovement(dt);
|
this.updatePlayerMovement(dt);
|
||||||
this.updateEnemies(dt);
|
this.updateEnemies(dt);
|
||||||
@@ -885,26 +1367,53 @@ export class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateBloodParticles(dt);
|
||||||
|
|
||||||
|
// Schadenszahlen aktualisieren
|
||||||
|
for (let i = this.damageNumbers.length - 1; i >= 0; i--) {
|
||||||
|
if (!this.damageNumbers[i].update(dt)) {
|
||||||
|
this.damageNumbers[i].dispose(this.scene);
|
||||||
|
this.damageNumbers.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.aura) this.aura.update(dt, this);
|
||||||
|
if (this.boomerang) this.boomerang.update(dt, this);
|
||||||
|
this.updateTraps(dt);
|
||||||
|
this.updateGasClouds(dt);
|
||||||
|
|
||||||
// Update animations
|
// Update animations
|
||||||
this.player.updateAnimations(dt);
|
this.player.updateAnimations(dt);
|
||||||
this.player.rightHandWeapon.updateAnimation(dt);
|
this.player.rightHandWeapon.updateAnimation(dt);
|
||||||
|
|
||||||
// Spawn enemies in waves
|
// Spawn enemies in waves
|
||||||
if (this.state === 'playing') {
|
if (this.state === 'playing') {
|
||||||
this.spawnTimer += dt;
|
const arenaCleared = this.enemies.every(e => e.dead);
|
||||||
if (this.spawnTimer >= SPAWN_TIME) {
|
if (this.bossFightActive) {
|
||||||
this.spawns++;
|
// Boss besiegt -> naechste Welle startet
|
||||||
if (this.spawns % 5 === 0) {
|
if (arenaCleared) {
|
||||||
const x = (Math.random() - 0.5) * 80;
|
this.bossFightActive = false;
|
||||||
const z = (Math.random() - 0.5) * 80;
|
this.spawns++;
|
||||||
this.spawnEnemyInstance(new Boss(), x, z, 0.15);
|
this.spawnTimer = 0;
|
||||||
playSound('spawn', 0.8);
|
}
|
||||||
|
} else if (this.spawnTimer >= SPAWN_TIME) {
|
||||||
|
if (this.isBossWave(this.spawns + 1)) {
|
||||||
|
// Boss-Welle startet erst, wenn alle Gegner tot sind
|
||||||
|
if (arenaCleared) {
|
||||||
|
this.spawns++;
|
||||||
|
this.spawnBossForWave(this.spawns);
|
||||||
|
this.bossFightActive = true;
|
||||||
|
this.spawnTimer = 0;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.spawns++;
|
||||||
for (let i = 0; i < this.spawns; i++) {
|
for (let i = 0; i < this.spawns; i++) {
|
||||||
this.spawnEnemy();
|
this.spawnEnemy();
|
||||||
}
|
}
|
||||||
|
this.spawnTimer = 0;
|
||||||
}
|
}
|
||||||
this.spawnTimer = 0;
|
} else {
|
||||||
|
this.spawnTimer += dt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,7 +1424,7 @@ export class Game {
|
|||||||
if (enemy.dead) continue;
|
if (enemy.dead) continue;
|
||||||
const dist = enemy.body.position.distanceTo(this.player.body.position);
|
const dist = enemy.body.position.distanceTo(this.player.body.position);
|
||||||
if (dist < enemy.contactRadius) {
|
if (dist < enemy.contactRadius) {
|
||||||
this.player.damage(enemy.contactDps * dt);
|
this.player.damage(enemy.contactDps * dt, enemy.body.position);
|
||||||
if (thorns > 0) {
|
if (thorns > 0) {
|
||||||
enemy.takeDamage(thorns * dt, this, false);
|
enemy.takeDamage(thorns * dt, this, false);
|
||||||
}
|
}
|
||||||
@@ -954,6 +1463,7 @@ export class Game {
|
|||||||
this.player.rightHandWeapon.reduceCooldowns(dt);
|
this.player.rightHandWeapon.reduceCooldowns(dt);
|
||||||
this.player.leftHandWeapon.reduceCooldowns(dt);
|
this.player.leftHandWeapon.reduceCooldowns(dt);
|
||||||
this.player.skillLightning.reduceCooldowns(dt);
|
this.player.skillLightning.reduceCooldowns(dt);
|
||||||
|
this.trapper.reduceCooldowns(dt);
|
||||||
|
|
||||||
// Camera follows player
|
// Camera follows player
|
||||||
this.camera.position.set(
|
this.camera.position.set(
|
||||||
@@ -961,12 +1471,25 @@ export class Game {
|
|||||||
25,
|
25,
|
||||||
this.player.body.position.z - 15
|
this.player.body.position.z - 15
|
||||||
);
|
);
|
||||||
|
if (this.shakeTime > 0) {
|
||||||
|
this.shakeTime -= dt;
|
||||||
|
const k = this.shakeTime * this.shakeAmp;
|
||||||
|
this.camera.position.x += (Math.random() - 0.5) * k;
|
||||||
|
this.camera.position.z += (Math.random() - 0.5) * k;
|
||||||
|
}
|
||||||
this.camera.lookAt(
|
this.camera.lookAt(
|
||||||
this.player.body.position.x,
|
this.player.body.position.x,
|
||||||
0,
|
0,
|
||||||
this.player.body.position.z
|
this.player.body.position.z
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Lichtschein folgt dem Spieler
|
||||||
|
this.playerLight.position.set(
|
||||||
|
this.player.body.position.x,
|
||||||
|
6,
|
||||||
|
this.player.body.position.z
|
||||||
|
);
|
||||||
|
|
||||||
// Clean dead enemies
|
// Clean dead enemies
|
||||||
for (let i = this.enemies.length - 1; i >= 0; i--) {
|
for (let i = this.enemies.length - 1; i >= 0; i--) {
|
||||||
const enemy = this.enemies[i];
|
const enemy = this.enemies[i];
|
||||||
@@ -995,12 +1518,11 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.state === 'playing' || this.state === 'levelup') {
|
if (this.state === 'playing' || this.state === 'levelup') {
|
||||||
const leveled = this.skillSystem.addXp(enemy.xp);
|
const levels = this.skillSystem.addXp(enemy.xp);
|
||||||
if (leveled) {
|
if (levels > 0) {
|
||||||
|
this.pendingPicks += levels;
|
||||||
if (this.state === 'playing') {
|
if (this.state === 'playing') {
|
||||||
this.openLevelUp();
|
this.openLevelUp();
|
||||||
} else {
|
|
||||||
this.pendingPicks++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ import * as THREE from 'three';
|
|||||||
import type { Game } from './Game';
|
import type { Game } from './Game';
|
||||||
import { Brute } from './Brute';
|
import { Brute } from './Brute';
|
||||||
|
|
||||||
export class Boss extends Brute {
|
export class Golem extends Brute {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.health = 800;
|
this.health = 800;
|
||||||
|
this.maxHealth = 800;
|
||||||
|
this.isBoss = true;
|
||||||
|
this.displayName = 'Golem';
|
||||||
this.speed = 3;
|
this.speed = 3;
|
||||||
this.xp = 80;
|
this.xp = 80;
|
||||||
this.contactDps = 50;
|
this.contactDps = 50;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
|
||||||
|
const SPEED = 8;
|
||||||
|
const PLAYER_PROXIMITY = 1.4;
|
||||||
|
|
||||||
|
export class InkBlob {
|
||||||
|
body: THREE.Group;
|
||||||
|
target: THREE.Vector3;
|
||||||
|
private vel: THREE.Vector3;
|
||||||
|
private life = 6;
|
||||||
|
dead = false;
|
||||||
|
nearPlayer = false;
|
||||||
|
|
||||||
|
constructor(from: THREE.Vector3, target: THREE.Vector3) {
|
||||||
|
this.target = target.clone();
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.copy(from);
|
||||||
|
|
||||||
|
this.vel = new THREE.Vector3().subVectors(target, from);
|
||||||
|
this.vel.y = 0;
|
||||||
|
const dist = this.vel.length();
|
||||||
|
if (dist > 0.01) {
|
||||||
|
this.vel.normalize().multiplyScalar(SPEED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const glowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xaa44ff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.5,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 12), glowMat);
|
||||||
|
this.body.add(glow);
|
||||||
|
|
||||||
|
const coreMat = new THREE.MeshToonMaterial({ color: 0x3a1060 });
|
||||||
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.28, 10, 10), coreMat);
|
||||||
|
this.body.add(core);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.life -= dt;
|
||||||
|
this.body.position.x += this.vel.x * dt;
|
||||||
|
this.body.position.z += this.vel.z * dt;
|
||||||
|
this.body.position.y = 1.2 + Math.sin(this.life * 4) * 0.1;
|
||||||
|
|
||||||
|
if (game.player.health > 0 && !this.nearPlayer) {
|
||||||
|
const dist = this.body.position.distanceTo(game.player.body.position);
|
||||||
|
if (dist < PLAYER_PROXIMITY) {
|
||||||
|
this.nearPlayer = true;
|
||||||
|
game.triggerInkVignette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = this.target.x - this.body.position.x;
|
||||||
|
const dz = this.target.z - this.body.position.z;
|
||||||
|
if (this.life <= 0 || dx * dx + dz * dz < 0.25) {
|
||||||
|
this.dead = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.body.removeFromParent();
|
||||||
|
for (const child of [...this.body.children]) {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
|
||||||
|
const TICK = 0.5;
|
||||||
|
const TICK_DAMAGE = 4;
|
||||||
|
const SLOW_REFRESH = 0.15;
|
||||||
|
|
||||||
|
export class InkPuddle {
|
||||||
|
body: THREE.Group;
|
||||||
|
private radius: number;
|
||||||
|
private life: number;
|
||||||
|
private tickTimer = 0;
|
||||||
|
private puffs: THREE.Mesh[] = [];
|
||||||
|
|
||||||
|
constructor(position: THREE.Vector3, radius = 3.5, duration = 6) {
|
||||||
|
this.radius = radius;
|
||||||
|
this.life = duration;
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.set(position.x, 0.1, position.z);
|
||||||
|
|
||||||
|
const puffGeom = new THREE.SphereGeometry(1, 12, 10);
|
||||||
|
const puffMat = new THREE.MeshToonMaterial({
|
||||||
|
color: 0x552277,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.35,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const puff = new THREE.Mesh(puffGeom, puffMat);
|
||||||
|
const a = (i / 6) * Math.PI * 2;
|
||||||
|
puff.position.set(
|
||||||
|
Math.cos(a) * this.radius * 0.4,
|
||||||
|
0.3 + Math.random() * 0.3,
|
||||||
|
Math.sin(a) * this.radius * 0.4
|
||||||
|
);
|
||||||
|
puff.scale.setScalar(this.radius * (0.22 + Math.random() * 0.12));
|
||||||
|
this.body.add(puff);
|
||||||
|
this.puffs.push(puff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.life -= dt;
|
||||||
|
if (this.life <= 0) return false;
|
||||||
|
|
||||||
|
const fade = Math.min(1, this.life / 0.6);
|
||||||
|
for (const puff of this.puffs) {
|
||||||
|
(puff.material as THREE.MeshToonMaterial).opacity = 0.35 * fade;
|
||||||
|
puff.rotation.y += dt * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.player.health > 0) {
|
||||||
|
const dist = Math.hypot(
|
||||||
|
game.player.body.position.x - this.body.position.x,
|
||||||
|
game.player.body.position.z - this.body.position.z
|
||||||
|
);
|
||||||
|
if (dist < this.radius) {
|
||||||
|
game.player.slowTimer = SLOW_REFRESH;
|
||||||
|
this.tickTimer -= dt;
|
||||||
|
if (this.tickTimer <= 0) {
|
||||||
|
this.tickTimer = TICK;
|
||||||
|
game.player.damage(TICK_DAMAGE, this.body.position);
|
||||||
|
game.triggerInkVignette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.body.removeFromParent();
|
||||||
|
for (const puff of this.puffs) {
|
||||||
|
puff.geometry.dispose();
|
||||||
|
(puff.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
import { InkBlob } from './InkBlob';
|
||||||
|
import { InkPuddle } from './InkPuddle';
|
||||||
|
|
||||||
|
const MAX_HEALTH = 3200;
|
||||||
|
const INK_COOLDOWN = 4.5;
|
||||||
|
const PULL_COOLDOWN = 7.5;
|
||||||
|
const PULL_MARK_COUNT = 5;
|
||||||
|
const PULL_MARK_RADIUS = 2.2;
|
||||||
|
const PULL_MARK_DIST = 6.5;
|
||||||
|
const PULL_CHARGE_TIME = 2.5;
|
||||||
|
const PULL_DAMAGE = 25;
|
||||||
|
const PULL_TIME = 0.6;
|
||||||
|
const SLAM_TIME = 0.5;
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
const SWEEP_RADIUS = 11;
|
||||||
|
const SWEEP_DURATION = 3.6;
|
||||||
|
const SWEEP_HIT_WIDTH = 1.2;
|
||||||
|
const SWEEP_DAMAGE = 25;
|
||||||
|
const ENRAGE_RATIO = 0.3;
|
||||||
|
const INK_RADIUS = 3.5;
|
||||||
|
const INK_DURATION = 6;
|
||||||
|
|
||||||
|
export class Krake extends Enemy {
|
||||||
|
spawnAtCenter = true;
|
||||||
|
private model: THREE.Group;
|
||||||
|
private game: Game | null = null;
|
||||||
|
private waveTime = 0;
|
||||||
|
private tentacles: THREE.Group[] = [];
|
||||||
|
private inkTimer = 2;
|
||||||
|
private pullTimer = 3.5;
|
||||||
|
private sweepTimer = 5;
|
||||||
|
private enraged = false;
|
||||||
|
private effectsReady = false;
|
||||||
|
|
||||||
|
private slamState: 'none' | 'grab' | 'slam' = 'none';
|
||||||
|
private slamChain!: THREE.Group;
|
||||||
|
private slamTime = 0;
|
||||||
|
private slamRetractFrom = 1;
|
||||||
|
private pullState: 'idle' | 'charge' = 'idle';
|
||||||
|
private pullChargeTime = 0;
|
||||||
|
private markOffsetAngle = 0;
|
||||||
|
private pullMarks: {
|
||||||
|
group: THREE.Group;
|
||||||
|
ringMat: THREE.MeshBasicMaterial;
|
||||||
|
discMat: THREE.MeshBasicMaterial;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
private sweepTents: { group: THREE.Group; angle: number; dir: number; progress: number }[] = [];
|
||||||
|
private sweepActive = false;
|
||||||
|
private sweepHit = false;
|
||||||
|
|
||||||
|
private inkBlobs: InkBlob[] = [];
|
||||||
|
private puddles: InkPuddle[] = [];
|
||||||
|
private skinMat!: THREE.MeshToonMaterial;
|
||||||
|
private darkMat!: THREE.MeshToonMaterial;
|
||||||
|
private hitFlash = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = MAX_HEALTH;
|
||||||
|
this.maxHealth = MAX_HEALTH;
|
||||||
|
this.isBoss = true;
|
||||||
|
this.displayName = 'Krake';
|
||||||
|
this.immovable = true;
|
||||||
|
this.speed = 0;
|
||||||
|
this.xp = 150;
|
||||||
|
this.contactDps = 0;
|
||||||
|
this.contactRadius = 0;
|
||||||
|
this.guaranteedDrop = true;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
const skinMat = new THREE.MeshToonMaterial({ color: 0x8a4a9a });
|
||||||
|
const darkMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a });
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffee44 });
|
||||||
|
this.skinMat = skinMat;
|
||||||
|
this.darkMat = darkMat;
|
||||||
|
|
||||||
|
// Kopf
|
||||||
|
const head = new THREE.Mesh(new THREE.SphereGeometry(2.2, 24, 18), skinMat);
|
||||||
|
head.scale.set(1, 0.85, 1);
|
||||||
|
head.position.y = 3.1;
|
||||||
|
head.castShadow = true;
|
||||||
|
this.registerFadeMesh(head);
|
||||||
|
this.model.add(head);
|
||||||
|
|
||||||
|
// Augen auf +Z (Blickrichtung)
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), eyeMat);
|
||||||
|
eye.position.set(side * 0.8, 3.45, 1.9);
|
||||||
|
this.registerFadeMesh(eye);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentakel um den Kopf herum
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const chain = new THREE.Group();
|
||||||
|
const a = (i / 8) * Math.PI * 2;
|
||||||
|
for (let j = 0; j < 7; j++) {
|
||||||
|
const seg = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.42 - j * 0.04, 12, 10),
|
||||||
|
j % 2 === 0 ? skinMat : darkMat
|
||||||
|
);
|
||||||
|
seg.position.set(j * 0.55, -j * 0.28, 0);
|
||||||
|
this.registerFadeMesh(seg);
|
||||||
|
chain.add(seg);
|
||||||
|
}
|
||||||
|
chain.position.set(Math.cos(a) * 1.7, 1.1, Math.sin(a) * 1.7);
|
||||||
|
chain.rotation.y = -a;
|
||||||
|
this.model.add(chain);
|
||||||
|
this.tentacles.push(chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weltraum-Effekte (Pull-Marken, Slam-Tentakel, Sweep-Tentakel)
|
||||||
|
this.buildEffects();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEffects() {
|
||||||
|
// 5 rote Boden-Marken (Arme) rund um den Oktopus
|
||||||
|
for (let i = 0; i < PULL_MARK_COUNT; i++) {
|
||||||
|
const ringMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xff3333,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const discMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xff2222,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(PULL_MARK_RADIUS - 0.15, PULL_MARK_RADIUS + 0.15, 32),
|
||||||
|
ringMat
|
||||||
|
);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
ring.position.y = 0.05;
|
||||||
|
const disc = new THREE.Mesh(new THREE.CircleGeometry(PULL_MARK_RADIUS, 32), discMat);
|
||||||
|
disc.rotation.x = -Math.PI / 2;
|
||||||
|
disc.position.y = 0.03;
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(ring, disc);
|
||||||
|
group.visible = false;
|
||||||
|
this.pullMarks.push({ group, ringMat, discMat });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.slamChain = new THREE.Group();
|
||||||
|
for (let j = 1; j <= 10; j++) {
|
||||||
|
const seg = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.3, 8, 6),
|
||||||
|
new THREE.MeshToonMaterial({ color: 0x7a3a8a })
|
||||||
|
);
|
||||||
|
seg.position.set(0, 0, j * 0.5);
|
||||||
|
this.slamChain.add(seg);
|
||||||
|
}
|
||||||
|
this.slamChain.visible = false;
|
||||||
|
|
||||||
|
// Kreisende Sweep-Tentakel (dicker roter Arm mit Saugnaepfen, ueber den man springen kann)
|
||||||
|
// Zwei Exemplare: Phase 2 nutzt beide in entgegengesetzte Richtungen
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const group = this.buildSweepTentacle();
|
||||||
|
group.visible = false;
|
||||||
|
this.sweepTents.push({ group, angle: 0, dir: i === 0 ? 1 : -1, progress: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSweepTentacle(): THREE.Group {
|
||||||
|
const sweepMat = new THREE.MeshToonMaterial({ color: 0xdd4455 });
|
||||||
|
const cupMat = new THREE.MeshToonMaterial({ color: 0xffb3a0 });
|
||||||
|
const cupGeom = new THREE.SphereGeometry(1, 10, 6);
|
||||||
|
const group = new THREE.Group();
|
||||||
|
for (let j = 1; j <= 18; j++) {
|
||||||
|
const r = Math.max(0.22, 0.42 - j * 0.012);
|
||||||
|
const seg = new THREE.Mesh(new THREE.SphereGeometry(r, 12, 10), sweepMat);
|
||||||
|
seg.position.set(j * 0.6, 0.3, 0);
|
||||||
|
group.add(seg);
|
||||||
|
|
||||||
|
// Saugnapf: flache Scheibe, abwechselnd links/rechts
|
||||||
|
const side = j % 2 === 0 ? 1 : -1;
|
||||||
|
const cup = new THREE.Mesh(cupGeom, cupMat);
|
||||||
|
cup.scale.set(r * 0.5, r * 0.18, r * 0.5);
|
||||||
|
cup.position.set(j * 0.6, 0.3, side * (r + 0.02));
|
||||||
|
group.add(cup);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureEffects(scene: THREE.Scene) {
|
||||||
|
if (this.effectsReady) return;
|
||||||
|
for (const mark of this.pullMarks) scene.add(mark.group);
|
||||||
|
scene.add(this.slamChain);
|
||||||
|
for (const tent of this.sweepTents) scene.add(tent.group);
|
||||||
|
this.effectsReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.game = game;
|
||||||
|
this.ensureEffects(game.scene);
|
||||||
|
this.waveTime += dt;
|
||||||
|
|
||||||
|
// Hit-Feedback: kurz weiss aufblitzen + leichtes Pulsieren
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
this.skinMat.color.set(0xffffff);
|
||||||
|
this.darkMat.color.set(0xffffff);
|
||||||
|
this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05);
|
||||||
|
} else {
|
||||||
|
this.skinMat.color.set(this.enraged ? 0x8a2233 : 0x8a4a9a);
|
||||||
|
this.darkMat.color.set(this.enraged ? 0x551520 : 0x5a2a6a);
|
||||||
|
this.model.scale.setScalar(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentakel wippen
|
||||||
|
for (let i = 0; i < this.tentacles.length; i++) {
|
||||||
|
this.tentacles[i].rotation.z = Math.sin(this.waveTime * 2.2 + i * 0.9) * 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zum Spieler drehen
|
||||||
|
const playerPos = game.player.body.position;
|
||||||
|
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
|
||||||
|
toPlayer.y = 0;
|
||||||
|
if (toPlayer.length() > 0.1) {
|
||||||
|
this.body.lookAt(
|
||||||
|
this.body.position.x + toPlayer.x,
|
||||||
|
this.body.position.y,
|
||||||
|
this.body.position.z + toPlayer.z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrage unter 50% HP
|
||||||
|
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
|
||||||
|
this.enraged = true;
|
||||||
|
// Phase 2: Krake wird dunkelrot
|
||||||
|
this.skinMat.color.set(0x8a2233);
|
||||||
|
this.darkMat.color.set(0x551520);
|
||||||
|
playSound('damage', 0.8);
|
||||||
|
}
|
||||||
|
const inkCd = this.enraged ? INK_COOLDOWN * 0.6 : INK_COOLDOWN;
|
||||||
|
const pullCd = this.enraged ? PULL_COOLDOWN * 0.65 : PULL_COOLDOWN;
|
||||||
|
|
||||||
|
// Tinte spucken
|
||||||
|
this.inkTimer -= dt;
|
||||||
|
if (this.inkTimer <= 0) {
|
||||||
|
this.inkTimer = inkCd;
|
||||||
|
this.spitInk(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentakel-Griff
|
||||||
|
if (this.pullState === 'idle') {
|
||||||
|
this.pullTimer -= dt;
|
||||||
|
if (this.pullTimer <= 0) {
|
||||||
|
this.pullTimer = pullCd;
|
||||||
|
this.startPullCharge(game);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.updatePullCharge(dt, game);
|
||||||
|
}
|
||||||
|
if (this.slamState === 'grab') {
|
||||||
|
// Tentakel bleibt am Spieler haengen, solange er gezogen wird
|
||||||
|
if (game.player.pullTime > 0) {
|
||||||
|
const playerPos = game.player.body.position;
|
||||||
|
this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z);
|
||||||
|
this.slamChain.lookAt(playerPos.x, 1, playerPos.z);
|
||||||
|
const dist = Math.max(1, Math.hypot(
|
||||||
|
playerPos.x - this.body.position.x,
|
||||||
|
playerPos.z - this.body.position.z
|
||||||
|
));
|
||||||
|
this.slamChain.scale.set(1, 1, Math.min(dist / 5, 2.5));
|
||||||
|
} else {
|
||||||
|
// Zug vorbei -> Tentakel zieht sich zurueck
|
||||||
|
this.slamRetractFrom = this.slamChain.scale.z;
|
||||||
|
this.slamState = 'slam';
|
||||||
|
this.slamTime = SLAM_TIME;
|
||||||
|
}
|
||||||
|
} else if (this.slamState === 'slam') {
|
||||||
|
this.updateSlam(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentakel-Sweep
|
||||||
|
this.updateSweep(dt, game);
|
||||||
|
|
||||||
|
// Tinten-Blobs aktualisieren
|
||||||
|
for (let i = this.inkBlobs.length - 1; i >= 0; i--) {
|
||||||
|
const blob = this.inkBlobs[i];
|
||||||
|
if (!blob.update(dt, game)) {
|
||||||
|
this.inkBlobs.splice(i, 1);
|
||||||
|
const puddle = new InkPuddle(
|
||||||
|
blob.body.position,
|
||||||
|
this.enraged ? INK_RADIUS + 0.5 : INK_RADIUS,
|
||||||
|
INK_DURATION
|
||||||
|
);
|
||||||
|
game.scene.add(puddle.body);
|
||||||
|
this.puddles.push(puddle);
|
||||||
|
blob.dispose();
|
||||||
|
playSound('explosion', 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tintenpfützen aktualisieren
|
||||||
|
for (let i = this.puddles.length - 1; i >= 0; i--) {
|
||||||
|
const puddle = this.puddles[i];
|
||||||
|
if (!puddle.update(dt, game)) {
|
||||||
|
this.puddles.splice(i, 1);
|
||||||
|
puddle.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spitInk(game: Game) {
|
||||||
|
const from = new THREE.Vector3(this.body.position.x, 3.0, this.body.position.z);
|
||||||
|
const target = game.player.body.position.clone();
|
||||||
|
target.y = 0;
|
||||||
|
|
||||||
|
if (this.enraged) {
|
||||||
|
const t2 = target.clone().add(
|
||||||
|
new THREE.Vector3((Math.random() - 0.5) * 8, 0, (Math.random() - 0.5) * 8)
|
||||||
|
);
|
||||||
|
const blob2 = new InkBlob(from, t2);
|
||||||
|
game.scene.add(blob2.body);
|
||||||
|
this.inkBlobs.push(blob2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new InkBlob(from, target);
|
||||||
|
game.scene.add(blob.body);
|
||||||
|
this.inkBlobs.push(blob);
|
||||||
|
playSound('spawn', 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPullCharge(game: Game) {
|
||||||
|
this.pullState = 'charge';
|
||||||
|
this.pullChargeTime = PULL_CHARGE_TIME;
|
||||||
|
this.markOffsetAngle = Math.random() * Math.PI * 2;
|
||||||
|
const cx = this.body.position.x;
|
||||||
|
const cz = this.body.position.z;
|
||||||
|
for (let i = 0; i < PULL_MARK_COUNT; i++) {
|
||||||
|
const a = this.markOffsetAngle + (i / PULL_MARK_COUNT) * Math.PI * 2;
|
||||||
|
const mark = this.pullMarks[i];
|
||||||
|
mark.group.position.set(
|
||||||
|
cx + Math.cos(a) * PULL_MARK_DIST,
|
||||||
|
0.06,
|
||||||
|
cz + Math.sin(a) * PULL_MARK_DIST
|
||||||
|
);
|
||||||
|
mark.group.visible = true;
|
||||||
|
}
|
||||||
|
playSound('spawn', 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePullCharge(dt: number, game: Game) {
|
||||||
|
this.pullChargeTime -= dt;
|
||||||
|
const pulse = Math.abs(Math.sin(this.waveTime * 9));
|
||||||
|
for (const mark of this.pullMarks) {
|
||||||
|
mark.ringMat.opacity = 0.3 + 0.5 * pulse;
|
||||||
|
mark.discMat.opacity = 0.1 + 0.08 * pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pullChargeTime <= 0) {
|
||||||
|
this.pullState = 'idle';
|
||||||
|
const playerPos = game.player.body.position;
|
||||||
|
|
||||||
|
// Spieler in einer Marke? -> heranziehen
|
||||||
|
let grabbed = false;
|
||||||
|
for (const mark of this.pullMarks) {
|
||||||
|
const dx = playerPos.x - mark.group.position.x;
|
||||||
|
const dz = playerPos.z - mark.group.position.z;
|
||||||
|
if (Math.hypot(dx, dz) < PULL_MARK_RADIUS) {
|
||||||
|
grabbed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const mark of this.pullMarks) {
|
||||||
|
mark.group.visible = false;
|
||||||
|
mark.ringMat.opacity = 0;
|
||||||
|
mark.discMat.opacity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grabbed && playerPos.y <= 0.8) {
|
||||||
|
// Slam-Tentakel greift den Spieler und bleibt haengen
|
||||||
|
this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z);
|
||||||
|
this.slamChain.lookAt(playerPos.x, 1, playerPos.z);
|
||||||
|
this.slamChain.scale.set(1, 1, 1);
|
||||||
|
this.slamChain.visible = true;
|
||||||
|
this.slamState = 'grab';
|
||||||
|
|
||||||
|
game.player.damage(PULL_DAMAGE, this.body.position);
|
||||||
|
const dir = new THREE.Vector3().subVectors(this.body.position, playerPos);
|
||||||
|
dir.y = 0;
|
||||||
|
if (dir.lengthSq() < 0.01) dir.set(0, 0, 1);
|
||||||
|
dir.normalize();
|
||||||
|
game.player.pullDir.copy(dir);
|
||||||
|
game.player.pullTime = PULL_TIME;
|
||||||
|
playSound('damage', 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSlam(dt: number) {
|
||||||
|
this.slamTime -= dt;
|
||||||
|
const s = Math.max(0, this.slamTime / SLAM_TIME);
|
||||||
|
this.slamChain.scale.set(1, 1, this.slamRetractFrom * s);
|
||||||
|
|
||||||
|
if (this.slamTime <= 0) {
|
||||||
|
this.slamChain.visible = false;
|
||||||
|
this.slamState = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSweep(dt: number, game: Game) {
|
||||||
|
if (!this.sweepActive) {
|
||||||
|
this.sweepTimer -= dt;
|
||||||
|
const cd = this.enraged ? 3.2 : 4.5;
|
||||||
|
if (this.sweepTimer <= 0) {
|
||||||
|
this.sweepTimer = cd;
|
||||||
|
this.sweepActive = true;
|
||||||
|
this.sweepHit = false;
|
||||||
|
const start = Math.random() * Math.PI * 2;
|
||||||
|
for (let i = 0; i < this.sweepTents.length; i++) {
|
||||||
|
const tent = this.sweepTents[i];
|
||||||
|
tent.angle = start + i * Math.PI;
|
||||||
|
tent.progress = 0;
|
||||||
|
tent.group.position.set(this.body.position.x, 0.06, this.body.position.z);
|
||||||
|
// Tentakel liegt auf lokaler +X; rotation.y = -Winkel zeigt auf (cos, sin)
|
||||||
|
tent.group.rotation.y = -tent.angle;
|
||||||
|
tent.group.visible = i === 0 || this.enraged;
|
||||||
|
}
|
||||||
|
playSound('spawn', 0.6);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotSpeed = ((Math.PI * 2) / (this.enraged ? 3.0 : SWEEP_DURATION)) * dt;
|
||||||
|
|
||||||
|
for (const tent of this.sweepTents) {
|
||||||
|
// Phase 2: zweiter Tentakel kreist gegensinnig
|
||||||
|
if (tent.dir === -1 && !this.enraged) {
|
||||||
|
tent.group.visible = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tent.angle += tent.dir * rotSpeed;
|
||||||
|
tent.progress += rotSpeed;
|
||||||
|
tent.group.rotation.y = -tent.angle;
|
||||||
|
if (!this.sweepHit) {
|
||||||
|
const playerPos = game.player.body.position;
|
||||||
|
const dx = playerPos.x - this.body.position.x;
|
||||||
|
const dz = playerPos.z - this.body.position.z;
|
||||||
|
const dist = Math.hypot(dx, dz);
|
||||||
|
if (dist > 0.5 && dist < SWEEP_RADIUS + 0.5) {
|
||||||
|
const dirX = Math.cos(tent.angle);
|
||||||
|
const dirZ = Math.sin(tent.angle);
|
||||||
|
// Abstand des Spielers zur Tentakel-Linie (radial vom Boss)
|
||||||
|
const perp = Math.abs(dx * dirZ - dz * dirX);
|
||||||
|
const along = dx * dirX + dz * dirZ;
|
||||||
|
// Ueberspringbar: nur Treffer, solange der Spieler am Boden ist
|
||||||
|
if (along > 0.3 && perp < SWEEP_HIT_WIDTH && playerPos.y <= 0.6) {
|
||||||
|
this.sweepHit = true;
|
||||||
|
game.player.damage(SWEEP_DAMAGE, this.body.position);
|
||||||
|
playSound('damage', 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sweepTents.every(t => t.progress >= Math.PI * 2)) {
|
||||||
|
this.sweepActive = false;
|
||||||
|
for (const tent of this.sweepTents) {
|
||||||
|
tent.group.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override takeDamage(
|
||||||
|
damage: number,
|
||||||
|
game: Game,
|
||||||
|
withKnockback = true,
|
||||||
|
sourcePos?: THREE.Vector3
|
||||||
|
) {
|
||||||
|
super.takeDamage(damage, game, withKnockback, sourcePos);
|
||||||
|
this.hitFlash = HIT_FLASH_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.cleanupEffects();
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose() {
|
||||||
|
super.dispose();
|
||||||
|
this.cleanupEffects();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupEffects() {
|
||||||
|
for (const blob of this.inkBlobs) {
|
||||||
|
blob.dispose();
|
||||||
|
}
|
||||||
|
this.inkBlobs = [];
|
||||||
|
for (const puddle of this.puddles) {
|
||||||
|
puddle.dispose();
|
||||||
|
}
|
||||||
|
this.puddles = [];
|
||||||
|
if (this.game) {
|
||||||
|
for (const mark of this.pullMarks) {
|
||||||
|
this.game.scene.remove(mark.group);
|
||||||
|
}
|
||||||
|
this.game.scene.remove(this.slamChain);
|
||||||
|
for (const tent of this.sweepTents) {
|
||||||
|
this.game.scene.remove(tent.group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const mark of this.pullMarks) {
|
||||||
|
for (const child of mark.group.children) {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pullMarks = [];
|
||||||
|
for (const tent of this.sweepTents) {
|
||||||
|
for (const child of tent.group.children) {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of this.slamChain.children) {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-3
@@ -30,6 +30,11 @@ 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;
|
||||||
stats: PlayerStats = {
|
stats: PlayerStats = {
|
||||||
maxHealth: 100,
|
maxHealth: 100,
|
||||||
regen: 0,
|
regen: 0,
|
||||||
@@ -44,10 +49,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 +115,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 +177,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 +197,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 +236,36 @@ 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;
|
||||||
|
|
||||||
|
// 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 +284,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 +292,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 +315,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 +344,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 +357,9 @@ 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.body.position.set(0, 0.5, 0);
|
this.body.position.set(0, 0.5, 0);
|
||||||
this.velocity.set(0, 0, 0);
|
this.velocity.set(0, 0, 0);
|
||||||
this.jumpVelocity = 0;
|
this.jumpVelocity = 0;
|
||||||
|
|||||||
@@ -0,0 +1,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] ?? '';
|
||||||
|
}
|
||||||
+77
-5
@@ -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',
|
||||||
@@ -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 {
|
||||||
@@ -126,6 +158,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 [25, 35, 45, 55, 70][Math.min(level, 5) - 1] ?? 25;
|
||||||
|
}
|
||||||
|
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 [8, 12, 16, 20, 25][Math.min(level, 5) - 1] ?? 8;
|
||||||
|
}
|
||||||
|
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;
|
||||||
@@ -162,15 +233,16 @@ export class SkillSystem {
|
|||||||
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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import type { Enemy } from './Enemy';
|
||||||
|
import { trapDamage, trapHold } from './Skills';
|
||||||
|
|
||||||
|
const TRIGGER_RADIUS = 1.1;
|
||||||
|
const LIFETIME = 20;
|
||||||
|
const TICK = 0.5;
|
||||||
|
|
||||||
|
export class Trap {
|
||||||
|
body: THREE.Group;
|
||||||
|
private level: number;
|
||||||
|
private life = LIFETIME;
|
||||||
|
private triggered = false;
|
||||||
|
private tickTimer = 0;
|
||||||
|
private target: Enemy | null = null;
|
||||||
|
private jaws: THREE.Mesh[];
|
||||||
|
private pulse = 0;
|
||||||
|
|
||||||
|
constructor(position: THREE.Vector3, level: number) {
|
||||||
|
this.level = level;
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.copy(position);
|
||||||
|
this.body.position.y = 0;
|
||||||
|
|
||||||
|
const baseGeom = new THREE.CylinderGeometry(0.45, 0.55, 0.12, 24);
|
||||||
|
const baseMat = new THREE.MeshToonMaterial({ color: 0x556066 });
|
||||||
|
const base = new THREE.Mesh(baseGeom, baseMat);
|
||||||
|
base.position.y = 0.06;
|
||||||
|
base.castShadow = true;
|
||||||
|
this.body.add(base);
|
||||||
|
|
||||||
|
const ringGeom = new THREE.TorusGeometry(0.35, 0.05, 8, 24);
|
||||||
|
const ringMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xffcc33,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
ring.position.y = 0.13;
|
||||||
|
this.body.add(ring);
|
||||||
|
|
||||||
|
this.jaws = [];
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const jaw = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(0.3, 0.06, 0.55),
|
||||||
|
new THREE.MeshToonMaterial({ color: 0x9aa7b0 })
|
||||||
|
);
|
||||||
|
jaw.position.set(side * 0.2, 0.14, 0);
|
||||||
|
jaw.castShadow = true;
|
||||||
|
this.body.add(jaw);
|
||||||
|
this.jaws.push(jaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
if (!this.triggered) {
|
||||||
|
this.life -= dt;
|
||||||
|
if (this.life <= 0) return false;
|
||||||
|
|
||||||
|
for (const enemy of game.enemies) {
|
||||||
|
if (enemy.dead) continue;
|
||||||
|
const dist = enemy.body.position.distanceTo(this.body.position);
|
||||||
|
if (dist < TRIGGER_RADIUS) {
|
||||||
|
this.triggered = true;
|
||||||
|
this.target = enemy;
|
||||||
|
enemy.rootTime = trapHold(this.level);
|
||||||
|
this.pulse = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tickTimer -= dt;
|
||||||
|
if (this.pulse > 0) {
|
||||||
|
this.pulse -= dt * 4;
|
||||||
|
for (const jaw of this.jaws) {
|
||||||
|
jaw.scale.setScalar(1 + Math.max(0, this.pulse) * 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.target || this.target.dead || this.target.rootTime <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.tickTimer <= 0) {
|
||||||
|
this.tickTimer = TICK;
|
||||||
|
this.target.takeDamage(
|
||||||
|
(trapDamage(this.level) / trapHold(this.level)) * TICK,
|
||||||
|
game,
|
||||||
|
false,
|
||||||
|
this.body.position
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.body.removeFromParent();
|
||||||
|
for (const child of [...this.body.children]) {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { Weapon } from './Weapon';
|
||||||
|
import { Player } from './Player';
|
||||||
|
import { Trap } from './Trap';
|
||||||
|
import { GasCloud } from './GasCloud';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
import { trapCooldown, trapMax, gasCooldown } from './Skills';
|
||||||
|
|
||||||
|
export class Trapper extends Weapon {
|
||||||
|
skill1(groundPoint: THREE.Vector3, player: Player): void {
|
||||||
|
const level = player.game.skillSystem.getLevel('trap');
|
||||||
|
if (level <= 0) return;
|
||||||
|
if (this.cooldown1 > 0) return;
|
||||||
|
if (player.game.traps.length >= trapMax(level)) return;
|
||||||
|
|
||||||
|
this.COOLDOWN1_TIME = trapCooldown(level);
|
||||||
|
this.cooldown1 = this.COOLDOWN1_TIME;
|
||||||
|
|
||||||
|
const trap = new Trap(groundPoint.clone(), level);
|
||||||
|
player.game.scene.add(trap.body);
|
||||||
|
player.game.traps.push(trap);
|
||||||
|
playSound('spawn', 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
skill2(groundPoint: THREE.Vector3, player: Player): void {
|
||||||
|
const level = player.game.skillSystem.getLevel('gascloud');
|
||||||
|
if (level <= 0) return;
|
||||||
|
if (this.cooldown2 > 0) return;
|
||||||
|
|
||||||
|
this.COOLDOWN2_TIME = gasCooldown(level);
|
||||||
|
this.cooldown2 = this.COOLDOWN2_TIME;
|
||||||
|
|
||||||
|
const cloud = new GasCloud(groundPoint.clone(), level);
|
||||||
|
player.game.scene.add(cloud.body);
|
||||||
|
player.game.gasClouds.push(cloud);
|
||||||
|
playSound('spawn', 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export class Turret extends Enemy {
|
|||||||
this.speed = 0;
|
this.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user