Add Spider Queen, Frost Giant, Necromancer and Dragon bosses
This commit is contained in:
@@ -34,7 +34,7 @@ npm run dev # Dev-Server mit HMR
|
|||||||
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
|
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
|
||||||
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
|
| `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()` |
|
| `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 |
|
| `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf |
|
||||||
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` |
|
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | 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 |
|
| `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").
|
- **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.
|
- **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).
|
- **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).
|
- **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.
|
- **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.
|
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
|
||||||
|
|||||||
@@ -400,6 +400,10 @@
|
|||||||
<div id="changelog-panel">
|
<div id="changelog-panel">
|
||||||
<h2>Changelog</h2>
|
<h2>Changelog</h2>
|
||||||
<div id="changelog-list">
|
<div id="changelog-list">
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Frostriese (Welle 50): Eispanzer-Rüstung, Eisberst-Ringe und Eiszapfen-Regen</span></div>
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Spinnenkönigin (Welle 40): Netze fesseln den Spieler, Spinngeschwister und Säuresalven</span></div>
|
||||||
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>Minotaurus-Boss mit Frontpanzerung, Dash-Stun und überspringbarem Axthieb</span></div>
|
<div class="changelog-entry"><span class="changelog-date">19.08.</span><span>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>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>Krake-Boss mit Tinte, Zug-Markierungen und Sweep-Tentakeln plus Boss-Modus</span></div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-2
@@ -11,6 +11,10 @@ import { Brute } from './Brute';
|
|||||||
import { Golem } from './Golem';
|
import { Golem } from './Golem';
|
||||||
import { Krake } from './Krake';
|
import { Krake } from './Krake';
|
||||||
import { Minotaurus } from './Minotaurus';
|
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 { Crab } from './Crab';
|
||||||
import { Turret } from './Turret';
|
import { Turret } from './Turret';
|
||||||
import { EnemyProjectile } from './EnemyProjectile';
|
import { EnemyProjectile } from './EnemyProjectile';
|
||||||
@@ -83,6 +87,10 @@ export class Game {
|
|||||||
[10, { name: 'Golem', spawn: () => new Golem() }],
|
[10, { name: 'Golem', spawn: () => new Golem() }],
|
||||||
[20, { name: 'Krake', spawn: () => new Krake() }],
|
[20, { name: 'Krake', spawn: () => new Krake() }],
|
||||||
[30, { name: 'Minotaurus', spawn: () => new Minotaurus() }],
|
[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 bossFightActive = false;
|
||||||
private shakeTime = 0;
|
private shakeTime = 0;
|
||||||
@@ -309,7 +317,8 @@ export class Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.code === 'Space' && this.state === 'playing'
|
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();
|
this.player.jump();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -1053,6 +1062,9 @@ export class Game {
|
|||||||
if (p.stunTime > 0) {
|
if (p.stunTime > 0) {
|
||||||
// Gestunnt (Stampf-Schockwelle): keine Eingaben
|
// Gestunnt (Stampf-Schockwelle): keine Eingaben
|
||||||
p.velocity.set(0, 0, 0);
|
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) {
|
} else if (p.pullTime > 0) {
|
||||||
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
|
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
|
||||||
p.pullTime -= dt;
|
p.pullTime -= dt;
|
||||||
@@ -1083,7 +1095,7 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Jump / gravity
|
// 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.jumpVelocity -= 20 * dt;
|
||||||
p.body.position.y += p.jumpVelocity * dt;
|
p.body.position.y += p.jumpVelocity * dt;
|
||||||
if (p.body.position.y <= 0.5) {
|
if (p.body.position.y <= 0.5) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ export class Player {
|
|||||||
pullTime = 0;
|
pullTime = 0;
|
||||||
pullDir = new THREE.Vector3();
|
pullDir = new THREE.Vector3();
|
||||||
stunTime = 0;
|
stunTime = 0;
|
||||||
|
rootTime = 0;
|
||||||
|
rootCooldown = 0;
|
||||||
stats: PlayerStats = {
|
stats: PlayerStats = {
|
||||||
maxHealth: 100,
|
maxHealth: 100,
|
||||||
regen: 0,
|
regen: 0,
|
||||||
@@ -239,6 +241,8 @@ export class Player {
|
|||||||
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
|
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
|
||||||
if (this.slowTimer > 0) this.slowTimer -= dt;
|
if (this.slowTimer > 0) this.slowTimer -= dt;
|
||||||
if (this.stunTime > 0) this.stunTime -= 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
|
// Stun-Sterne animieren
|
||||||
this.stunStars.visible = this.stunTime > 0;
|
this.stunStars.visible = this.stunTime > 0;
|
||||||
@@ -360,6 +364,8 @@ export class Player {
|
|||||||
this.slowTimer = 0;
|
this.slowTimer = 0;
|
||||||
this.pullTime = 0;
|
this.pullTime = 0;
|
||||||
this.stunTime = 0;
|
this.stunTime = 0;
|
||||||
|
this.rootTime = 0;
|
||||||
|
this.rootCooldown = 0;
|
||||||
this.body.position.set(0, 0.5, 0);
|
this.body.position.set(0, 0.5, 0);
|
||||||
this.velocity.set(0, 0, 0);
|
this.velocity.set(0, 0, 0);
|
||||||
this.jumpVelocity = 0;
|
this.jumpVelocity = 0;
|
||||||
|
|||||||
+24
-12
@@ -16,29 +16,41 @@ export async function initSpiderModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Spider extends Enemy {
|
// Wiederverwendbarer Spinnen-Modell-Builder (für Spinne UND Spinnenkönigin).
|
||||||
constructor() {
|
// tint färbt die Textur mit ein (0xffffff = Original).
|
||||||
super();
|
export function buildSpiderModel(scale: number, tint: number): THREE.Group | null {
|
||||||
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
|
if (!spiderGLTF) return null;
|
||||||
|
const model = cloneSkeleton(spiderGLTF.scene) as THREE.Group;
|
||||||
if (spiderGLTF) {
|
model.scale.set(scale, scale, scale);
|
||||||
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');
|
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
|
||||||
spiderTexture.flipY = false;
|
spiderTexture.flipY = false;
|
||||||
spiderTexture.colorSpace = THREE.SRGBColorSpace;
|
spiderTexture.colorSpace = THREE.SRGBColorSpace;
|
||||||
model.traverse((child) => {
|
model.traverse((child) => {
|
||||||
if (child instanceof THREE.Mesh) {
|
if (child instanceof THREE.Mesh) {
|
||||||
child.castShadow = true;
|
child.castShadow = true;
|
||||||
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
|
child.material = new THREE.MeshToonMaterial({ map: spiderTexture, color: tint });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animationen des Spinnen-Modells (leer, wenn nicht geladen)
|
||||||
|
export function getSpiderAnimations(): THREE.AnimationClip[] {
|
||||||
|
return spiderGLTF ? spiderGLTF.animations : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Spider extends Enemy {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
|
||||||
|
|
||||||
|
const model = buildSpiderModel(0.5, 0xffffff);
|
||||||
|
if (model) {
|
||||||
this.body.add(model);
|
this.body.add(model);
|
||||||
|
|
||||||
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
|
if (getSpiderAnimations().length > 0 && ANIMATIONS_ENABLED) {
|
||||||
this.mixer = new THREE.AnimationMixer(model);
|
this.mixer = new THREE.AnimationMixer(model);
|
||||||
for (const clip of spiderGLTF.animations) {
|
for (const clip of getSpiderAnimations()) {
|
||||||
const action = this.mixer.clipAction(clip);
|
const action = this.mixer.clipAction(clip);
|
||||||
this.clips.set(clip.name, action);
|
this.clips.set(clip.name, action);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { Spider, buildSpiderModel, getSpiderAnimations } from './Spider';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
import { WebPatch } from './WebPatch';
|
||||||
|
|
||||||
|
const MAX_HEALTH = 8500;
|
||||||
|
const WEB_COOLDOWN = 4.5;
|
||||||
|
const SCREAM_COOLDOWN = 6;
|
||||||
|
const SPIT_COOLDOWN = 5;
|
||||||
|
const WEB_RADIUS = 2.2;
|
||||||
|
const WEB_DURATION = 3;
|
||||||
|
const WEB_DIRECT_ROOT = 0.5;
|
||||||
|
const SPIDERLING_COUNT = 3;
|
||||||
|
const SPIDERLING_COUNT_2 = 5;
|
||||||
|
const SPIDERLING_MAX_ALIVE = 8;
|
||||||
|
const SPIT_COUNT = 3;
|
||||||
|
const SPIT_COUNT_2 = 5;
|
||||||
|
const SPIT_DAMAGE = 15;
|
||||||
|
const ENRAGE_RATIO = 0.4;
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
const WEB_BLOB_SPEED = 7;
|
||||||
|
const WEB_BLOB_PROXIMITY = 1.3;
|
||||||
|
const WEB_BLOB_DAMAGE = 10;
|
||||||
|
|
||||||
|
export class SpiderQueen extends Enemy {
|
||||||
|
spawnAtCenter = true;
|
||||||
|
private game: Game | null = null;
|
||||||
|
private waveTime = 0;
|
||||||
|
private webTimer = 2;
|
||||||
|
private screamTimer = 4;
|
||||||
|
private spitTimer = 5;
|
||||||
|
private enraged = false;
|
||||||
|
private effectsReady = false;
|
||||||
|
private hitFlash = 0;
|
||||||
|
|
||||||
|
private model!: THREE.Group;
|
||||||
|
private tintMats: THREE.MeshToonMaterial[] = [];
|
||||||
|
|
||||||
|
private webBlobs: WebBlob[] = [];
|
||||||
|
private webs: WebPatch[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = MAX_HEALTH;
|
||||||
|
this.maxHealth = MAX_HEALTH;
|
||||||
|
this.isBoss = true;
|
||||||
|
this.displayName = 'Spinnenkönigin';
|
||||||
|
this.immovable = true;
|
||||||
|
this.speed = 0;
|
||||||
|
this.xp = 240;
|
||||||
|
this.contactDps = 0;
|
||||||
|
this.contactRadius = 0;
|
||||||
|
this.guaranteedDrop = true;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
// Königinnen-Modell: bestehende Spinne, größer + violett eingefärbt
|
||||||
|
const spiderModel = buildSpiderModel(3.2, 0x9a5ac8);
|
||||||
|
if (spiderModel) {
|
||||||
|
this.model.add(spiderModel);
|
||||||
|
spiderModel.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshToonMaterial) {
|
||||||
|
this.tintMats.push(child.material);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (getSpiderAnimations().length > 0) {
|
||||||
|
this.mixer = new THREE.AnimationMixer(spiderModel);
|
||||||
|
for (const clip of getSpiderAnimations()) {
|
||||||
|
this.clips.set(clip.name, this.mixer.clipAction(clip));
|
||||||
|
}
|
||||||
|
this.playAnim('stand');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback (Modell nicht geladen): einfache große Spinne
|
||||||
|
const skinMat = new THREE.MeshToonMaterial({ color: 0x7a3a8a });
|
||||||
|
const darkMat = new THREE.MeshToonMaterial({ color: 0x4a2260 });
|
||||||
|
this.tintMats.push(skinMat, darkMat);
|
||||||
|
const abdomen = new THREE.Mesh(new THREE.SphereGeometry(1.6, 24, 18), skinMat);
|
||||||
|
abdomen.scale.set(1, 1.05, 1.35);
|
||||||
|
abdomen.position.set(0, 2.0, -1.7);
|
||||||
|
abdomen.castShadow = true;
|
||||||
|
this.registerFadeMesh(abdomen);
|
||||||
|
this.model.add(abdomen);
|
||||||
|
const cephalo = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 16), skinMat);
|
||||||
|
cephalo.scale.set(1, 0.85, 1.1);
|
||||||
|
cephalo.position.set(0, 1.9, 0.6);
|
||||||
|
cephalo.castShadow = true;
|
||||||
|
this.registerFadeMesh(cephalo);
|
||||||
|
this.model.add(cephalo);
|
||||||
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.6, 16, 12), darkMat);
|
||||||
|
head.scale.set(1, 0.8, 0.9);
|
||||||
|
head.position.set(0, 2.0, 1.5);
|
||||||
|
this.registerFadeMesh(head);
|
||||||
|
this.model.add(head);
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff3333 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), eyeMat);
|
||||||
|
eye.position.set(side * 0.32, 2.15, 1.85);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
const legMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a });
|
||||||
|
this.tintMats.push(legMat);
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const side = i < 4 ? -1 : 1;
|
||||||
|
const back = i % 2 === 0;
|
||||||
|
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 1.1, 6), legMat);
|
||||||
|
leg.position.set(side * (back ? 1.0 : 0.9), 0.6, back ? -0.9 : 0.4);
|
||||||
|
leg.rotation.z = side * 1.1;
|
||||||
|
this.registerFadeMesh(leg);
|
||||||
|
this.model.add(leg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim(name: string, loop = true, speed = 1) {
|
||||||
|
if (!this.clips.has(name) || this.currentAnim === name) return;
|
||||||
|
if (this.currentAnim) {
|
||||||
|
const prev = this.clips.get(this.currentAnim);
|
||||||
|
if (prev) prev.stop();
|
||||||
|
}
|
||||||
|
this.currentAnim = name;
|
||||||
|
const action = this.clips.get(name)!;
|
||||||
|
action.reset();
|
||||||
|
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
|
||||||
|
action.clampWhenFinished = !loop;
|
||||||
|
action.setEffectiveTimeScale(speed);
|
||||||
|
action.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
if (this.mixer) this.mixer.update(dt);
|
||||||
|
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.game = game;
|
||||||
|
this.waveTime += dt;
|
||||||
|
|
||||||
|
// Hit-Feedback: Materialien kurz weiß aufblitzen
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
for (const mat of this.tintMats) mat.color.set(0xffffff);
|
||||||
|
this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05);
|
||||||
|
} else {
|
||||||
|
for (const mat of this.tintMats) mat.color.set(this.enraged ? 0xff6644 : 0x9a5ac8);
|
||||||
|
this.model.scale.setScalar(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zum Spieler drehen
|
||||||
|
const playerPos = game.player.body.position;
|
||||||
|
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
|
||||||
|
toPlayer.y = 0;
|
||||||
|
if (toPlayer.length() > 0.1) {
|
||||||
|
this.body.lookAt(
|
||||||
|
this.body.position.x + toPlayer.x,
|
||||||
|
this.body.position.y,
|
||||||
|
this.body.position.z + toPlayer.z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrage unter 40% HP
|
||||||
|
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
|
||||||
|
this.enraged = true;
|
||||||
|
playSound('damage', 0.8);
|
||||||
|
game.triggerCameraShake(0.35, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angriffe
|
||||||
|
this.webTimer -= dt;
|
||||||
|
if (this.webTimer <= 0) {
|
||||||
|
this.webTimer = this.enraged ? WEB_COOLDOWN * 0.65 : WEB_COOLDOWN;
|
||||||
|
this.spitWeb(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.screamTimer -= dt;
|
||||||
|
if (this.screamTimer <= 0) {
|
||||||
|
this.screamTimer = this.enraged ? SCREAM_COOLDOWN * 0.6 : SCREAM_COOLDOWN;
|
||||||
|
this.scream(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.spitTimer -= dt;
|
||||||
|
if (this.spitTimer <= 0) {
|
||||||
|
this.spitTimer = this.enraged ? SPIT_COOLDOWN * 0.7 : SPIT_COOLDOWN;
|
||||||
|
this.spitAcid(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netz-Bälle aktualisieren
|
||||||
|
for (let i = this.webBlobs.length - 1; i >= 0; i--) {
|
||||||
|
const blob = this.webBlobs[i];
|
||||||
|
if (!blob.update(dt, game)) {
|
||||||
|
this.webBlobs.splice(i, 1);
|
||||||
|
const patch = new WebPatch(
|
||||||
|
blob.body.position,
|
||||||
|
this.enraged ? WEB_RADIUS + 0.4 : WEB_RADIUS,
|
||||||
|
this.enraged ? WEB_DURATION * 1.5 : WEB_DURATION
|
||||||
|
);
|
||||||
|
game.scene.add(patch.body);
|
||||||
|
this.webs.push(patch);
|
||||||
|
blob.dispose();
|
||||||
|
playSound('explosion', 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netze aktualisieren
|
||||||
|
for (let i = this.webs.length - 1; i >= 0; i--) {
|
||||||
|
const web = this.webs[i];
|
||||||
|
if (!web.update(dt, game)) {
|
||||||
|
this.webs.splice(i, 1);
|
||||||
|
web.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spitWeb(game: Game) {
|
||||||
|
const from = new THREE.Vector3(this.body.position.x, 2.2, this.body.position.z);
|
||||||
|
const target = game.player.body.position.clone();
|
||||||
|
target.y = 0;
|
||||||
|
|
||||||
|
const blob = new WebBlob(from, target);
|
||||||
|
game.scene.add(blob.body);
|
||||||
|
this.webBlobs.push(blob);
|
||||||
|
playSound('spawn', 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scream(game: Game) {
|
||||||
|
// Nicht endlos viele Spinnlinge anhäufen
|
||||||
|
let alive = 0;
|
||||||
|
for (const e of game.enemies) {
|
||||||
|
if (e instanceof Spiderling && !e.dead) alive++;
|
||||||
|
}
|
||||||
|
const count = Math.min(
|
||||||
|
this.enraged ? SPIDERLING_COUNT_2 : SPIDERLING_COUNT,
|
||||||
|
SPIDERLING_MAX_ALIVE - alive
|
||||||
|
);
|
||||||
|
if (count <= 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const mini = new Spiderling();
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const r = 4 + Math.random() * 4;
|
||||||
|
game.spawnEnemyInstance(
|
||||||
|
mini,
|
||||||
|
this.body.position.x + Math.cos(a) * r,
|
||||||
|
this.body.position.z + Math.sin(a) * r,
|
||||||
|
0.1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
playSound('spawn', 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spitAcid(game: Game) {
|
||||||
|
const from = new THREE.Vector3(this.body.position.x, 2.4, this.body.position.z);
|
||||||
|
const target = game.player.body.position.clone();
|
||||||
|
const count = this.enraged ? SPIT_COUNT_2 : SPIT_COUNT;
|
||||||
|
const spread = 0.35;
|
||||||
|
const baseDir = new THREE.Vector3().subVectors(target, from);
|
||||||
|
baseDir.y = 0;
|
||||||
|
baseDir.normalize();
|
||||||
|
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const off = (i / (count - 1) - 0.5) * 2 * spread;
|
||||||
|
const dir = new THREE.Vector3()
|
||||||
|
.addScaledVector(baseDir, 1)
|
||||||
|
.addScaledVector(perp, off)
|
||||||
|
.normalize();
|
||||||
|
const t = from.clone().add(dir.multiplyScalar(20));
|
||||||
|
t.y = 0;
|
||||||
|
game.spawnEnemyProjectile(from, t, SPIT_DAMAGE);
|
||||||
|
}
|
||||||
|
playSound('spawn', 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
override takeDamage(
|
||||||
|
damage: number,
|
||||||
|
game: Game,
|
||||||
|
withKnockback = true,
|
||||||
|
sourcePos?: THREE.Vector3
|
||||||
|
) {
|
||||||
|
super.takeDamage(damage, game, withKnockback, sourcePos);
|
||||||
|
this.hitFlash = HIT_FLASH_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.cleanupEffects();
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose() {
|
||||||
|
super.dispose();
|
||||||
|
this.cleanupEffects();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupEffects() {
|
||||||
|
for (const blob of this.webBlobs) blob.dispose();
|
||||||
|
this.webBlobs = [];
|
||||||
|
for (const web of this.webs) web.dispose();
|
||||||
|
this.webs = [];
|
||||||
|
for (const mat of this.tintMats) mat.color.set(0x9a5ac8);
|
||||||
|
this.model.scale.setScalar(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spinnling: wie die normale Spinne, stirbt aber sichtbar (schrumpft + sinkt),
|
||||||
|
// damit keine stehenden Leichen in der Arena zurückbleiben.
|
||||||
|
export class Spiderling extends Spider {
|
||||||
|
private fadeDying = false;
|
||||||
|
private fadeDieTime = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 25;
|
||||||
|
this.speed = 4.2;
|
||||||
|
this.xp = 5;
|
||||||
|
this.contactDps = 12;
|
||||||
|
this.contactRadius = 1.5;
|
||||||
|
// Kein grace: Spider dekrementiert grace nie, sonst wären die Spinnlinge unverwundbar.
|
||||||
|
this.body.scale.setScalar(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.fadeDying = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
super.updateAnimation(dt);
|
||||||
|
if (this.fadeDying) {
|
||||||
|
this.fadeDieTime += dt;
|
||||||
|
const t = Math.min(this.fadeDieTime / 0.5, 1);
|
||||||
|
this.body.scale.setScalar(0.5 * (1 - t));
|
||||||
|
this.body.position.y -= dt * 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netz-Ball: fliegt zum Spieler und hinterlässt beim Auftreffen ein WebPatch
|
||||||
|
class WebBlob {
|
||||||
|
body: THREE.Group;
|
||||||
|
target: THREE.Vector3;
|
||||||
|
private vel: THREE.Vector3;
|
||||||
|
private life = 6;
|
||||||
|
dead = false;
|
||||||
|
|
||||||
|
constructor(from: THREE.Vector3, target: THREE.Vector3) {
|
||||||
|
this.target = target.clone();
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.copy(from);
|
||||||
|
|
||||||
|
this.vel = new THREE.Vector3().subVectors(target, from);
|
||||||
|
this.vel.y = 0;
|
||||||
|
const dist = this.vel.length();
|
||||||
|
if (dist > 0.01) {
|
||||||
|
this.vel.normalize().multiplyScalar(WEB_BLOB_SPEED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const glowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xddddff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.5,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 12), glowMat);
|
||||||
|
this.body.add(glow);
|
||||||
|
|
||||||
|
const coreMat = new THREE.MeshToonMaterial({ color: 0xf0f0ff });
|
||||||
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 10), coreMat);
|
||||||
|
this.body.add(core);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.life -= dt;
|
||||||
|
this.body.position.x += this.vel.x * dt;
|
||||||
|
this.body.position.z += this.vel.z * dt;
|
||||||
|
this.body.position.y = 1.4 + Math.sin(this.life * 4) * 0.1;
|
||||||
|
|
||||||
|
if (game.player.health > 0) {
|
||||||
|
const dist = this.body.position.distanceTo(game.player.body.position);
|
||||||
|
if (dist < WEB_BLOB_PROXIMITY && game.player.rootCooldown <= 0) {
|
||||||
|
game.player.rootTime = WEB_DIRECT_ROOT;
|
||||||
|
game.player.rootCooldown = 1.0;
|
||||||
|
game.player.damage(WEB_BLOB_DAMAGE, this.body.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = this.target.x - this.body.position.x;
|
||||||
|
const dz = this.target.z - this.body.position.z;
|
||||||
|
if (this.life <= 0 || dx * dx + dz * dz < 0.25) {
|
||||||
|
this.dead = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.body.removeFromParent();
|
||||||
|
for (const child of [...this.body.children]) {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user