From 2bffefb02c54a6338a7d2910d527f56a7d5d230c Mon Sep 17 00:00:00 2001 From: nico Date: Thu, 20 Aug 2026 10:21:08 +0200 Subject: [PATCH] Add Spider Queen, Frost Giant, Necromancer and Dragon bosses --- AGENTS.md | 4 +- web/index.html | 4 + web/src/Dragon.ts | 778 +++++++++++++++++++++++++++++++++++++++++ web/src/FrostGiant.ts | 692 ++++++++++++++++++++++++++++++++++++ web/src/Game.ts | 16 +- web/src/Necromancer.ts | 527 ++++++++++++++++++++++++++++ web/src/Player.ts | 6 + web/src/Spider.ts | 42 ++- web/src/SpiderQueen.ts | 408 +++++++++++++++++++++ web/src/WebPatch.ts | 105 ++++++ 10 files changed, 2563 insertions(+), 19 deletions(-) create mode 100644 web/src/Dragon.ts create mode 100644 web/src/FrostGiant.ts create mode 100644 web/src/Necromancer.ts create mode 100644 web/src/SpiderQueen.ts create mode 100644 web/src/WebPatch.ts diff --git a/AGENTS.md b/AGENTS.md index a6be78f..b4d678d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ npm run dev # Dev-Server mit HMR | `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) | | `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` | | `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` | -| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` | Bosse (Welle 10 / 20 / 30), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) | +| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` / `SpiderQueen.ts` / `FrostGiant.ts` / `Necromancer.ts` / `Dragon.ts` | Bosse (Welle 10 / 20 / 30 / 40 / 50 / 60 / 70), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Spinnenkönigin: stationär, Netze (`WebPatch.ts`: fesselt via `player.rootTime`), Spinngeschwister (Mini-Spiders), Säuresalven. Frostriese: Eispanzer (70% Schadensreduktion, `armorHp`, bricht + Stagger, regeneriert nach Timer), Eisberst-Ring (nacheinander explodierende Marken), Eiszapfen-Regen, Stampf (überspringbar). Erz-Nekromant: schwebend, Teleport mit Glow-Ring-Telegraf + Ankunftssalve, Beschwörungen mit wachsendem Boden-Glow. Drache: fliegt und ist nur im Lande-Zustand verletzbar (`takeDamage`-Override blockt im Flug), Sturzangriff mit wachsendem Schatten-Telegraf, Feuerspeer (Telegraf-Bogen, überspringbar), Feuerbälle im Flug, Enrage mit Doppel-Sturz. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) | | `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 | @@ -50,7 +50,7 @@ npm run dev # Dev-Server mit HMR - **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen"). - **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen. - **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf). -- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung) und `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf). Alles wird in `Player.reset()` zurückgesetzt. +- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung), `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf) und `rootTime` (Netz-Fessel: keine Bewegung, Angriffe bleiben möglich). Alles wird in `Player.reset()` zurückgesetzt. - **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig). - **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag. - **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln. diff --git a/web/index.html b/web/index.html index b8e1a4a..7c7258d 100644 --- a/web/index.html +++ b/web/index.html @@ -400,6 +400,10 @@

Changelog

+
20.08.Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer
+
20.08.Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen
+
20.08.Frostriese (Welle 50): Eispanzer-Rüstung, Eisberst-Ringe und Eiszapfen-Regen
+
20.08.Spinnenkönigin (Welle 40): Netze fesseln den Spieler, Spinngeschwister und Säuresalven
19.08.Minotaurus-Boss mit Frontpanzerung, Dash-Stun und überspringbarem Axthieb
19.08.Boss-Treffer-Feedback mit Aufblitzen und schwebenden Schadenszahlen
19.08.Krake-Boss mit Tinte, Zug-Markierungen und Sweep-Tentakeln plus Boss-Modus
diff --git a/web/src/Dragon.ts b/web/src/Dragon.ts new file mode 100644 index 0000000..d62de39 --- /dev/null +++ b/web/src/Dragon.ts @@ -0,0 +1,778 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { playSound } from './SoundManager'; + +const MAX_HEALTH = 16000; +const HIT_FLASH_TIME = 0.14; +const MODEL_SCALE = 1.6; + +const FLY_TIME = 4.5; +const FLY_TIME_2 = 3.5; +const FLY_HEIGHT = 7; +const FLY_RADIUS = 9; +const FLY_FIREBALL_CD = 1.8; + +const DIVEWINDUP_TIME = 1.0; +const DIVEWINDUP_TIME_2 = 0.7; +const DIVE_TIME = 0.9; +const DIVE_IMPACT_RADIUS = 2.8; +const DIVE_DAMAGE = 30; +const DIVERECOVER_TIME = 0.4; + +const LANDED_TIME = 4.0; +const LANDED_TIME_2 = 2.5; +const TAKEOFF_TIME = 0.8; +const TAKEOFF_HEIGHT = 6; + +const BREATH_WINDUP = 0.9; +const BREATH_TIME = 1.1; +const BREATH_RANGE = 11; +const BREATH_HALF_ANGLE = 0.65; +const BREATH_DAMAGE = 35; + +const FIREBALL_SPREAD = 0.35; +const FIREBALL_SPEED = 13; +const FIREBALL_GRAVITY = 6; +const FIREBALL_HOMING = 3.2; +const FIREBALL_DAMAGE = 20; +const FIREBALL_HIT_RADIUS = 1.1; +const FIREBALL_COUNT = 3; +const FIREBALL_COUNT_2 = 5; + +const ENRAGE_RATIO = 0.35; +const WALL_LIMIT = 48; + +type State = 'fly' | 'divewindup' | 'dive' | 'diverecover' | 'landed' | 'takeoff'; + +export class Dragon extends Enemy { + private model: THREE.Group; + private game: Game | null = null; + private waveTime = 0; + private state: State = 'fly'; + private stateTime = 0; + private enraged = false; + private hitFlash = 0; + private doubleDiveLeft = 0; + + private skinMat!: THREE.MeshToonMaterial; + private darkMat!: THREE.MeshToonMaterial; + private bellyMat!: THREE.MeshToonMaterial; + private wingMat!: THREE.MeshToonMaterial; + private eyeMat!: THREE.MeshBasicMaterial; + private shadow!: THREE.Mesh; + private shadowMat!: THREE.MeshBasicMaterial; + + private wingL!: THREE.Group; + private wingR!: THREE.Group; + private jaw!: THREE.Group; + private breathGroup!: THREE.Group; + private breathMat!: THREE.MeshBasicMaterial; + private telegraphArc!: THREE.Mesh; + private telegraphArcMat!: THREE.MeshBasicMaterial; + + private flyAngle = Math.random() * Math.PI * 2; + private flyFireballTimer = 1.5; + private diveTarget = new THREE.Vector3(); + private diveFrom = new THREE.Vector3(); + private diveShadow!: THREE.Mesh; + private diveShadowMat!: THREE.MeshBasicMaterial; + private diveShadowActive = false; + private diveShadowAdded = false; + private breathHit = false; + private breathActive = false; + + private fireballs: DragonFireball[] = []; + private impactFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = []; + private breathPhase = 'idle'; + private breathTime = 0; + + constructor() { + super(); + this.health = MAX_HEALTH; + this.maxHealth = MAX_HEALTH; + this.isBoss = true; + this.displayName = 'Drache'; + this.speed = 0; + this.xp = 400; + this.contactDps = 30; + this.contactRadius = 3.6; + this.guaranteedDrop = true; + + this.model = new THREE.Group(); + this.model.scale.setScalar(MODEL_SCALE); + this.fadeOutModel = this.model; + this.body.add(this.model); + this.stateTime = FLY_TIME; + + this.skinMat = new THREE.MeshToonMaterial({ color: 0xbb4433 }); + this.darkMat = new THREE.MeshToonMaterial({ color: 0x772a1e }); + this.bellyMat = new THREE.MeshToonMaterial({ color: 0xe8c890 }); + this.wingMat = new THREE.MeshToonMaterial({ color: 0x993322 }); + this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xffcc33 }); + + // --- Körper (Blickrichtung +Z) --- + + // Rumpf: gestreckte Kugel + const torso = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 14), this.skinMat); + torso.scale.set(1, 0.85, 1.7); + torso.position.y = 1.4; + torso.castShadow = true; + this.registerFadeMesh(torso); + this.model.add(torso); + + // Bauch (hell, unten) + const belly = new THREE.Mesh(new THREE.SphereGeometry(0.75, 16, 10), this.bellyMat); + belly.scale.set(1, 0.55, 1.6); + belly.position.set(0, 1.05, 0.1); + this.registerFadeMesh(belly); + this.model.add(belly); + + // Schwanz (-Z, verjüngt) + for (let i = 0; i < 5; i++) { + const seg = new THREE.Mesh( + new THREE.SphereGeometry(0.5 - i * 0.08, 10, 8), + i % 2 === 0 ? this.skinMat : this.darkMat + ); + seg.position.set(0, 1.4 - i * 0.06, -1.7 - i * 0.6); + this.registerFadeMesh(seg); + this.model.add(seg); + } + + // Schwanzspitze (Pfeil) + const tailTip = new THREE.Mesh(new THREE.ConeGeometry(0.2, 0.8, 6), this.darkMat); + tailTip.rotation.x = -Math.PI / 2; + tailTip.position.set(0, 1.1, -4.8); + this.registerFadeMesh(tailTip); + this.model.add(tailTip); + + // Kopf (+Z) + const head = new THREE.Mesh(new THREE.SphereGeometry(0.7, 16, 12), this.skinMat); + head.scale.set(1, 0.9, 1.2); + head.position.set(0, 2.0, 1.7); + head.castShadow = true; + this.registerFadeMesh(head); + this.model.add(head); + + // Schnauze (+Z) + const snout = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.35, 0.6), this.skinMat); + snout.position.set(0, 1.85, 2.5); + this.registerFadeMesh(snout); + this.model.add(snout); + + // Kiefer (beweglich, öffnet sich beim Feuerspeer) + this.jaw = new THREE.Group(); + const jawMesh = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.22, 0.55), this.darkMat); + jawMesh.position.set(0, -0.1, 0.22); + this.registerFadeMesh(jawMesh); + this.jaw.add(jawMesh); + this.jaw.position.set(0, 1.75, 2.4); + this.jaw.rotation.x = 0.15; + this.model.add(this.jaw); + + // Augen (+Z, ragen aus dem Kopf heraus) + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), this.eyeMat); + eye.position.set(side * 0.32, 2.3, 2.42); + this.model.add(eye); + } + + // Hörner + for (const side of [-1, 1]) { + const horn = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.6, 6), this.darkMat); + horn.position.set(side * 0.28, 2.6, 1.4); + horn.rotation.set(-0.9, 0, side * 0.5); + this.registerFadeMesh(horn); + this.model.add(horn); + } + + // Rückenstacheln + for (let i = 0; i < 4; i++) { + const spike = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.5, 5), this.darkMat); + spike.position.set(0, 1.9, 0.8 - i * 0.9); + this.registerFadeMesh(spike); + this.model.add(spike); + } + + // Beine (4, unter dem Rumpf) + for (const side of [-1, 1]) { + for (const back of [true, false]) { + const leg = new THREE.Mesh( + new THREE.CylinderGeometry(0.16, 0.22, 0.7, 8), + this.darkMat + ); + leg.position.set(side * 0.65, 0.35, back ? -0.8 : 0.7); + this.registerFadeMesh(leg); + this.model.add(leg); + const claw = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.14, 0.5), this.skinMat); + claw.position.set(side * 0.65, 0.08, back ? -0.8 : 0.7); + this.registerFadeMesh(claw); + this.model.add(claw); + } + } + + // --- Flügel (animiertes Flattern) --- + const wingGeom = new THREE.BoxGeometry(0.1, 2.4, 1.5); + this.wingL = new THREE.Group(); + this.wingL.position.set(-0.7, 2.2, -0.2); + const wingLmesh = new THREE.Mesh(wingGeom, this.wingMat); + wingLmesh.position.set(-1.6, 0, 0); + wingLmesh.rotation.z = Math.PI / 2; + wingLmesh.castShadow = true; + this.registerFadeMesh(wingLmesh); + this.wingL.add(wingLmesh); + this.model.add(this.wingL); + + this.wingR = new THREE.Group(); + this.wingR.position.set(0.7, 2.2, -0.2); + const wingRmesh = new THREE.Mesh(wingGeom, this.wingMat); + wingRmesh.position.set(1.6, 0, 0); + wingRmesh.rotation.z = -Math.PI / 2; + wingRmesh.castShadow = true; + this.registerFadeMesh(wingRmesh); + this.wingR.add(wingRmesh); + this.model.add(this.wingR); + + // --- Effekte --- + + // Boden-Schatten (folgt dem Drachen) + this.shadowMat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0.3, + }); + this.shadow = new THREE.Mesh(new THREE.CircleGeometry(2.2, 24), this.shadowMat); + this.shadow.rotation.x = -Math.PI / 2; + this.shadow.renderOrder = 998; + this.body.add(this.shadow); + + // Telegraf-Schatten am Sturz-Ziel (wächst) + this.diveShadowMat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0, + depthWrite: false, + }); + this.diveShadow = new THREE.Mesh(new THREE.CircleGeometry(2.4, 24), this.diveShadowMat); + this.diveShadow.rotation.x = -Math.PI / 2; + this.diveShadow.position.y = 0.05; + this.diveShadow.scale.setScalar(0.3); + this.diveShadow.visible = false; + + // Feuer-Telegraf (Bogen vor dem Drachen) und Feuer-Kegel + this.telegraphArcMat = new THREE.MeshBasicMaterial({ + color: 0xff6644, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + const arcGeom = new THREE.RingGeometry( + BREATH_RANGE - 0.8, BREATH_RANGE - 0.2, 48, 1, + -BREATH_HALF_ANGLE - Math.PI / 2, BREATH_HALF_ANGLE * 2 + ); + this.telegraphArc = new THREE.Mesh(arcGeom, this.telegraphArcMat); + this.telegraphArc.rotation.x = -Math.PI / 2; + this.telegraphArc.position.y = 0.05; + this.telegraphArc.visible = false; + this.body.add(this.telegraphArc); + + this.breathGroup = new THREE.Group(); + this.breathMat = new THREE.MeshBasicMaterial({ + color: 0xff8833, + transparent: true, + opacity: 0, + blending: THREE.AdditiveBlending, + depthWrite: false, + side: THREE.DoubleSide, + }); + const flame = new THREE.Mesh(new THREE.ConeGeometry(1.8, BREATH_RANGE, 16, 1, true), this.breathMat); + flame.rotation.x = Math.PI / 2; + flame.position.z = BREATH_RANGE / 2; + flame.position.y = 1.2; + this.breathGroup.add(flame); + this.breathGroup.visible = false; + this.body.add(this.breathGroup); + } + + playAnim() { + // Prozedurale Optik + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); + } + + update(dt: number, game: Game) { + this.game = game; + this.waveTime += dt; + + // Hit-Feedback + if (this.hitFlash > 0) { + this.hitFlash -= dt; + this.skinMat.color.set(0xffffff); + this.darkMat.color.set(0xffffff); + this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05)); + } else { + this.skinMat.color.set(this.enraged ? 0x992211 : 0xbb4433); + this.darkMat.color.set(this.enraged ? 0x551108 : 0x772a1e); + this.model.scale.setScalar(MODEL_SCALE); + } + + // Enrage unter 35% HP + if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) { + this.enraged = true; + this.eyeMat.color.set(0xff2200); + playSound('damage', 0.9); + game.triggerCameraShake(0.35, 0.5); + } + + // Flügel-Flattern (schneller im Flug) + const flapSpeed = this.state === 'fly' || this.state === 'takeoff' ? 9 : 2; + const flap = Math.sin(this.waveTime * flapSpeed) * (this.state === 'fly' ? 0.7 : 0.2); + this.wingL.rotation.z = flap; + this.wingR.rotation.z = -flap; + // Flügel-Einstellwinkel + this.wingL.rotation.x = -0.3; + this.wingR.rotation.x = 0.3; + + // Schatten bleibt am Boden + this.shadow.position.y = 0.06 - this.body.position.y; + const shadowScale = 0.6 + this.body.position.y * 0.15; + this.shadow.scale.setScalar(shadowScale); + + // Sturz-Telegraf-Schatten + if (this.diveShadowActive) { + if (!this.diveShadowAdded) { + game.scene.add(this.diveShadow); + this.diveShadowAdded = true; + } + const t = 1 - this.stateTime / DIVEWINDUP_TIME; + this.diveShadow.position.set(this.diveTarget.x, 0.05, this.diveTarget.z); + this.diveShadow.scale.setScalar(0.3 + t * 0.9); + this.diveShadowMat.opacity = 0.2 + t * 0.35; + } else { + this.diveShadow.visible = false; + } + + switch (this.state) { + case 'fly': this.updateFly(dt, game); break; + case 'divewindup': this.updateDiveWindup(dt, game); break; + case 'dive': this.updateDive(dt, game); break; + case 'diverecover': this.updateDiveRecover(dt); break; + case 'landed': this.updateLanded(dt, game); break; + case 'takeoff': this.updateTakeoff(dt); break; + } + + // Feuerbälle + for (let i = this.fireballs.length - 1; i >= 0; i--) { + if (!this.fireballs[i].update(dt, game)) { + this.fireballs[i].dispose(); + this.fireballs.splice(i, 1); + } + } + + // Aufprall-Blitzflächen + for (let i = this.impactFlashes.length - 1; i >= 0; i--) { + const f = this.impactFlashes[i]; + f.life -= dt; + const t = 1 - Math.max(0, f.life) / 0.35; + f.mesh.scale.setScalar(0.5 + t * 7); + f.mat.opacity = 0.7 * (1 - t); + if (f.life <= 0) { + game.scene.remove(f.mesh); + f.mesh.geometry.dispose(); + f.mat.dispose(); + this.impactFlashes.splice(i, 1); + } + } + } + + // --- Flug --- + + private updateFly(dt: number, game: Game) { + this.stateTime -= dt; + this.body.position.y = FLY_HEIGHT; + + // Kreisen über dem Spieler + const playerPos = game.player.body.position; + this.flyAngle += dt * 0.8; + const cx = playerPos.x; + const cz = playerPos.z; + const x = cx + Math.cos(this.flyAngle) * FLY_RADIUS; + const z = cz + Math.sin(this.flyAngle) * FLY_RADIUS; + this.body.position.x = THREE.MathUtils.clamp(x, -WALL_LIMIT + 3, WALL_LIMIT - 3); + this.body.position.z = THREE.MathUtils.clamp(z, -WALL_LIMIT + 3, WALL_LIMIT - 3); + + // Flugrichtung anschauen (kreisen) + const lookX = cx + Math.cos(this.flyAngle + 0.5) * FLY_RADIUS; + const lookZ = cz + Math.sin(this.flyAngle + 0.5) * FLY_RADIUS; + this.body.lookAt(lookX, this.body.position.y, lookZ); + + // Feuerbälle abwerfen + this.flyFireballTimer -= dt; + if (this.flyFireballTimer <= 0) { + this.flyFireballTimer = FLY_FIREBALL_CD; + this.throwFireballs(game); + } + + if (this.stateTime <= 0) { + // Sturzangriff vorbereiten + this.state = 'divewindup'; + this.stateTime = this.enraged ? DIVEWINDUP_TIME_2 : DIVEWINDUP_TIME; + this.doubleDiveLeft = this.enraged ? 1 : 0; + this.diveTarget.copy(game.player.body.position); + this.diveTarget.y = 0; + this.game?.clampToArena(this.diveTarget); + this.diveShadow.visible = true; + this.diveShadow.position.set(this.diveTarget.x, 0.05, this.diveTarget.z); + this.diveShadowActive = true; + playSound('spawn', 0.7); + } + } + + // --- Sturzangriff --- + + private updateDiveWindup(dt: number, game: Game) { + this.stateTime -= dt; + this.body.position.y = FLY_HEIGHT; + // Über dem Ziel schweben + this.body.position.x += (this.diveTarget.x - this.body.position.x) * Math.min(1, dt * 4); + this.body.position.z += (this.diveTarget.z - this.body.position.z) * Math.min(1, dt * 4); + this.body.lookAt( + this.diveTarget.x, + this.body.position.y, + this.diveTarget.z + ); + + if (this.stateTime <= 0) { + this.diveShadowActive = false; + this.diveShadow.visible = false; + this.state = 'dive'; + this.stateTime = DIVE_TIME; + this.diveFrom.copy(this.body.position); + playSound('spawn', 0.9); + } + } + + private updateDive(dt: number, game: Game) { + this.stateTime -= dt; + const t = 1 - Math.max(0, this.stateTime) / DIVE_TIME; + const ease = t * t; + this.body.position.x = this.diveFrom.x + (this.diveTarget.x - this.diveFrom.x) * ease; + this.body.position.z = this.diveFrom.z + (this.diveTarget.z - this.diveFrom.z) * ease; + this.body.position.y = FLY_HEIGHT * (1 - ease); + + if (this.stateTime <= 0) { + this.body.position.y = 0.15; + this.diveImpact(game); + } + } + + private diveImpact(game: Game) { + // Aufprall: Schock + Schaden (überspringbar: nur am Boden) + game.triggerCameraShake(0.4, 0.5); + playSound('explosion', 0.8); + + const flashMat = new THREE.MeshBasicMaterial({ + color: 0xff8844, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + depthWrite: false, + }); + const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat); + flash.rotation.x = -Math.PI / 2; + flash.position.set(this.body.position.x, 0.07, this.body.position.z); + game.scene.add(flash); + this.impactFlashes.push({ mesh: flash, mat: flashMat, life: 0.35 }); + + if (game.player.health > 0 && game.player.body.position.y <= 0.8) { + const dx = game.player.body.position.x - this.body.position.x; + const dz = game.player.body.position.z - this.body.position.z; + if (Math.hypot(dx, dz) < DIVE_IMPACT_RADIUS) { + game.player.damage(DIVE_DAMAGE, this.body.position); + } + } + + if (this.doubleDiveLeft > 0) { + this.doubleDiveLeft--; + this.state = 'diverecover'; + this.stateTime = DIVERECOVER_TIME; + } else { + this.state = 'landed'; + this.stateTime = this.enraged ? LANDED_TIME_2 : LANDED_TIME; + this.breathPhase = 'idle'; + this.breathHit = false; + this.breathActive = false; + } + } + + private updateDiveRecover(dt: number) { + this.stateTime -= dt; + if (this.stateTime <= 0) { + // Doppel-Sturz: neues Ziel = aktuelle Spielerposition + this.state = 'divewindup'; + this.stateTime = this.enraged ? DIVEWINDUP_TIME_2 : DIVEWINDUP_TIME; + const p = this.game?.player.body.position; + if (p) { + this.diveTarget.set(p.x, 0, p.z); + this.game?.clampToArena(this.diveTarget); + } + this.diveShadow.visible = true; + this.diveShadowActive = true; + } + } + + // --- Gelandet (Angriffs-Fenster) --- + + private updateLanded(dt: number, game: Game) { + this.stateTime -= dt; + this.body.position.y = 0.15; + + // Zum Spieler drehen + const playerPos = game.player.body.position; + const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position); + toPlayer.y = 0; + if (toPlayer.length() > 0.1) { + this.body.lookAt( + this.body.position.x + toPlayer.x, + this.body.position.y, + this.body.position.z + toPlayer.z + ); + } + + // Feuerspeer: Windup -> aktiv -> fertig + if (this.breathPhase === 'idle') { + this.breathPhase = 'windup'; + this.breathTime = BREATH_WINDUP; + this.breathHit = false; + this.telegraphArc.visible = true; + playSound('spawn', 0.6); + } else if (this.breathPhase === 'windup') { + this.breathTime -= dt; + (this.telegraphArc.material as THREE.MeshBasicMaterial).opacity = + 0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 12)); + if (this.breathTime <= 0) { + this.breathPhase = 'active'; + this.breathTime = BREATH_TIME; + this.telegraphArc.visible = false; + this.breathGroup.visible = true; + this.breathActive = true; + this.jaw.rotation.x = -0.5; + } + } else if (this.breathPhase === 'active') { + this.breathTime -= dt; + const t = 1 - this.breathTime / BREATH_TIME; + this.breathMat.opacity = 0.35 + Math.sin(t * Math.PI) * 0.4; + this.breathGroup.scale.z = 0.8 + Math.sin(t * Math.PI * 2) * 0.15; + + // Treffer in der Mitte des Feuers + if (!this.breathHit && t >= 0.4) { + this.breathHit = true; + const dx = playerPos.x - this.body.position.x; + const dz = playerPos.z - this.body.position.z; + const dist = Math.hypot(dx, dz); + if (dist < BREATH_RANGE && playerPos.y <= 0.6) { + const ang = Math.atan2(dx, dz); + const yaw = this.body.rotation.y; + let diff = ang - yaw; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + if (Math.abs(diff) < BREATH_HALF_ANGLE) { + game.player.damage(BREATH_DAMAGE, this.body.position); + playSound('explosion', 0.7); + } + } + } + if (this.breathTime <= 0) { + this.breathPhase = 'done'; + this.breathGroup.visible = false; + this.breathMat.opacity = 0; + this.jaw.rotation.x = 0.15; + } + } + + if (this.stateTime <= 0) { + this.state = 'takeoff'; + this.stateTime = TAKEOFF_TIME; + this.telegraphArc.visible = false; + this.breathGroup.visible = false; + playSound('spawn', 0.6); + } + } + + // --- Abheben --- + + private updateTakeoff(dt: number) { + this.stateTime -= dt; + const t = 1 - Math.max(0, this.stateTime) / TAKEOFF_TIME; + this.body.position.y = 0.15 + t * TAKEOFF_HEIGHT; + if (this.stateTime <= 0) { + this.state = 'fly'; + this.stateTime = this.enraged ? FLY_TIME_2 : FLY_TIME; + this.flyFireballTimer = 1.2; + this.breathPhase = 'idle'; + } + } + + // --- Feuerbälle (Flug) --- + + private throwFireballs(game: Game) { + const from = new THREE.Vector3(this.body.position.x, this.body.position.y - 2, this.body.position.z); + const target = game.player.body.position.clone(); + const count = this.enraged ? FIREBALL_COUNT_2 : FIREBALL_COUNT; + const baseDir = new THREE.Vector3().subVectors(target, from); + baseDir.y = 0; + const horizDist = baseDir.length(); + baseDir.normalize(); + const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x); + + // Ballistisches Zielen: Abwärtsgeschwindigkeit so wählen, dass der Ball + // die Spielerhöhe genau beim Überflug erreicht (sonst segeln sie drüber). + const flightTime = Math.max(0.3, horizDist / FIREBALL_SPEED); + const dropVy = (target.y - from.y + 0.5 * FIREBALL_GRAVITY * flightTime * flightTime) / flightTime; + + for (let i = 0; i < count; i++) { + const off = (i / (count - 1) - 0.5) * 2 * FIREBALL_SPREAD; + const dir = new THREE.Vector3() + .addScaledVector(baseDir, 1) + .addScaledVector(perp, off) + .normalize() + .multiplyScalar(FIREBALL_SPEED); + dir.y = dropVy; + const fb = new DragonFireball(from.clone(), dir); + game.scene.add(fb.body); + this.fireballs.push(fb); + } + playSound('spawn', 0.5); + } + + // --- Schaden --- + + override takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + if (this.dead) return; + // Im Flug unverwundbar: nur am Boden (landed) verletzbar + if (this.state !== 'landed') return; + super.takeDamage(damage, game, withKnockback, sourcePos); + this.hitFlash = HIT_FLASH_TIME; + } + + protected onDeath(_game: Game) { + this.cleanupEffects(); + this.beginFadeDeath(); + } + + override dispose() { + super.dispose(); + this.cleanupEffects(); + } + + private cleanupEffects() { + for (const fb of this.fireballs) fb.dispose(); + this.fireballs = []; + if (this.game) { + for (const f of this.impactFlashes) { + this.game.scene.remove(f.mesh); + f.mesh.geometry.dispose(); + f.mat.dispose(); + } + } + this.impactFlashes = []; + this.diveShadowActive = false; + this.diveShadow.visible = false; + if (this.diveShadowAdded && this.game) { + this.game.scene.remove(this.diveShadow); + } + this.diveShadowAdded = false; + this.telegraphArc.visible = false; + this.breathGroup.visible = false; + this.skinMat.color.set(0xbb4433); + this.darkMat.color.set(0x772a1e); + this.eyeMat.color.set(0xffcc33); + this.model.scale.setScalar(MODEL_SCALE); + } +} + +// Feuerball: gerades Projektil mit leichter Gravitation +class DragonFireball { + body: THREE.Group; + private vel: THREE.Vector3; + private life = 4; + dead = false; + + constructor(from: THREE.Vector3, dir: THREE.Vector3) { + this.body = new THREE.Group(); + this.body.position.copy(from); + this.vel = dir.clone(); + + const glowMat = new THREE.MeshBasicMaterial({ + color: 0xffaa33, + transparent: true, + opacity: 0.5, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const glow = new THREE.Mesh(new THREE.SphereGeometry(0.45, 12, 12), glowMat); + this.body.add(glow); + + const coreMat = new THREE.MeshBasicMaterial({ + color: 0xffdd55, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const core = new THREE.Mesh(new THREE.SphereGeometry(0.24, 10, 10), coreMat); + this.body.add(core); + } + + update(dt: number, game: Game): boolean { + this.life -= dt; + + // Homing: sanft in Richtung des aktuellen Spielerstandorts lenken, + // damit die Bälle nicht über den Kopf segeln. Der begrenzte Lenkwinkel + // (FIREBALL_HOMING als Lerp-Rate) hält sie ausweichbar. + if (game.player.health > 0) { + const toPlayer = new THREE.Vector3() + .subVectors(game.player.body.position, this.body.position) + .normalize(); + const desired = toPlayer.multiplyScalar(FIREBALL_SPEED); + this.vel.lerp(desired, Math.min(1, dt * FIREBALL_HOMING)); + } + + this.body.position.addScaledVector(this.vel, dt); + + if (this.body.position.y < 0.15) { + this.body.position.y = 0.15; + } + + if (game.player.health > 0) { + const dist = this.body.position.distanceTo(game.player.body.position); + if (dist < FIREBALL_HIT_RADIUS) { + game.player.damage(FIREBALL_DAMAGE, this.body.position); + this.dead = true; + return false; + } + } + + if (this.life <= 0) { + this.dead = true; + return false; + } + return true; + } + + dispose() { + this.body.removeFromParent(); + for (const child of [...this.body.children]) { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + } +} diff --git a/web/src/FrostGiant.ts b/web/src/FrostGiant.ts new file mode 100644 index 0000000..a78d794 --- /dev/null +++ b/web/src/FrostGiant.ts @@ -0,0 +1,692 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { playSound } from './SoundManager'; + +const MAX_HEALTH = 10500; +const MAX_ARMOR = 2500; +const ARMOR_REGEN_TIME = 10; +const ARMOR_REDUCTION = 0.7; +const HIT_FLASH_TIME = 0.14; +const MODEL_SCALE = 2.0; +const MOVE_SPEED = 3.5; + +const BURST_COOLDOWN = 7; +const BURST_COOLDOWN_2 = 5; +const BURST_MARK_COUNT = 8; +const BURST_MARK_COUNT_2 = 12; +const BURST_RING2_RADIUS = 13; +const BURST_MARK_RADIUS = 2.8; +const BURST_TELEGRAPH = 1.6; +const BURST_STAGGER = 0.18; +const BURST_DAMAGE = 22; +const BURST_RADIUS = 9; + +const RAIN_COOLDOWN = 5; +const RAIN_COOLDOWN_2 = 4; +const RAIN_COUNT = 7; +const RAIN_COUNT_2 = 10; + +const STOMP_COOLDOWN = 10; +const STOMP_COOLDOWN_2 = 8; +const STOMP_RADIUS = 11; +const STOMP_DAMAGE = 15; + +const ENRAGE_RATIO = 0.35; +const ICICLE_DAMAGE = 30; +const ICICLE_TELEGRAPH = 0.9; +const ICICLE_IMPACT_RADIUS = 1.8; + +export class FrostGiant extends Enemy { + private model: THREE.Group; + private game: Game | null = null; + private waveTime = 0; + private enraged = false; + private hitFlash = 0; + + private skinMat!: THREE.MeshToonMaterial; + private darkMat!: THREE.MeshToonMaterial; + private eyeMat!: THREE.MeshBasicMaterial; + private armorShards: THREE.Mesh[] = []; + private armorShardMats: THREE.MeshToonMaterial[] = []; + private armorBroken = false; + private armorHp = MAX_ARMOR; + private armorRegenTimer = 0; + private armorGrow = 0; + private burstTimer = 3; + private rainTimer = 2; + private stompTimer = 6; + + private burstMarks: { + group: THREE.Group; + ringMat: THREE.MeshBasicMaterial; + discMat: THREE.MeshBasicMaterial; + index: number; + exploded: boolean; + explAt: number; + }[] = []; + private burstActive = false; + private burstExplodeStart = 0; + private burstStartTime = 0; + private burstFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = []; + private icicles: FallingIcicle[] = []; + private shockRing!: THREE.Mesh; + private shockRingMat!: THREE.MeshBasicMaterial; + private shockRingLife = 0; + + constructor() { + super(); + this.health = MAX_HEALTH; + this.maxHealth = MAX_HEALTH; + this.isBoss = true; + this.displayName = 'Frostriese'; + this.speed = MOVE_SPEED; + this.xp = 280; + this.contactDps = 35; + this.contactRadius = 3.2; + this.guaranteedDrop = true; + + this.model = new THREE.Group(); + this.model.scale.setScalar(MODEL_SCALE); + this.fadeOutModel = this.model; + this.body.add(this.model); + + this.skinMat = new THREE.MeshToonMaterial({ color: 0x77bbdd }); + this.darkMat = new THREE.MeshToonMaterial({ color: 0x4477aa }); + this.eyeMat = new THREE.MeshBasicMaterial({ color: 0x88ddff }); + const crystalMat = new THREE.MeshToonMaterial({ color: 0xccf0ff, emissive: 0x223344 }); + const metalMat = new THREE.MeshToonMaterial({ color: 0x5599bb }); + + // Beine + for (const side of [-1, 1]) { + const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.45, 1.2, 10), this.darkMat); + leg.position.set(side * 0.55, -0.6, 0); + leg.castShadow = true; + this.registerFadeMesh(leg); + this.model.add(leg); + const foot = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.25, 0.9), this.skinMat); + foot.position.set(side * 0.55, -1.25, 0.1); + this.registerFadeMesh(foot); + this.model.add(foot); + } + + // Torso + const torso = new THREE.Mesh(new THREE.BoxGeometry(2.0, 2.2, 1.4), this.skinMat); + torso.position.y = 0.9; + torso.castShadow = true; + this.registerFadeMesh(torso); + this.model.add(torso); + + // Bauchpanzer + const belly = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.9, 0.1), metalMat); + belly.position.set(0, 0.55, 0.72); + this.registerFadeMesh(belly); + this.model.add(belly); + + // Schultern + for (const side of [-1, 1]) { + const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.7, 12, 10), this.skinMat); + shoulder.position.set(side * 1.3, 1.7, 0); + shoulder.castShadow = true; + this.registerFadeMesh(shoulder); + this.model.add(shoulder); + + // Arme + const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.42, 1.4, 8), this.darkMat); + arm.position.set(side * 1.6, 0.6, 0); + this.registerFadeMesh(arm); + this.model.add(arm); + + // Faust + const fist = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), this.skinMat); + fist.position.set(side * 1.7, 0.0, 0.2); + this.registerFadeMesh(fist); + this.model.add(fist); + } + + // Kopf + const head = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.9, 0.95), this.skinMat); + head.position.y = 2.5; + head.castShadow = true; + this.registerFadeMesh(head); + this.model.add(head); + + // Frostbart (+Z) + const beard = new THREE.Mesh(new THREE.ConeGeometry(0.45, 0.7, 8), this.darkMat); + beard.position.set(0, 1.95, 0.5); + beard.rotation.x = Math.PI; + this.registerFadeMesh(beard); + this.model.add(beard); + + // Augen (+Z) + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), this.eyeMat); + eye.position.set(side * 0.3, 2.62, 0.5); + this.model.add(eye); + } + + // Eiskristall-Panzer (verdeckt den Rumpf, solange die Rüstung hält) + const shardPositions: [number, number, number, number][] = [ + [0, 1.5, 0.75, 0], + [-0.7, 1.3, 0.7, 0.4], + [0.7, 1.3, 0.7, -0.4], + [0, 2.1, 0.7, 0], + [-1.4, 1.7, 0, 0.8], + [1.4, 1.7, 0, -0.8], + [-0.6, 0.7, 0.75, 0.3], + [0.6, 0.7, 0.75, -0.3], + [-0.3, 2.6, 0.5, 0.5], + [0.3, 2.6, 0.5, -0.5], + ]; + for (const [x, y, z, rotY] of shardPositions) { + const mat = crystalMat.clone(); + const shard = new THREE.Mesh(new THREE.ConeGeometry(0.35, 0.9, 6), mat); + shard.position.set(x, y, z); + shard.rotation.set(-0.15, rotY, 0); + this.registerFadeMesh(shard); + this.model.add(shard); + this.armorShards.push(shard); + this.armorShardMats.push(mat); + } + + // Schockwellen-Ring + this.shockRingMat = new THREE.MeshBasicMaterial({ + color: 0x88ccff, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + this.shockRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.0, 48), this.shockRingMat); + this.shockRing.rotation.x = -Math.PI / 2; + this.shockRing.position.y = 0.06; + this.shockRing.visible = false; + this.body.add(this.shockRing); + } + + playAnim() { + // Prozedurale Optik + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); + } + + update(dt: number, game: Game) { + this.game = game; + this.waveTime += dt; + + // Hit-Feedback + if (this.hitFlash > 0) { + this.hitFlash -= dt; + this.skinMat.color.set(0xffffff); + this.darkMat.color.set(0xffffff); + this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05)); + } else { + const dark = this.armorBroken; + this.skinMat.color.set(dark ? 0x4477aa : 0x77bbdd); + this.darkMat.color.set(dark ? 0x2a4a6a : 0x4477aa); + this.model.scale.setScalar(MODEL_SCALE); + } + + // Enrage unter 35% HP + if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) { + this.enraged = true; + this.eyeMat.color.set(0xff4422); + playSound('damage', 0.9); + game.triggerCameraShake(0.35, 0.5); + } + + // Rüstungs-Regeneration + if (this.armorBroken) { + this.armorRegenTimer -= dt; + const t = 1 - Math.max(0, this.armorRegenTimer) / ARMOR_REGEN_TIME; + // Kristalle wachsen langsam nach, kurz vor Abschluss deutlich sichtbar + this.armorGrow = Math.min(1, t * 1.6); + const showScale = this.armorGrow; + for (const shard of this.armorShards) shard.visible = showScale > 0.02; + for (let i = 0; i < this.armorShards.length; i++) { + this.armorShards[i].scale.setScalar(showScale); + this.armorShardMats[i].opacity = Math.min(1, showScale * 2); + } + if (this.armorRegenTimer <= 0) { + this.armorBroken = false; + this.armorHp = MAX_ARMOR; + this.armorRegenTimer = 0; + for (const shard of this.armorShards) shard.scale.setScalar(1); + for (const mat of this.armorShardMats) mat.opacity = 1; + playSound('spawn', 0.7); + } + } + + // Zum Spieler laufen + const playerPos = game.player.body.position; + const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + if (dist > 0.1) { + const dir = toPlayer.normalize(); + this.body.lookAt( + this.body.position.x + dir.x, + this.body.position.y, + this.body.position.z + dir.z + ); + let moveDir = dir.multiplyScalar(MOVE_SPEED); + if (this.knockback > 0) { + moveDir = dir.multiplyScalar(-MOVE_SPEED); + this.knockback -= dt; + } + this.body.position.x += moveDir.x * dt; + this.body.position.z += moveDir.z * dt; + game.clampToArena(this.body.position); + } + + // Angriffe + this.burstTimer -= dt; + if (this.burstTimer <= 0 && !this.burstActive) { + this.burstTimer = this.enraged ? BURST_COOLDOWN_2 : BURST_COOLDOWN; + this.startBurst(game); + } + this.rainTimer -= dt; + if (this.rainTimer <= 0) { + this.rainTimer = this.enraged ? RAIN_COOLDOWN_2 : RAIN_COOLDOWN; + this.icicleRain(game); + } + this.stompTimer -= dt; + if (this.stompTimer <= 0) { + this.stompTimer = this.enraged ? STOMP_COOLDOWN_2 : STOMP_COOLDOWN; + this.stomp(game); + } + + this.updateBurst(dt, game); + this.updateBurstFlashes(dt); + + // Schockwellen-Ring expandieren + if (this.shockRing.visible) { + const s = this.shockRing.scale.x + dt * 26; + this.shockRing.scale.setScalar(s); + this.shockRingMat.opacity = Math.max(0, 0.5 - (s / STOMP_RADIUS) * 0.5); + if (s >= STOMP_RADIUS) this.shockRing.visible = false; + } + + // Eiszapfen + for (let i = this.icicles.length - 1; i >= 0; i--) { + if (!this.icicles[i].update(dt, game)) { + this.icicles[i].dispose(); + this.icicles.splice(i, 1); + } + } + } + + // --- Eispanzer --- + + private breakArmor(game: Game) { + this.armorBroken = true; + this.armorRegenTimer = ARMOR_REGEN_TIME; + this.armorGrow = 0; + for (const shard of this.armorShards) shard.visible = false; + for (const mat of this.armorShardMats) mat.opacity = 0; + game.spawnDamageText('PANZER WEG!', this.body.position, '#88ccff'); + game.triggerCameraShake(0.4, 0.5); + playSound('explosion', 0.9); + } + + // --- Eisberst-Ring --- + + private startBurst(game: Game) { + this.burstActive = true; + this.burstExplodeStart = BURST_TELEGRAPH; + this.burstStartTime = this.waveTime; + this.burstMarks = []; + const count = this.enraged ? BURST_MARK_COUNT_2 : BURST_MARK_COUNT; + const offset = Math.random() * Math.PI * 2; + const rings = this.enraged + ? [{ radius: BURST_RADIUS, count: BURST_MARK_COUNT }] + : [{ radius: BURST_RADIUS, count }]; + if (this.enraged) { + rings.push({ radius: BURST_RING2_RADIUS, count: BURST_MARK_COUNT - 2 }); + } + const cx = this.body.position.x; + const cz = this.body.position.z; + let index = 0; + for (const ring of rings) { + for (let i = 0; i < ring.count; i++) { + const a = offset + (i / ring.count) * Math.PI * 2; + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x66ccff, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + const discMat = new THREE.MeshBasicMaterial({ + color: 0x4488ff, + transparent: true, + opacity: 0, + depthWrite: false, + }); + const ringMesh = new THREE.Mesh( + new THREE.RingGeometry(BURST_MARK_RADIUS - 0.15, BURST_MARK_RADIUS + 0.15, 32), + ringMat + ); + ringMesh.rotation.x = -Math.PI / 2; + ringMesh.position.y = 0.05; + const disc = new THREE.Mesh( + new THREE.CircleGeometry(BURST_MARK_RADIUS, 32), + discMat + ); + disc.rotation.x = -Math.PI / 2; + disc.position.y = 0.03; + const group = new THREE.Group(); + group.add(ringMesh, disc); + group.position.set( + cx + Math.cos(a) * ring.radius, + 0, + cz + Math.sin(a) * ring.radius + ); + game.scene.add(group); + this.burstMarks.push({ group, ringMat, discMat, index, exploded: false, explAt: 0 }); + index++; + } + } + playSound('spawn', 0.7); + } + + private updateBurst(dt: number, game: Game) { + if (!this.burstActive) return; + if (this.burstExplodeStart > 0) { + // Telegraf: Marken pulsieren + this.burstExplodeStart -= dt; + const pulse = Math.abs(Math.sin(this.waveTime * 9)); + for (const mark of this.burstMarks) { + mark.ringMat.opacity = 0.3 + 0.5 * pulse; + mark.discMat.opacity = 0.12 + 0.08 * pulse; + } + if (this.burstExplodeStart <= 0) { + // Explosionsfenster: nacheinander + this.burstExplodeStart = -1; + } + return; + } + + let allDone = true; + for (const mark of this.burstMarks) { + if (!mark.exploded) { + allDone = false; + const t = (this.waveTime - this.burstStartTime) - (mark.index * BURST_STAGGER); + if (t >= 0) { + mark.exploded = true; + this.explodeMark(mark, game); + } + } + } + if (allDone) { + this.burstActive = false; + for (const mark of this.burstMarks) { + game.scene.remove(mark.group); + mark.group.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry.dispose(); + (obj.material as THREE.Material).dispose(); + } + }); + } + this.burstMarks = []; + } + } + + private explodeMark(mark: { group: THREE.Group }, game: Game) { + // Expandierender Frost-Ring + const flashMat = new THREE.MeshBasicMaterial({ + color: 0x99ddff, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + depthWrite: false, + }); + const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat); + flash.rotation.x = -Math.PI / 2; + flash.position.set(mark.group.position.x, 0.07, mark.group.position.z); + game.scene.add(flash); + this.burstFlashes.push({ mesh: flash, mat: flashMat, life: 0.35 }); + + // Schaden (überspringbar: nur am Boden) + if (game.player.health > 0 && game.player.body.position.y <= 0.8) { + const dx = game.player.body.position.x - mark.group.position.x; + const dz = game.player.body.position.z - mark.group.position.z; + if (Math.hypot(dx, dz) < BURST_MARK_RADIUS + 0.3) { + game.player.damage(BURST_DAMAGE, mark.group.position); + } + } + playSound('explosion', 0.4); + } + + private updateBurstFlashes(dt: number) { + for (let i = this.burstFlashes.length - 1; i >= 0; i--) { + const f = this.burstFlashes[i]; + f.life -= dt; + const t = 1 - Math.max(0, f.life) / 0.35; + f.mesh.scale.setScalar(0.5 + t * 6); + f.mat.opacity = 0.7 * (1 - t); + if (f.life <= 0) { + this.game?.scene.remove(f.mesh); + f.mesh.geometry.dispose(); + f.mat.dispose(); + this.burstFlashes.splice(i, 1); + } + } + } + + // --- Eiszapfen-Regen --- + + private icicleRain(game: Game) { + const playerPos = game.player.body.position; + const count = this.enraged ? RAIN_COUNT_2 : RAIN_COUNT; + for (let i = 0; i < count; i++) { + const a = Math.random() * Math.PI * 2; + const r = 2 + Math.random() * 6; + const icicle = new FallingIcicle(new THREE.Vector3( + playerPos.x + Math.cos(a) * r, + 0, + playerPos.z + Math.sin(a) * r + )); + game.scene.add(icicle.body); + this.icicles.push(icicle); + } + playSound('spawn', 0.5); + } + + // --- Stampf --- + + private stomp(game: Game) { + this.shockRing.visible = true; + this.shockRing.scale.setScalar(0.2); + this.shockRingMat.opacity = 0.5; + playSound('explosion', 0.7); + + const playerPos = game.player.body.position; + const dist = Math.hypot( + playerPos.x - this.body.position.x, + playerPos.z - this.body.position.z + ); + // Überspringbar: nur Treffer am Boden + if (dist < STOMP_RADIUS && playerPos.y <= 0.6) { + game.player.damage(STOMP_DAMAGE, this.body.position); + } + game.triggerCameraShake(0.3, 0.3); + } + + override takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + if (this.dead) return; + if (this.armorBroken === false && this.armorShards.length > 0) { + const absorbed = Math.min(MAX_ARMOR, damage * ARMOR_REDUCTION); + damage *= 1 - ARMOR_REDUCTION; + game.spawnDamageText('Eispanzer!', this.body.position, '#99ccff'); + // Rüstung abbauen: Kristalle werden mit sinkender Armor-HP blasser + const pct = 1 - this.armorHp / MAX_ARMOR; + for (let i = 0; i < this.armorShards.length; i++) { + const shard = this.armorShards[i]; + const threshold = (i + 1) / this.armorShards.length; + shard.visible = pct < threshold; + if (!shard.visible) { + this.armorShardMats[i].opacity = 0; + } + } + this.armorHp -= absorbed; + if (this.armorHp <= 0) { + this.armorHp = 0; + this.breakArmor(game); + } + } + super.takeDamage(damage, game, withKnockback, sourcePos); + this.hitFlash = HIT_FLASH_TIME; + } + + protected onDeath(_game: Game) { + this.cleanupEffects(); + this.beginFadeDeath(); + } + + override dispose() { + super.dispose(); + this.cleanupEffects(); + } + + private cleanupEffects() { + this.burstActive = false; + if (this.game) { + for (const mark of this.burstMarks) { + this.game.scene.remove(mark.group); + mark.group.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry.dispose(); + (obj.material as THREE.Material).dispose(); + } + }); + } + for (const f of this.burstFlashes) { + this.game.scene.remove(f.mesh); + f.mesh.geometry.dispose(); + f.mat.dispose(); + } + } + this.burstMarks = []; + this.burstFlashes = []; + for (const icicle of this.icicles) icicle.dispose(); + this.icicles = []; + this.shockRing.visible = false; + this.skinMat.color.set(0x77bbdd); + this.darkMat.color.set(0x4477aa); + this.eyeMat.color.set(0x88ddff); + this.model.scale.setScalar(MODEL_SCALE); + for (const shard of this.armorShards) shard.visible = true; + for (const mat of this.armorShardMats) mat.opacity = 1; + } +} + +// Eiszapfen: fällt mit wachsendem Schatten-Telegrafen vom Himmel +class FallingIcicle { + body: THREE.Group; + private shadow: THREE.Mesh; + private icicle: THREE.Mesh; + private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph'; + private timer = ICICLE_TELEGRAPH; + private dust: THREE.Mesh[] = []; + private dustDirs: THREE.Vector3[] = []; + private dustMat: THREE.MeshBasicMaterial; + + constructor(pos: THREE.Vector3) { + this.body = new THREE.Group(); + this.body.position.set(pos.x, 0, pos.z); + + const shadowMat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0.3, + depthWrite: false, + }); + this.shadow = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), shadowMat); + this.shadow.rotation.x = -Math.PI / 2; + this.shadow.position.y = 0.06; + this.shadow.scale.setScalar(0.4); + this.body.add(this.shadow); + + const iceMat = new THREE.MeshToonMaterial({ color: 0xbbeeff, emissive: 0x334455 }); + this.icicle = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.6, 6), iceMat); + this.icicle.position.y = 16; + this.icicle.rotation.x = Math.PI; + this.body.add(this.icicle); + + this.dustMat = new THREE.MeshBasicMaterial({ + color: 0x99bbdd, + transparent: true, + opacity: 0, + depthWrite: false, + }); + } + + update(dt: number, game: Game): boolean { + this.timer -= dt; + + if (this.phase === 'telegraph') { + const t = 1 - this.timer / ICICLE_TELEGRAPH; + this.shadow.scale.setScalar(0.4 + t * 0.6); + (this.shadow.material as THREE.MeshBasicMaterial).opacity = 0.2 + t * 0.2; + if (this.timer <= 0) { + this.phase = 'fall'; + this.timer = 2; + } + } else if (this.phase === 'fall') { + this.icicle.position.y -= 30 * dt; + this.icicle.rotation.z += dt * 2; + if (this.icicle.position.y <= 0.15) { + this.icicle.position.y = 0.15; + this.icicle.visible = false; + this.shadow.visible = false; + + if (game.player.health > 0) { + const dx = game.player.body.position.x - this.body.position.x; + const dz = game.player.body.position.z - this.body.position.z; + if (Math.hypot(dx, dz) < ICICLE_IMPACT_RADIUS) { + game.player.damage(ICICLE_DAMAGE, this.body.position); + } + } + + for (let i = 0; i < 5; i++) { + const a = (i / 5) * Math.PI * 2; + const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a)); + const puff = new THREE.Mesh(new THREE.SphereGeometry(0.25, 6, 5), this.dustMat); + this.body.add(puff); + this.dust.push(puff); + this.dustDirs.push(dir); + } + this.phase = 'dust'; + this.timer = 0.4; + } + } else { + const t = 1 - this.timer / 0.4; + for (let i = 0; i < this.dust.length; i++) { + this.dust[i].position.copy(this.dustDirs[i]).multiplyScalar(0.5 + t * 1.8); + this.dust[i].position.y = 0.2 + t * 0.8; + } + this.dustMat.opacity = 0.5 * (1 - t); + if (this.timer <= 0) return false; + } + return true; + } + + dispose() { + this.body.removeFromParent(); + this.body.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry.dispose(); + (obj.material as THREE.Material).dispose(); + } + }); + } +} diff --git a/web/src/Game.ts b/web/src/Game.ts index b486366..86153dc 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -11,6 +11,10 @@ import { Brute } from './Brute'; import { Golem } from './Golem'; import { Krake } from './Krake'; import { Minotaurus } from './Minotaurus'; +import { SpiderQueen } from './SpiderQueen'; +import { FrostGiant } from './FrostGiant'; +import { Necromancer } from './Necromancer'; +import { Dragon } from './Dragon'; import { Crab } from './Crab'; import { Turret } from './Turret'; import { EnemyProjectile } from './EnemyProjectile'; @@ -83,6 +87,10 @@ export class Game { [10, { name: 'Golem', spawn: () => new Golem() }], [20, { name: 'Krake', spawn: () => new Krake() }], [30, { name: 'Minotaurus', spawn: () => new Minotaurus() }], + [40, { name: 'Spinnenkönigin', spawn: () => new SpiderQueen() }], + [50, { name: 'Frostriese', spawn: () => new FrostGiant() }], + [60, { name: 'Erz-Nekromant', spawn: () => new Necromancer() }], + [70, { name: 'Drache', spawn: () => new Dragon() }], ]); private bossFightActive = false; private shakeTime = 0; @@ -309,7 +317,8 @@ export class Game { return; } if (e.code === 'Space' && this.state === 'playing' - && this.player.pullTime <= 0 && this.player.stunTime <= 0) { + && this.player.pullTime <= 0 && this.player.stunTime <= 0 + && this.player.rootTime <= 0) { this.player.jump(); e.preventDefault(); } @@ -1053,6 +1062,9 @@ export class Game { if (p.stunTime > 0) { // Gestunnt (Stampf-Schockwelle): keine Eingaben p.velocity.set(0, 0, 0); + } else if (p.rootTime > 0) { + // Netz-Fessel (Spinnenkönigin): keine Bewegung, Angriffe bleiben möglich + p.velocity.set(0, 0, 0); } else if (p.pullTime > 0) { // Tentakel-Griff: Spieler wird komplett zum Boss gezogen p.pullTime -= dt; @@ -1083,7 +1095,7 @@ export class Game { } // Jump / gravity - if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0) { + if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0 && p.rootTime <= 0) { p.jumpVelocity -= 20 * dt; p.body.position.y += p.jumpVelocity * dt; if (p.body.position.y <= 0.5) { diff --git a/web/src/Necromancer.ts b/web/src/Necromancer.ts new file mode 100644 index 0000000..bbbfaf7 --- /dev/null +++ b/web/src/Necromancer.ts @@ -0,0 +1,527 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { Ghost } from './Ghost'; +import { Slime } from './Slime'; +import { Spider } from './Spider'; +import { playSound } from './SoundManager'; + +const MAX_HEALTH = 13000; +const HIT_FLASH_TIME = 0.14; +const MODEL_SCALE = 1.8; +const FLOAT_HEIGHT = 1.2; +const BOB_AMPLITUDE = 0.25; +const DRIFT_SPEED = 2.5; + +const TELEPORT_COOLDOWN = 6; +const TELEPORT_COOLDOWN_2 = 4; +const TELEPORT_CHARGE = 1.0; +const TELEPORT_CHARGE_2 = 0.6; +const TELEPORT_RADIUS = 3.2; +const TELEPORT_DAMAGE = 15; +const TELEPORT_RING_PULSE = 6; + +const VOLLEY_COOLDOWN = 4; +const VOLLEY_COOLDOWN_2 = 3; +const VOLLEY_COUNT = 5; +const VOLLEY_COUNT_2 = 7; +const VOLLEY_SPREAD = 0.4; +const BOLT_DAMAGE = 18; + +const SUMMON_COOLDOWN = 8; +const SUMMON_COOLDOWN_2 = 6; +const SUMMON_COUNT = 3; +const SUMMON_COUNT_2 = 5; +const SUMMON_TELEGRAPH = 0.8; + +const ENRAGE_RATIO = 0.4; + +export class Necromancer extends Enemy { + private model: THREE.Group; + private game: Game | null = null; + private waveTime = 0; + private enraged = false; + private hitFlash = 0; + + private robeMat!: THREE.MeshToonMaterial; + private darkMat!: THREE.MeshToonMaterial; + private glowMat!: THREE.MeshBasicMaterial; + private eyeMat!: THREE.MeshBasicMaterial; + private shadow!: THREE.Mesh; + private shadowMat!: THREE.MeshBasicMaterial; + + private teleportTimer = 3; + private volleyTimer = 1.5; + private summonTimer = 5; + private teleportState: 'idle' | 'charge' = 'idle'; + private teleportCharge = 0; + private teleportTarget = new THREE.Vector3(); + private teleportRing!: THREE.Group; + private teleportRingMesh!: THREE.Mesh; + private teleportRingDisc!: THREE.Mesh; + private teleportRingMat!: THREE.MeshBasicMaterial; + private teleportDiscMat!: THREE.MeshBasicMaterial; + private teleportRingInScene = false; + + private spawnRings: { group: THREE.Group; timer: number; spawn: () => void }[] = []; + private arrivalFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = []; + + constructor() { + super(); + this.health = MAX_HEALTH; + this.maxHealth = MAX_HEALTH; + this.isBoss = true; + this.displayName = 'Erz-Nekromant'; + this.speed = 0; + this.xp = 320; + this.contactDps = 15; + this.contactRadius = 1.8; + this.guaranteedDrop = true; + + this.model = new THREE.Group(); + this.model.scale.setScalar(MODEL_SCALE); + this.fadeOutModel = this.model; + this.body.add(this.model); + + this.robeMat = new THREE.MeshToonMaterial({ color: 0x6a3a9a, emissive: 0x1a0a2a }); + this.darkMat = new THREE.MeshToonMaterial({ color: 0x2a1445 }); + this.glowMat = new THREE.MeshBasicMaterial({ color: 0xbb66ff }); + this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xdd88ff }); + + // Robe (umgedrehter Kegel, schwingt beim Schweben) + const robe = new THREE.Mesh(new THREE.ConeGeometry(0.9, 1.8, 12, 1, true), this.robeMat); + robe.position.y = -0.2; + robe.rotation.x = Math.PI; + robe.castShadow = true; + this.registerFadeMesh(robe); + this.model.add(robe); + + // Rocksaum + const hem = new THREE.Mesh(new THREE.CircleGeometry(0.95, 12), this.darkMat); + hem.rotation.x = -Math.PI / 2; + hem.position.y = -1.05; + this.registerFadeMesh(hem); + this.model.add(hem); + + // Oberkörper + const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), this.robeMat); + body.position.y = 0.6; + body.scale.set(1, 1.2, 1); + body.castShadow = true; + this.registerFadeMesh(body); + this.model.add(body); + + // Kapuze + const hood = new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.8, 12, 1, true), this.darkMat); + hood.position.set(0, 1.1, 0.1); + hood.rotation.x = 0.15; + this.registerFadeMesh(hood); + this.model.add(hood); + + // Kapuzen-Öffnung (+Z, dunkel) + const hoodFace = new THREE.Mesh(new THREE.CircleGeometry(0.36, 12), this.darkMat); + hoodFace.position.set(0, 1.02, 0.42); + hoodFace.rotation.y = Math.PI / 2 - 0.1; + hoodFace.rotation.x = -0.35; + this.registerFadeMesh(hoodFace); + this.model.add(hoodFace); + + // Leuchtende Augen in der Kapuze (+Z) + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), this.eyeMat); + eye.position.set(side * 0.13, 1.05, 0.44); + this.model.add(eye); + } + + // Glühender Amulett-Kern + const amulet = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), this.glowMat); + amulet.position.set(0, 0.62, 0.52); + this.model.add(amulet); + + // Arm mit Stab + const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 0.6, 8), this.darkMat); + arm.position.set(0.55, 0.7, 0.15); + arm.rotation.z = -0.6; + this.registerFadeMesh(arm); + this.model.add(arm); + + const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.0, 8), this.darkMat); + staff.position.set(0.85, 1.3, 0.25); + staff.rotation.z = 0.35; + staff.rotation.x = 0.2; + this.registerFadeMesh(staff); + this.model.add(staff); + + const orbMat = new THREE.MeshBasicMaterial({ + color: 0xbb66ff, + transparent: true, + opacity: 0.85, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const orb = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), orbMat); + orb.position.set(1.2, 1.95, 0.35); + this.model.add(orb); + this.orbMat = orbMat; + this.orb = orb; + + // Boden-Schatten + this.shadowMat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0.3, + }); + this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.9, 20), this.shadowMat); + this.shadow.rotation.x = -Math.PI / 2; + this.shadow.renderOrder = 998; + this.body.add(this.shadow); + + // Teleport-Telegraf: Ring + Scheibe am Zielpunkt + this.teleportRingMat = new THREE.MeshBasicMaterial({ + color: 0xbb66ff, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + this.teleportDiscMat = new THREE.MeshBasicMaterial({ + color: 0x8833cc, + transparent: true, + opacity: 0, + depthWrite: false, + }); + const ringMesh = new THREE.Mesh( + new THREE.RingGeometry(1.6, 2.0, 32), + this.teleportRingMat + ); + ringMesh.rotation.x = -Math.PI / 2; + ringMesh.position.y = 0.05; + const disc = new THREE.Mesh(new THREE.CircleGeometry(1.8, 32), this.teleportDiscMat); + disc.rotation.x = -Math.PI / 2; + disc.position.y = 0.03; + this.teleportRingMesh = ringMesh; + this.teleportRingDisc = disc; + this.teleportRing = new THREE.Group(); + this.teleportRing.add(ringMesh, disc); + this.teleportRing.visible = false; + } + + private orb!: THREE.Mesh; + private orbMat!: THREE.MeshBasicMaterial; + + playAnim() { + // Prozedurale Optik + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); + } + + update(dt: number, game: Game) { + this.game = game; + this.waveTime += dt; + + // Schweben + this.body.position.y = FLOAT_HEIGHT + Math.sin(this.waveTime * 2.2) * BOB_AMPLITUDE; + this.shadow.position.y = 0.06 - this.body.position.y; + const sway = Math.sin(this.waveTime * 1.5) * 0.06; + this.model.rotation.z = sway; + this.model.rotation.x = Math.sin(this.waveTime * 2.2) * 0.04; + + // Hit-Feedback + if (this.hitFlash > 0) { + this.hitFlash -= dt; + this.robeMat.color.set(0xffffff); + this.darkMat.color.set(0xffffff); + this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05)); + } else { + this.robeMat.color.set(this.enraged ? 0x8a2233 : 0x6a3a9a); + this.darkMat.color.set(this.enraged ? 0x551520 : 0x2a1445); + this.model.scale.setScalar(MODEL_SCALE); + } + + // Orb pulsiert + const orbPulse = 1 + Math.abs(Math.sin(this.waveTime * 5)) * 0.25; + this.orb.scale.setScalar(orbPulse); + this.orbMat.opacity = 0.6 + Math.abs(Math.sin(this.waveTime * 5)) * 0.3; + + // Enrage unter 40% HP + if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) { + this.enraged = true; + this.eyeMat.color.set(0xff4422); + this.glowMat.color.set(0xff6644); + playSound('damage', 0.9); + game.triggerCameraShake(0.35, 0.5); + } + + // Zum Spieler driften (langsam) + const playerPos = game.player.body.position; + const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position); + toPlayer.y = 0; + const dist = toPlayer.length(); + if (dist > 0.1) { + const dir = toPlayer.normalize(); + this.body.lookAt( + this.body.position.x + dir.x, + this.body.position.y, + this.body.position.z + dir.z + ); + let moveDir = dir.multiplyScalar(DRIFT_SPEED); + if (this.knockback > 0) { + moveDir = dir.multiplyScalar(-DRIFT_SPEED); + this.knockback -= dt; + } + this.body.position.x += moveDir.x * dt; + this.body.position.z += moveDir.z * dt; + game.clampToArena(this.body.position); + } + + // Teleport + if (this.teleportState === 'idle') { + this.teleportTimer -= dt; + if (this.teleportTimer <= 0) { + this.teleportTimer = this.enraged ? TELEPORT_COOLDOWN_2 : TELEPORT_COOLDOWN; + this.startTeleport(game); + } + } else { + this.updateTeleportCharge(dt, game); + } + + // Salven + this.volleyTimer -= dt; + if (this.volleyTimer <= 0) { + this.volleyTimer = this.enraged ? VOLLEY_COOLDOWN_2 : VOLLEY_COOLDOWN; + this.fireVolley(game); + } + + // Beschwörung + this.summonTimer -= dt; + if (this.summonTimer <= 0) { + this.summonTimer = this.enraged ? SUMMON_COOLDOWN_2 : SUMMON_COOLDOWN; + this.startSummon(game); + } + + // Beschwörungs-Telegraphen + for (let i = this.spawnRings.length - 1; i >= 0; i--) { + const r = this.spawnRings[i]; + r.timer -= dt; + if (r.timer <= 0) { + r.spawn(); + game.scene.remove(r.group); + r.group.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry.dispose(); + (obj.material as THREE.Material).dispose(); + } + }); + this.spawnRings.splice(i, 1); + } else { + const t = 1 - r.timer / SUMMON_TELEGRAPH; + r.group.scale.setScalar(0.5 + t * 1.2); + } + } + + // Ankunfts-Blitz-Flächen + for (let i = this.arrivalFlashes.length - 1; i >= 0; i--) { + const f = this.arrivalFlashes[i]; + f.life -= dt; + const t = 1 - Math.max(0, f.life) / 0.3; + f.mesh.scale.setScalar(0.5 + t * 5); + f.mat.opacity = 0.7 * (1 - t); + if (f.life <= 0) { + game.scene.remove(f.mesh); + f.mesh.geometry.dispose(); + f.mat.dispose(); + this.arrivalFlashes.splice(i, 1); + } + } + } + + // --- Teleport --- + + private startTeleport(game: Game) { + this.teleportState = 'charge'; + this.teleportCharge = this.enraged ? TELEPORT_CHARGE_2 : TELEPORT_CHARGE; + const playerPos = game.player.body.position; + // Ziel = aktuelle Spielerposition (leicht zufällig versetzt) + this.teleportTarget.set( + playerPos.x + (Math.random() - 0.5) * 2, + 0, + playerPos.z + (Math.random() - 0.5) * 2 + ); + game.clampToArena(this.teleportTarget); + if (!this.teleportRingInScene) { + game.scene.add(this.teleportRing); + this.teleportRingInScene = true; + } + this.teleportRing.position.set(this.teleportTarget.x, 0, this.teleportTarget.z); + this.teleportRing.visible = true; + playSound('spawn', 0.7); + } + + private updateTeleportCharge(dt: number, game: Game) { + this.teleportCharge -= dt; + const pulse = Math.abs(Math.sin(this.waveTime * TELEPORT_RING_PULSE)); + this.teleportRingMat.opacity = 0.3 + 0.5 * pulse; + this.teleportDiscMat.opacity = 0.1 + 0.08 * pulse; + + if (this.teleportCharge <= 0) { + this.teleportState = 'idle'; + this.teleportRing.visible = false; + this.teleportRingMat.opacity = 0; + this.teleportDiscMat.opacity = 0; + + // Blitz + this.body.position.x = this.teleportTarget.x; + this.body.position.z = this.teleportTarget.z; + playSound('teleport', 0.8); + + // Ankunftsexplosion + Schaden, wenn der Spieler unter dem Zielpunkt steht + const flashMat = new THREE.MeshBasicMaterial({ + color: 0xbb66ff, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + depthWrite: false, + }); + const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat); + flash.rotation.x = -Math.PI / 2; + flash.position.set(this.teleportTarget.x, 0.07, this.teleportTarget.z); + game.scene.add(flash); + this.arrivalFlashes.push({ mesh: flash, mat: flashMat, life: 0.3 }); + + if (game.player.health > 0 && game.player.body.position.y <= 0.8) { + const dx = game.player.body.position.x - this.teleportTarget.x; + const dz = game.player.body.position.z - this.teleportTarget.z; + if (Math.hypot(dx, dz) < TELEPORT_RADIUS) { + game.player.damage(TELEPORT_DAMAGE, this.teleportTarget); + } + } + + // Salve vom neuen Standort + this.fireVolley(game); + } + } + + // --- Projektilsalve --- + + private fireVolley(game: Game) { + const from = new THREE.Vector3(this.body.position.x, FLOAT_HEIGHT + 0.6, this.body.position.z); + const target = game.player.body.position.clone(); + const count = this.enraged ? VOLLEY_COUNT_2 : VOLLEY_COUNT; + const baseDir = new THREE.Vector3().subVectors(target, from); + baseDir.y = 0; + baseDir.normalize(); + const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x); + for (let i = 0; i < count; i++) { + const off = (i / (count - 1) - 0.5) * 2 * VOLLEY_SPREAD; + const dir = new THREE.Vector3() + .addScaledVector(baseDir, 1) + .addScaledVector(perp, off) + .normalize(); + const t = from.clone().add(dir.multiplyScalar(25)); + t.y = 0.5; + game.spawnEnemyProjectile(from, t, BOLT_DAMAGE); + } + playSound('spawn', 0.5); + } + + // --- Beschwörung --- + + private startSummon(game: Game) { + const count = this.enraged ? SUMMON_COUNT_2 : SUMMON_COUNT; + const types = [() => new Ghost(), () => new Slime(), () => new Spider()]; + for (let i = 0; i < count; i++) { + const a = Math.random() * Math.PI * 2; + const r = 5 + Math.random() * 8; + const [x, z] = this.clampToArena(game, + this.body.position.x + Math.cos(a) * r, + this.body.position.z + Math.sin(a) * r + ); + + // Wachsender Boden-Glow als Telegraf + const mat = new THREE.MeshBasicMaterial({ + color: 0xbb66ff, + transparent: true, + opacity: 0.35, + depthWrite: false, + }); + const ring = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), mat); + ring.rotation.x = -Math.PI / 2; + ring.position.set(x, 0.05, z); + ring.scale.setScalar(0.5); + const group = new THREE.Group(); + group.add(ring); + game.scene.add(group); + + const type = types[Math.floor(Math.random() * types.length)]; + this.spawnRings.push({ + group, + timer: SUMMON_TELEGRAPH, + spawn: () => { + game.spawnEnemyInstance(type(), x, z, 0.1); + playSound('spawn', 0.5); + }, + }); + } + playSound('spawn', 0.6); + } + + private clampToArena(game: Game, x: number, z: number): [number, number] { + const v = new THREE.Vector3(x, 0, z); + game.clampToArena(v); + return [v.x, v.z]; + } + + override takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + super.takeDamage(damage, game, withKnockback, sourcePos); + this.hitFlash = HIT_FLASH_TIME; + } + + protected onDeath(_game: Game) { + this.cleanupEffects(); + this.beginFadeDeath(); + } + + override dispose() { + super.dispose(); + this.cleanupEffects(); + } + + private cleanupEffects() { + if (this.game) { + for (const r of this.spawnRings) { + this.game.scene.remove(r.group); + r.group.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry.dispose(); + (obj.material as THREE.Material).dispose(); + } + }); + } + for (const f of this.arrivalFlashes) { + this.game.scene.remove(f.mesh); + f.mesh.geometry.dispose(); + f.mat.dispose(); + } + } + this.spawnRings = []; + this.arrivalFlashes = []; + this.teleportState = 'idle'; + this.teleportRing.visible = false; + if (this.teleportRingInScene && this.game) { + this.game.scene.remove(this.teleportRing); + } + this.teleportRingInScene = false; + this.robeMat.color.set(0x6a3a9a); + this.darkMat.color.set(0x2a1445); + this.eyeMat.color.set(0xdd88ff); + this.glowMat.color.set(0xbb66ff); + this.model.scale.setScalar(MODEL_SCALE); + } +} diff --git a/web/src/Player.ts b/web/src/Player.ts index c1d3dfe..451fcef 100644 --- a/web/src/Player.ts +++ b/web/src/Player.ts @@ -35,6 +35,8 @@ export class Player { pullTime = 0; pullDir = new THREE.Vector3(); stunTime = 0; + rootTime = 0; + rootCooldown = 0; stats: PlayerStats = { maxHealth: 100, regen: 0, @@ -239,6 +241,8 @@ export class Player { if (this.hurtCooldown > 0) this.hurtCooldown -= dt; if (this.slowTimer > 0) this.slowTimer -= dt; if (this.stunTime > 0) this.stunTime -= dt; + if (this.rootTime > 0) this.rootTime -= dt; + if (this.rootCooldown > 0) this.rootCooldown -= dt; // Stun-Sterne animieren this.stunStars.visible = this.stunTime > 0; @@ -360,6 +364,8 @@ export class Player { this.slowTimer = 0; this.pullTime = 0; this.stunTime = 0; + this.rootTime = 0; + this.rootCooldown = 0; this.body.position.set(0, 0.5, 0); this.velocity.set(0, 0, 0); this.jumpVelocity = 0; diff --git a/web/src/Spider.ts b/web/src/Spider.ts index bddfc2b..1b0db3b 100644 --- a/web/src/Spider.ts +++ b/web/src/Spider.ts @@ -16,29 +16,41 @@ export async function initSpiderModel() { } } +// Wiederverwendbarer Spinnen-Modell-Builder (für Spinne UND Spinnenkönigin). +// tint färbt die Textur mit ein (0xffffff = Original). +export function buildSpiderModel(scale: number, tint: number): THREE.Group | null { + if (!spiderGLTF) return null; + const model = cloneSkeleton(spiderGLTF.scene) as THREE.Group; + model.scale.set(scale, scale, scale); + const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png'); + spiderTexture.flipY = false; + spiderTexture.colorSpace = THREE.SRGBColorSpace; + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true; + child.material = new THREE.MeshToonMaterial({ map: spiderTexture, color: tint }); + } + }); + return model; +} + +// Animationen des Spinnen-Modells (leer, wenn nicht geladen) +export function getSpiderAnimations(): THREE.AnimationClip[] { + return spiderGLTF ? spiderGLTF.animations : []; +} + export class Spider extends Enemy { constructor() { super(); this.stopWalkSound = playLoopingSound('spiderwalking', 0.3); - if (spiderGLTF) { - const model = cloneSkeleton(spiderGLTF.scene); - // Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel) - model.scale.set(0.5, 0.5, 0.5); - const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png'); - spiderTexture.flipY = false; - spiderTexture.colorSpace = THREE.SRGBColorSpace; - model.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - child.material = new THREE.MeshToonMaterial({ map: spiderTexture }); - } - }); + const model = buildSpiderModel(0.5, 0xffffff); + if (model) { this.body.add(model); - if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) { + if (getSpiderAnimations().length > 0 && ANIMATIONS_ENABLED) { this.mixer = new THREE.AnimationMixer(model); - for (const clip of spiderGLTF.animations) { + for (const clip of getSpiderAnimations()) { const action = this.mixer.clipAction(clip); this.clips.set(clip.name, action); } diff --git a/web/src/SpiderQueen.ts b/web/src/SpiderQueen.ts new file mode 100644 index 0000000..035968b --- /dev/null +++ b/web/src/SpiderQueen.ts @@ -0,0 +1,408 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { Spider, buildSpiderModel, getSpiderAnimations } from './Spider'; +import { playSound } from './SoundManager'; +import { WebPatch } from './WebPatch'; + +const MAX_HEALTH = 8500; +const WEB_COOLDOWN = 4.5; +const SCREAM_COOLDOWN = 6; +const SPIT_COOLDOWN = 5; +const WEB_RADIUS = 2.2; +const WEB_DURATION = 3; +const WEB_DIRECT_ROOT = 0.5; +const SPIDERLING_COUNT = 3; +const SPIDERLING_COUNT_2 = 5; +const SPIDERLING_MAX_ALIVE = 8; +const SPIT_COUNT = 3; +const SPIT_COUNT_2 = 5; +const SPIT_DAMAGE = 15; +const ENRAGE_RATIO = 0.4; +const HIT_FLASH_TIME = 0.14; +const WEB_BLOB_SPEED = 7; +const WEB_BLOB_PROXIMITY = 1.3; +const WEB_BLOB_DAMAGE = 10; + +export class SpiderQueen extends Enemy { + spawnAtCenter = true; + private game: Game | null = null; + private waveTime = 0; + private webTimer = 2; + private screamTimer = 4; + private spitTimer = 5; + private enraged = false; + private effectsReady = false; + private hitFlash = 0; + + private model!: THREE.Group; + private tintMats: THREE.MeshToonMaterial[] = []; + + private webBlobs: WebBlob[] = []; + private webs: WebPatch[] = []; + + constructor() { + super(); + this.health = MAX_HEALTH; + this.maxHealth = MAX_HEALTH; + this.isBoss = true; + this.displayName = 'Spinnenkönigin'; + this.immovable = true; + this.speed = 0; + this.xp = 240; + this.contactDps = 0; + this.contactRadius = 0; + this.guaranteedDrop = true; + + this.model = new THREE.Group(); + this.fadeOutModel = this.model; + this.body.add(this.model); + + // Königinnen-Modell: bestehende Spinne, größer + violett eingefärbt + const spiderModel = buildSpiderModel(3.2, 0x9a5ac8); + if (spiderModel) { + this.model.add(spiderModel); + spiderModel.traverse((child) => { + if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshToonMaterial) { + this.tintMats.push(child.material); + } + }); + + if (getSpiderAnimations().length > 0) { + this.mixer = new THREE.AnimationMixer(spiderModel); + for (const clip of getSpiderAnimations()) { + this.clips.set(clip.name, this.mixer.clipAction(clip)); + } + this.playAnim('stand'); + } + } else { + // Fallback (Modell nicht geladen): einfache große Spinne + const skinMat = new THREE.MeshToonMaterial({ color: 0x7a3a8a }); + const darkMat = new THREE.MeshToonMaterial({ color: 0x4a2260 }); + this.tintMats.push(skinMat, darkMat); + const abdomen = new THREE.Mesh(new THREE.SphereGeometry(1.6, 24, 18), skinMat); + abdomen.scale.set(1, 1.05, 1.35); + abdomen.position.set(0, 2.0, -1.7); + abdomen.castShadow = true; + this.registerFadeMesh(abdomen); + this.model.add(abdomen); + const cephalo = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 16), skinMat); + cephalo.scale.set(1, 0.85, 1.1); + cephalo.position.set(0, 1.9, 0.6); + cephalo.castShadow = true; + this.registerFadeMesh(cephalo); + this.model.add(cephalo); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.6, 16, 12), darkMat); + head.scale.set(1, 0.8, 0.9); + head.position.set(0, 2.0, 1.5); + this.registerFadeMesh(head); + this.model.add(head); + const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff3333 }); + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), eyeMat); + eye.position.set(side * 0.32, 2.15, 1.85); + this.model.add(eye); + } + const legMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a }); + this.tintMats.push(legMat); + for (let i = 0; i < 8; i++) { + const side = i < 4 ? -1 : 1; + const back = i % 2 === 0; + const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 1.1, 6), legMat); + leg.position.set(side * (back ? 1.0 : 0.9), 0.6, back ? -0.9 : 0.4); + leg.rotation.z = side * 1.1; + this.registerFadeMesh(leg); + this.model.add(leg); + } + } + } + + playAnim(name: string, loop = true, speed = 1) { + if (!this.clips.has(name) || this.currentAnim === name) return; + if (this.currentAnim) { + const prev = this.clips.get(this.currentAnim); + if (prev) prev.stop(); + } + this.currentAnim = name; + const action = this.clips.get(name)!; + action.reset(); + action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1); + action.clampWhenFinished = !loop; + action.setEffectiveTimeScale(speed); + action.play(); + } + + updateAnimation(dt: number) { + if (this.mixer) this.mixer.update(dt); + this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); + } + + update(dt: number, game: Game) { + this.game = game; + this.waveTime += dt; + + // Hit-Feedback: Materialien kurz weiß aufblitzen + if (this.hitFlash > 0) { + this.hitFlash -= dt; + for (const mat of this.tintMats) mat.color.set(0xffffff); + this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05); + } else { + for (const mat of this.tintMats) mat.color.set(this.enraged ? 0xff6644 : 0x9a5ac8); + this.model.scale.setScalar(1); + } + + // Zum Spieler drehen + const playerPos = game.player.body.position; + const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position); + toPlayer.y = 0; + if (toPlayer.length() > 0.1) { + this.body.lookAt( + this.body.position.x + toPlayer.x, + this.body.position.y, + this.body.position.z + toPlayer.z + ); + } + + // Enrage unter 40% HP + if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) { + this.enraged = true; + playSound('damage', 0.8); + game.triggerCameraShake(0.35, 0.4); + } + + // Angriffe + this.webTimer -= dt; + if (this.webTimer <= 0) { + this.webTimer = this.enraged ? WEB_COOLDOWN * 0.65 : WEB_COOLDOWN; + this.spitWeb(game); + } + + this.screamTimer -= dt; + if (this.screamTimer <= 0) { + this.screamTimer = this.enraged ? SCREAM_COOLDOWN * 0.6 : SCREAM_COOLDOWN; + this.scream(game); + } + + this.spitTimer -= dt; + if (this.spitTimer <= 0) { + this.spitTimer = this.enraged ? SPIT_COOLDOWN * 0.7 : SPIT_COOLDOWN; + this.spitAcid(game); + } + + // Netz-Bälle aktualisieren + for (let i = this.webBlobs.length - 1; i >= 0; i--) { + const blob = this.webBlobs[i]; + if (!blob.update(dt, game)) { + this.webBlobs.splice(i, 1); + const patch = new WebPatch( + blob.body.position, + this.enraged ? WEB_RADIUS + 0.4 : WEB_RADIUS, + this.enraged ? WEB_DURATION * 1.5 : WEB_DURATION + ); + game.scene.add(patch.body); + this.webs.push(patch); + blob.dispose(); + playSound('explosion', 0.4); + } + } + + // Netze aktualisieren + for (let i = this.webs.length - 1; i >= 0; i--) { + const web = this.webs[i]; + if (!web.update(dt, game)) { + this.webs.splice(i, 1); + web.dispose(); + } + } + } + + private spitWeb(game: Game) { + const from = new THREE.Vector3(this.body.position.x, 2.2, this.body.position.z); + const target = game.player.body.position.clone(); + target.y = 0; + + const blob = new WebBlob(from, target); + game.scene.add(blob.body); + this.webBlobs.push(blob); + playSound('spawn', 0.5); + } + + private scream(game: Game) { + // Nicht endlos viele Spinnlinge anhäufen + let alive = 0; + for (const e of game.enemies) { + if (e instanceof Spiderling && !e.dead) alive++; + } + const count = Math.min( + this.enraged ? SPIDERLING_COUNT_2 : SPIDERLING_COUNT, + SPIDERLING_MAX_ALIVE - alive + ); + if (count <= 0) return; + + for (let i = 0; i < count; i++) { + const mini = new Spiderling(); + const a = Math.random() * Math.PI * 2; + const r = 4 + Math.random() * 4; + game.spawnEnemyInstance( + mini, + this.body.position.x + Math.cos(a) * r, + this.body.position.z + Math.sin(a) * r, + 0.1 + ); + } + playSound('spawn', 0.7); + } + + private spitAcid(game: Game) { + const from = new THREE.Vector3(this.body.position.x, 2.4, this.body.position.z); + const target = game.player.body.position.clone(); + const count = this.enraged ? SPIT_COUNT_2 : SPIT_COUNT; + const spread = 0.35; + const baseDir = new THREE.Vector3().subVectors(target, from); + baseDir.y = 0; + baseDir.normalize(); + const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x); + for (let i = 0; i < count; i++) { + const off = (i / (count - 1) - 0.5) * 2 * spread; + const dir = new THREE.Vector3() + .addScaledVector(baseDir, 1) + .addScaledVector(perp, off) + .normalize(); + const t = from.clone().add(dir.multiplyScalar(20)); + t.y = 0; + game.spawnEnemyProjectile(from, t, SPIT_DAMAGE); + } + playSound('spawn', 0.6); + } + + override takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + super.takeDamage(damage, game, withKnockback, sourcePos); + this.hitFlash = HIT_FLASH_TIME; + } + + protected onDeath(_game: Game) { + this.cleanupEffects(); + this.beginFadeDeath(); + } + + override dispose() { + super.dispose(); + this.cleanupEffects(); + } + + private cleanupEffects() { + for (const blob of this.webBlobs) blob.dispose(); + this.webBlobs = []; + for (const web of this.webs) web.dispose(); + this.webs = []; + for (const mat of this.tintMats) mat.color.set(0x9a5ac8); + this.model.scale.setScalar(1); + } +} + +// Spinnling: wie die normale Spinne, stirbt aber sichtbar (schrumpft + sinkt), +// damit keine stehenden Leichen in der Arena zurückbleiben. +export class Spiderling extends Spider { + private fadeDying = false; + private fadeDieTime = 0; + + constructor() { + super(); + this.health = 25; + this.speed = 4.2; + this.xp = 5; + this.contactDps = 12; + this.contactRadius = 1.5; + // Kein grace: Spider dekrementiert grace nie, sonst wären die Spinnlinge unverwundbar. + this.body.scale.setScalar(0.5); + } + + protected onDeath(_game: Game) { + this.fadeDying = true; + } + + updateAnimation(dt: number) { + super.updateAnimation(dt); + if (this.fadeDying) { + this.fadeDieTime += dt; + const t = Math.min(this.fadeDieTime / 0.5, 1); + this.body.scale.setScalar(0.5 * (1 - t)); + this.body.position.y -= dt * 1.2; + } + } +} + +// Netz-Ball: fliegt zum Spieler und hinterlässt beim Auftreffen ein WebPatch +class WebBlob { + body: THREE.Group; + target: THREE.Vector3; + private vel: THREE.Vector3; + private life = 6; + dead = false; + + constructor(from: THREE.Vector3, target: THREE.Vector3) { + this.target = target.clone(); + this.body = new THREE.Group(); + this.body.position.copy(from); + + this.vel = new THREE.Vector3().subVectors(target, from); + this.vel.y = 0; + const dist = this.vel.length(); + if (dist > 0.01) { + this.vel.normalize().multiplyScalar(WEB_BLOB_SPEED); + } + + const glowMat = new THREE.MeshBasicMaterial({ + color: 0xddddff, + transparent: true, + opacity: 0.5, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 12), glowMat); + this.body.add(glow); + + const coreMat = new THREE.MeshToonMaterial({ color: 0xf0f0ff }); + const core = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 10), coreMat); + this.body.add(core); + } + + update(dt: number, game: Game): boolean { + this.life -= dt; + this.body.position.x += this.vel.x * dt; + this.body.position.z += this.vel.z * dt; + this.body.position.y = 1.4 + Math.sin(this.life * 4) * 0.1; + + if (game.player.health > 0) { + const dist = this.body.position.distanceTo(game.player.body.position); + if (dist < WEB_BLOB_PROXIMITY && game.player.rootCooldown <= 0) { + game.player.rootTime = WEB_DIRECT_ROOT; + game.player.rootCooldown = 1.0; + game.player.damage(WEB_BLOB_DAMAGE, this.body.position); + } + } + + const dx = this.target.x - this.body.position.x; + const dz = this.target.z - this.body.position.z; + if (this.life <= 0 || dx * dx + dz * dz < 0.25) { + this.dead = true; + return false; + } + return true; + } + + dispose() { + this.body.removeFromParent(); + for (const child of [...this.body.children]) { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + } +} diff --git a/web/src/WebPatch.ts b/web/src/WebPatch.ts new file mode 100644 index 0000000..078f3d4 --- /dev/null +++ b/web/src/WebPatch.ts @@ -0,0 +1,105 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; + +const ROOT_DURATION = 0.35; +const ROOT_TICK = 1.0; +const ROOT_COOLDOWN = 1.0; +const FADE_TIME = 0.6; + +export class WebPatch { + body: THREE.Group; + private radius: number; + private life: number; + private rootTick = 0; + private meshes: THREE.Mesh[] = []; + + constructor(position: THREE.Vector3, radius = 3, duration = 6) { + this.radius = radius; + this.life = duration; + this.body = new THREE.Group(); + this.body.position.set(position.x, 0.08, position.z); + + const mat = new THREE.MeshToonMaterial({ + color: 0xdfe6ff, + transparent: true, + opacity: 0.55, + depthWrite: false, + }); + + const disc = new THREE.Mesh(new THREE.CircleGeometry(radius, 24), mat); + disc.rotation.x = -Math.PI / 2; + disc.position.y = 0.01; + this.body.add(disc); + this.meshes.push(disc); + + // Radiale Fäden + const threadMat = new THREE.MeshToonMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.7, + depthWrite: false, + }); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2; + const thread = new THREE.Mesh( + new THREE.BoxGeometry(radius * 2, 0.03, 0.05), + threadMat + ); + thread.rotation.y = -a; + thread.position.y = 0.02; + this.body.add(thread); + this.meshes.push(thread); + } + + // Konzentrische Ringe + for (let r = 1; r <= 3; r++) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(r * radius / 4 - 0.04, r * radius / 4 + 0.04, 24), + threadMat + ); + ring.rotation.x = -Math.PI / 2; + ring.position.y = 0.025; + this.body.add(ring); + this.meshes.push(ring); + } + } + + update(dt: number, game: Game): boolean { + this.life -= dt; + if (this.life <= 0) return false; + + const fade = Math.min(1, this.life / FADE_TIME); + for (const mesh of this.meshes) { + (mesh.material as THREE.MeshToonMaterial).opacity = + (mesh === this.meshes[0] ? 0.55 : 0.7) * fade; + } + + if (game.player.health > 0) { + const dist = Math.hypot( + game.player.body.position.x - this.body.position.x, + game.player.body.position.z - this.body.position.z + ); + if (dist < this.radius) { + // Gepulste Fessel: kurzer Root, dann Lücke zum Rauslaufen. + // rootCooldown verhindert Endlos-Fessel durch überlappende Netze. + this.rootTick -= dt; + if (this.rootTick <= 0 && game.player.rootCooldown <= 0) { + game.player.rootTime = ROOT_DURATION; + game.player.rootCooldown = ROOT_COOLDOWN; + this.rootTick = ROOT_TICK; + } + } else { + this.rootTick = 0; + } + } + return true; + } + + dispose() { + this.body.removeFromParent(); + for (const mesh of this.meshes) { + mesh.geometry.dispose(); + (mesh.material as THREE.Material).dispose(); + } + } +}