Add Spider Queen, Frost Giant, Necromancer and Dragon bosses

This commit is contained in:
2026-08-20 10:21:08 +02:00
parent 151b07cb4e
commit 2bffefb02c
10 changed files with 2563 additions and 19 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ npm run dev # Dev-Server mit HMR
| `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) |
| `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` |
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` | Bosse (Welle 10 / 20 / 30), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` / `SpiderQueen.ts` / `FrostGiant.ts` / `Necromancer.ts` / `Dragon.ts` | Bosse (Welle 10 / 20 / 30 / 40 / 50 / 60 / 70), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Spinnenkönigin: stationär, Netze (`WebPatch.ts`: fesselt via `player.rootTime`), Spinngeschwister (Mini-Spiders), Säuresalven. Frostriese: Eispanzer (70% Schadensreduktion, `armorHp`, bricht + Stagger, regeneriert nach Timer), Eisberst-Ring (nacheinander explodierende Marken), Eiszapfen-Regen, Stampf (überspringbar). Erz-Nekromant: schwebend, Teleport mit Glow-Ring-Telegraf + Ankunftssalve, Beschwörungen mit wachsendem Boden-Glow. Drache: fliegt und ist nur im Lande-Zustand verletzbar (`takeDamage`-Override blockt im Flug), Sturzangriff mit wachsendem Schatten-Telegraf, Feuerspeer (Telegraf-Bogen, überspringbar), Feuerbälle im Flug, Enrage mit Doppel-Sturz. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
| `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf |
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` |
| `SoundManager.ts` | Vorab geladene .ogg-Buffer (`playSound(name, volume)`); Sounds: damage, spawn, fireball, explosion, teleport, gameover, sword_hit1, spiderwalking |
@@ -50,7 +50,7 @@ npm run dev # Dev-Server mit HMR
- **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen").
- **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen.
- **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf).
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung) und `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf). Alles wird in `Player.reset()` zurückgesetzt.
- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung), `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf) und `rootTime` (Netz-Fessel: keine Bewegung, Angriffe bleiben möglich). Alles wird in `Player.reset()` zurückgesetzt.
- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig).
- **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag.
- **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln.
+4
View File
@@ -400,6 +400,10 @@
<div id="changelog-panel">
<h2>Changelog</h2>
<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>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>
+778
View File
@@ -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();
}
}
}
}
+692
View File
@@ -0,0 +1,692 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { playSound } from './SoundManager';
const MAX_HEALTH = 10500;
const MAX_ARMOR = 2500;
const ARMOR_REGEN_TIME = 10;
const ARMOR_REDUCTION = 0.7;
const HIT_FLASH_TIME = 0.14;
const MODEL_SCALE = 2.0;
const MOVE_SPEED = 3.5;
const BURST_COOLDOWN = 7;
const BURST_COOLDOWN_2 = 5;
const BURST_MARK_COUNT = 8;
const BURST_MARK_COUNT_2 = 12;
const BURST_RING2_RADIUS = 13;
const BURST_MARK_RADIUS = 2.8;
const BURST_TELEGRAPH = 1.6;
const BURST_STAGGER = 0.18;
const BURST_DAMAGE = 22;
const BURST_RADIUS = 9;
const RAIN_COOLDOWN = 5;
const RAIN_COOLDOWN_2 = 4;
const RAIN_COUNT = 7;
const RAIN_COUNT_2 = 10;
const STOMP_COOLDOWN = 10;
const STOMP_COOLDOWN_2 = 8;
const STOMP_RADIUS = 11;
const STOMP_DAMAGE = 15;
const ENRAGE_RATIO = 0.35;
const ICICLE_DAMAGE = 30;
const ICICLE_TELEGRAPH = 0.9;
const ICICLE_IMPACT_RADIUS = 1.8;
export class FrostGiant extends Enemy {
private model: THREE.Group;
private game: Game | null = null;
private waveTime = 0;
private enraged = false;
private hitFlash = 0;
private skinMat!: THREE.MeshToonMaterial;
private darkMat!: THREE.MeshToonMaterial;
private eyeMat!: THREE.MeshBasicMaterial;
private armorShards: THREE.Mesh[] = [];
private armorShardMats: THREE.MeshToonMaterial[] = [];
private armorBroken = false;
private armorHp = MAX_ARMOR;
private armorRegenTimer = 0;
private armorGrow = 0;
private burstTimer = 3;
private rainTimer = 2;
private stompTimer = 6;
private burstMarks: {
group: THREE.Group;
ringMat: THREE.MeshBasicMaterial;
discMat: THREE.MeshBasicMaterial;
index: number;
exploded: boolean;
explAt: number;
}[] = [];
private burstActive = false;
private burstExplodeStart = 0;
private burstStartTime = 0;
private burstFlashes: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; life: number }[] = [];
private icicles: FallingIcicle[] = [];
private shockRing!: THREE.Mesh;
private shockRingMat!: THREE.MeshBasicMaterial;
private shockRingLife = 0;
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Frostriese';
this.speed = MOVE_SPEED;
this.xp = 280;
this.contactDps = 35;
this.contactRadius = 3.2;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.model.scale.setScalar(MODEL_SCALE);
this.fadeOutModel = this.model;
this.body.add(this.model);
this.skinMat = new THREE.MeshToonMaterial({ color: 0x77bbdd });
this.darkMat = new THREE.MeshToonMaterial({ color: 0x4477aa });
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0x88ddff });
const crystalMat = new THREE.MeshToonMaterial({ color: 0xccf0ff, emissive: 0x223344 });
const metalMat = new THREE.MeshToonMaterial({ color: 0x5599bb });
// Beine
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.45, 1.2, 10), this.darkMat);
leg.position.set(side * 0.55, -0.6, 0);
leg.castShadow = true;
this.registerFadeMesh(leg);
this.model.add(leg);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.25, 0.9), this.skinMat);
foot.position.set(side * 0.55, -1.25, 0.1);
this.registerFadeMesh(foot);
this.model.add(foot);
}
// Torso
const torso = new THREE.Mesh(new THREE.BoxGeometry(2.0, 2.2, 1.4), this.skinMat);
torso.position.y = 0.9;
torso.castShadow = true;
this.registerFadeMesh(torso);
this.model.add(torso);
// Bauchpanzer
const belly = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.9, 0.1), metalMat);
belly.position.set(0, 0.55, 0.72);
this.registerFadeMesh(belly);
this.model.add(belly);
// Schultern
for (const side of [-1, 1]) {
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.7, 12, 10), this.skinMat);
shoulder.position.set(side * 1.3, 1.7, 0);
shoulder.castShadow = true;
this.registerFadeMesh(shoulder);
this.model.add(shoulder);
// Arme
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.42, 1.4, 8), this.darkMat);
arm.position.set(side * 1.6, 0.6, 0);
this.registerFadeMesh(arm);
this.model.add(arm);
// Faust
const fist = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), this.skinMat);
fist.position.set(side * 1.7, 0.0, 0.2);
this.registerFadeMesh(fist);
this.model.add(fist);
}
// Kopf
const head = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.9, 0.95), this.skinMat);
head.position.y = 2.5;
head.castShadow = true;
this.registerFadeMesh(head);
this.model.add(head);
// Frostbart (+Z)
const beard = new THREE.Mesh(new THREE.ConeGeometry(0.45, 0.7, 8), this.darkMat);
beard.position.set(0, 1.95, 0.5);
beard.rotation.x = Math.PI;
this.registerFadeMesh(beard);
this.model.add(beard);
// Augen (+Z)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), this.eyeMat);
eye.position.set(side * 0.3, 2.62, 0.5);
this.model.add(eye);
}
// Eiskristall-Panzer (verdeckt den Rumpf, solange die Rüstung hält)
const shardPositions: [number, number, number, number][] = [
[0, 1.5, 0.75, 0],
[-0.7, 1.3, 0.7, 0.4],
[0.7, 1.3, 0.7, -0.4],
[0, 2.1, 0.7, 0],
[-1.4, 1.7, 0, 0.8],
[1.4, 1.7, 0, -0.8],
[-0.6, 0.7, 0.75, 0.3],
[0.6, 0.7, 0.75, -0.3],
[-0.3, 2.6, 0.5, 0.5],
[0.3, 2.6, 0.5, -0.5],
];
for (const [x, y, z, rotY] of shardPositions) {
const mat = crystalMat.clone();
const shard = new THREE.Mesh(new THREE.ConeGeometry(0.35, 0.9, 6), mat);
shard.position.set(x, y, z);
shard.rotation.set(-0.15, rotY, 0);
this.registerFadeMesh(shard);
this.model.add(shard);
this.armorShards.push(shard);
this.armorShardMats.push(mat);
}
// Schockwellen-Ring
this.shockRingMat = new THREE.MeshBasicMaterial({
color: 0x88ccff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
this.shockRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.0, 48), this.shockRingMat);
this.shockRing.rotation.x = -Math.PI / 2;
this.shockRing.position.y = 0.06;
this.shockRing.visible = false;
this.body.add(this.shockRing);
}
playAnim() {
// Prozedurale Optik
}
updateAnimation(dt: number) {
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback
if (this.hitFlash > 0) {
this.hitFlash -= dt;
this.skinMat.color.set(0xffffff);
this.darkMat.color.set(0xffffff);
this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05));
} else {
const dark = this.armorBroken;
this.skinMat.color.set(dark ? 0x4477aa : 0x77bbdd);
this.darkMat.color.set(dark ? 0x2a4a6a : 0x4477aa);
this.model.scale.setScalar(MODEL_SCALE);
}
// Enrage unter 35% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
this.eyeMat.color.set(0xff4422);
playSound('damage', 0.9);
game.triggerCameraShake(0.35, 0.5);
}
// Rüstungs-Regeneration
if (this.armorBroken) {
this.armorRegenTimer -= dt;
const t = 1 - Math.max(0, this.armorRegenTimer) / ARMOR_REGEN_TIME;
// Kristalle wachsen langsam nach, kurz vor Abschluss deutlich sichtbar
this.armorGrow = Math.min(1, t * 1.6);
const showScale = this.armorGrow;
for (const shard of this.armorShards) shard.visible = showScale > 0.02;
for (let i = 0; i < this.armorShards.length; i++) {
this.armorShards[i].scale.setScalar(showScale);
this.armorShardMats[i].opacity = Math.min(1, showScale * 2);
}
if (this.armorRegenTimer <= 0) {
this.armorBroken = false;
this.armorHp = MAX_ARMOR;
this.armorRegenTimer = 0;
for (const shard of this.armorShards) shard.scale.setScalar(1);
for (const mat of this.armorShardMats) mat.opacity = 1;
playSound('spawn', 0.7);
}
}
// Zum Spieler laufen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
this.body.lookAt(
this.body.position.x + dir.x,
this.body.position.y,
this.body.position.z + dir.z
);
let moveDir = dir.multiplyScalar(MOVE_SPEED);
if (this.knockback > 0) {
moveDir = dir.multiplyScalar(-MOVE_SPEED);
this.knockback -= dt;
}
this.body.position.x += moveDir.x * dt;
this.body.position.z += moveDir.z * dt;
game.clampToArena(this.body.position);
}
// Angriffe
this.burstTimer -= dt;
if (this.burstTimer <= 0 && !this.burstActive) {
this.burstTimer = this.enraged ? BURST_COOLDOWN_2 : BURST_COOLDOWN;
this.startBurst(game);
}
this.rainTimer -= dt;
if (this.rainTimer <= 0) {
this.rainTimer = this.enraged ? RAIN_COOLDOWN_2 : RAIN_COOLDOWN;
this.icicleRain(game);
}
this.stompTimer -= dt;
if (this.stompTimer <= 0) {
this.stompTimer = this.enraged ? STOMP_COOLDOWN_2 : STOMP_COOLDOWN;
this.stomp(game);
}
this.updateBurst(dt, game);
this.updateBurstFlashes(dt);
// Schockwellen-Ring expandieren
if (this.shockRing.visible) {
const s = this.shockRing.scale.x + dt * 26;
this.shockRing.scale.setScalar(s);
this.shockRingMat.opacity = Math.max(0, 0.5 - (s / STOMP_RADIUS) * 0.5);
if (s >= STOMP_RADIUS) this.shockRing.visible = false;
}
// Eiszapfen
for (let i = this.icicles.length - 1; i >= 0; i--) {
if (!this.icicles[i].update(dt, game)) {
this.icicles[i].dispose();
this.icicles.splice(i, 1);
}
}
}
// --- Eispanzer ---
private breakArmor(game: Game) {
this.armorBroken = true;
this.armorRegenTimer = ARMOR_REGEN_TIME;
this.armorGrow = 0;
for (const shard of this.armorShards) shard.visible = false;
for (const mat of this.armorShardMats) mat.opacity = 0;
game.spawnDamageText('PANZER WEG!', this.body.position, '#88ccff');
game.triggerCameraShake(0.4, 0.5);
playSound('explosion', 0.9);
}
// --- Eisberst-Ring ---
private startBurst(game: Game) {
this.burstActive = true;
this.burstExplodeStart = BURST_TELEGRAPH;
this.burstStartTime = this.waveTime;
this.burstMarks = [];
const count = this.enraged ? BURST_MARK_COUNT_2 : BURST_MARK_COUNT;
const offset = Math.random() * Math.PI * 2;
const rings = this.enraged
? [{ radius: BURST_RADIUS, count: BURST_MARK_COUNT }]
: [{ radius: BURST_RADIUS, count }];
if (this.enraged) {
rings.push({ radius: BURST_RING2_RADIUS, count: BURST_MARK_COUNT - 2 });
}
const cx = this.body.position.x;
const cz = this.body.position.z;
let index = 0;
for (const ring of rings) {
for (let i = 0; i < ring.count; i++) {
const a = offset + (i / ring.count) * Math.PI * 2;
const ringMat = new THREE.MeshBasicMaterial({
color: 0x66ccff,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const discMat = new THREE.MeshBasicMaterial({
color: 0x4488ff,
transparent: true,
opacity: 0,
depthWrite: false,
});
const ringMesh = new THREE.Mesh(
new THREE.RingGeometry(BURST_MARK_RADIUS - 0.15, BURST_MARK_RADIUS + 0.15, 32),
ringMat
);
ringMesh.rotation.x = -Math.PI / 2;
ringMesh.position.y = 0.05;
const disc = new THREE.Mesh(
new THREE.CircleGeometry(BURST_MARK_RADIUS, 32),
discMat
);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.03;
const group = new THREE.Group();
group.add(ringMesh, disc);
group.position.set(
cx + Math.cos(a) * ring.radius,
0,
cz + Math.sin(a) * ring.radius
);
game.scene.add(group);
this.burstMarks.push({ group, ringMat, discMat, index, exploded: false, explAt: 0 });
index++;
}
}
playSound('spawn', 0.7);
}
private updateBurst(dt: number, game: Game) {
if (!this.burstActive) return;
if (this.burstExplodeStart > 0) {
// Telegraf: Marken pulsieren
this.burstExplodeStart -= dt;
const pulse = Math.abs(Math.sin(this.waveTime * 9));
for (const mark of this.burstMarks) {
mark.ringMat.opacity = 0.3 + 0.5 * pulse;
mark.discMat.opacity = 0.12 + 0.08 * pulse;
}
if (this.burstExplodeStart <= 0) {
// Explosionsfenster: nacheinander
this.burstExplodeStart = -1;
}
return;
}
let allDone = true;
for (const mark of this.burstMarks) {
if (!mark.exploded) {
allDone = false;
const t = (this.waveTime - this.burstStartTime) - (mark.index * BURST_STAGGER);
if (t >= 0) {
mark.exploded = true;
this.explodeMark(mark, game);
}
}
}
if (allDone) {
this.burstActive = false;
for (const mark of this.burstMarks) {
game.scene.remove(mark.group);
mark.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
this.burstMarks = [];
}
}
private explodeMark(mark: { group: THREE.Group }, game: Game) {
// Expandierender Frost-Ring
const flashMat = new THREE.MeshBasicMaterial({
color: 0x99ddff,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const flash = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), flashMat);
flash.rotation.x = -Math.PI / 2;
flash.position.set(mark.group.position.x, 0.07, mark.group.position.z);
game.scene.add(flash);
this.burstFlashes.push({ mesh: flash, mat: flashMat, life: 0.35 });
// Schaden (überspringbar: nur am Boden)
if (game.player.health > 0 && game.player.body.position.y <= 0.8) {
const dx = game.player.body.position.x - mark.group.position.x;
const dz = game.player.body.position.z - mark.group.position.z;
if (Math.hypot(dx, dz) < BURST_MARK_RADIUS + 0.3) {
game.player.damage(BURST_DAMAGE, mark.group.position);
}
}
playSound('explosion', 0.4);
}
private updateBurstFlashes(dt: number) {
for (let i = this.burstFlashes.length - 1; i >= 0; i--) {
const f = this.burstFlashes[i];
f.life -= dt;
const t = 1 - Math.max(0, f.life) / 0.35;
f.mesh.scale.setScalar(0.5 + t * 6);
f.mat.opacity = 0.7 * (1 - t);
if (f.life <= 0) {
this.game?.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
this.burstFlashes.splice(i, 1);
}
}
}
// --- Eiszapfen-Regen ---
private icicleRain(game: Game) {
const playerPos = game.player.body.position;
const count = this.enraged ? RAIN_COUNT_2 : RAIN_COUNT;
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = 2 + Math.random() * 6;
const icicle = new FallingIcicle(new THREE.Vector3(
playerPos.x + Math.cos(a) * r,
0,
playerPos.z + Math.sin(a) * r
));
game.scene.add(icicle.body);
this.icicles.push(icicle);
}
playSound('spawn', 0.5);
}
// --- Stampf ---
private stomp(game: Game) {
this.shockRing.visible = true;
this.shockRing.scale.setScalar(0.2);
this.shockRingMat.opacity = 0.5;
playSound('explosion', 0.7);
const playerPos = game.player.body.position;
const dist = Math.hypot(
playerPos.x - this.body.position.x,
playerPos.z - this.body.position.z
);
// Überspringbar: nur Treffer am Boden
if (dist < STOMP_RADIUS && playerPos.y <= 0.6) {
game.player.damage(STOMP_DAMAGE, this.body.position);
}
game.triggerCameraShake(0.3, 0.3);
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
if (this.dead) return;
if (this.armorBroken === false && this.armorShards.length > 0) {
const absorbed = Math.min(MAX_ARMOR, damage * ARMOR_REDUCTION);
damage *= 1 - ARMOR_REDUCTION;
game.spawnDamageText('Eispanzer!', this.body.position, '#99ccff');
// Rüstung abbauen: Kristalle werden mit sinkender Armor-HP blasser
const pct = 1 - this.armorHp / MAX_ARMOR;
for (let i = 0; i < this.armorShards.length; i++) {
const shard = this.armorShards[i];
const threshold = (i + 1) / this.armorShards.length;
shard.visible = pct < threshold;
if (!shard.visible) {
this.armorShardMats[i].opacity = 0;
}
}
this.armorHp -= absorbed;
if (this.armorHp <= 0) {
this.armorHp = 0;
this.breakArmor(game);
}
}
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
this.burstActive = false;
if (this.game) {
for (const mark of this.burstMarks) {
this.game.scene.remove(mark.group);
mark.group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
for (const f of this.burstFlashes) {
this.game.scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mat.dispose();
}
}
this.burstMarks = [];
this.burstFlashes = [];
for (const icicle of this.icicles) icicle.dispose();
this.icicles = [];
this.shockRing.visible = false;
this.skinMat.color.set(0x77bbdd);
this.darkMat.color.set(0x4477aa);
this.eyeMat.color.set(0x88ddff);
this.model.scale.setScalar(MODEL_SCALE);
for (const shard of this.armorShards) shard.visible = true;
for (const mat of this.armorShardMats) mat.opacity = 1;
}
}
// Eiszapfen: fällt mit wachsendem Schatten-Telegrafen vom Himmel
class FallingIcicle {
body: THREE.Group;
private shadow: THREE.Mesh;
private icicle: THREE.Mesh;
private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph';
private timer = ICICLE_TELEGRAPH;
private dust: THREE.Mesh[] = [];
private dustDirs: THREE.Vector3[] = [];
private dustMat: THREE.MeshBasicMaterial;
constructor(pos: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.set(pos.x, 0, pos.z);
const shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.3,
depthWrite: false,
});
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), shadowMat);
this.shadow.rotation.x = -Math.PI / 2;
this.shadow.position.y = 0.06;
this.shadow.scale.setScalar(0.4);
this.body.add(this.shadow);
const iceMat = new THREE.MeshToonMaterial({ color: 0xbbeeff, emissive: 0x334455 });
this.icicle = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.6, 6), iceMat);
this.icicle.position.y = 16;
this.icicle.rotation.x = Math.PI;
this.body.add(this.icicle);
this.dustMat = new THREE.MeshBasicMaterial({
color: 0x99bbdd,
transparent: true,
opacity: 0,
depthWrite: false,
});
}
update(dt: number, game: Game): boolean {
this.timer -= dt;
if (this.phase === 'telegraph') {
const t = 1 - this.timer / ICICLE_TELEGRAPH;
this.shadow.scale.setScalar(0.4 + t * 0.6);
(this.shadow.material as THREE.MeshBasicMaterial).opacity = 0.2 + t * 0.2;
if (this.timer <= 0) {
this.phase = 'fall';
this.timer = 2;
}
} else if (this.phase === 'fall') {
this.icicle.position.y -= 30 * dt;
this.icicle.rotation.z += dt * 2;
if (this.icicle.position.y <= 0.15) {
this.icicle.position.y = 0.15;
this.icicle.visible = false;
this.shadow.visible = false;
if (game.player.health > 0) {
const dx = game.player.body.position.x - this.body.position.x;
const dz = game.player.body.position.z - this.body.position.z;
if (Math.hypot(dx, dz) < ICICLE_IMPACT_RADIUS) {
game.player.damage(ICICLE_DAMAGE, this.body.position);
}
}
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2;
const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a));
const puff = new THREE.Mesh(new THREE.SphereGeometry(0.25, 6, 5), this.dustMat);
this.body.add(puff);
this.dust.push(puff);
this.dustDirs.push(dir);
}
this.phase = 'dust';
this.timer = 0.4;
}
} else {
const t = 1 - this.timer / 0.4;
for (let i = 0; i < this.dust.length; i++) {
this.dust[i].position.copy(this.dustDirs[i]).multiplyScalar(0.5 + t * 1.8);
this.dust[i].position.y = 0.2 + t * 0.8;
}
this.dustMat.opacity = 0.5 * (1 - t);
if (this.timer <= 0) return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
this.body.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
}
+14 -2
View File
@@ -11,6 +11,10 @@ import { Brute } from './Brute';
import { Golem } from './Golem';
import { Krake } from './Krake';
import { Minotaurus } from './Minotaurus';
import { SpiderQueen } from './SpiderQueen';
import { FrostGiant } from './FrostGiant';
import { Necromancer } from './Necromancer';
import { Dragon } from './Dragon';
import { Crab } from './Crab';
import { Turret } from './Turret';
import { EnemyProjectile } from './EnemyProjectile';
@@ -83,6 +87,10 @@ export class Game {
[10, { name: 'Golem', spawn: () => new Golem() }],
[20, { name: 'Krake', spawn: () => new Krake() }],
[30, { name: 'Minotaurus', spawn: () => new Minotaurus() }],
[40, { name: 'Spinnenkönigin', spawn: () => new SpiderQueen() }],
[50, { name: 'Frostriese', spawn: () => new FrostGiant() }],
[60, { name: 'Erz-Nekromant', spawn: () => new Necromancer() }],
[70, { name: 'Drache', spawn: () => new Dragon() }],
]);
private bossFightActive = false;
private shakeTime = 0;
@@ -309,7 +317,8 @@ export class Game {
return;
}
if (e.code === 'Space' && this.state === 'playing'
&& this.player.pullTime <= 0 && this.player.stunTime <= 0) {
&& this.player.pullTime <= 0 && this.player.stunTime <= 0
&& this.player.rootTime <= 0) {
this.player.jump();
e.preventDefault();
}
@@ -1053,6 +1062,9 @@ export class Game {
if (p.stunTime > 0) {
// Gestunnt (Stampf-Schockwelle): keine Eingaben
p.velocity.set(0, 0, 0);
} else if (p.rootTime > 0) {
// Netz-Fessel (Spinnenkönigin): keine Bewegung, Angriffe bleiben möglich
p.velocity.set(0, 0, 0);
} else if (p.pullTime > 0) {
// Tentakel-Griff: Spieler wird komplett zum Boss gezogen
p.pullTime -= dt;
@@ -1083,7 +1095,7 @@ export class Game {
}
// Jump / gravity
if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0) {
if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0 && p.rootTime <= 0) {
p.jumpVelocity -= 20 * dt;
p.body.position.y += p.jumpVelocity * dt;
if (p.body.position.y <= 0.5) {
+527
View File
@@ -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);
}
}
+6
View File
@@ -35,6 +35,8 @@ export class Player {
pullTime = 0;
pullDir = new THREE.Vector3();
stunTime = 0;
rootTime = 0;
rootCooldown = 0;
stats: PlayerStats = {
maxHealth: 100,
regen: 0,
@@ -239,6 +241,8 @@ export class Player {
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
if (this.slowTimer > 0) this.slowTimer -= dt;
if (this.stunTime > 0) this.stunTime -= dt;
if (this.rootTime > 0) this.rootTime -= dt;
if (this.rootCooldown > 0) this.rootCooldown -= dt;
// Stun-Sterne animieren
this.stunStars.visible = this.stunTime > 0;
@@ -360,6 +364,8 @@ export class Player {
this.slowTimer = 0;
this.pullTime = 0;
this.stunTime = 0;
this.rootTime = 0;
this.rootCooldown = 0;
this.body.position.set(0, 0.5, 0);
this.velocity.set(0, 0, 0);
this.jumpVelocity = 0;
+27 -15
View File
@@ -16,29 +16,41 @@ export async function initSpiderModel() {
}
}
// Wiederverwendbarer Spinnen-Modell-Builder (für Spinne UND Spinnenkönigin).
// tint färbt die Textur mit ein (0xffffff = Original).
export function buildSpiderModel(scale: number, tint: number): THREE.Group | null {
if (!spiderGLTF) return null;
const model = cloneSkeleton(spiderGLTF.scene) as THREE.Group;
model.scale.set(scale, scale, scale);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture, color: tint });
}
});
return model;
}
// Animationen des Spinnen-Modells (leer, wenn nicht geladen)
export function getSpiderAnimations(): THREE.AnimationClip[] {
return spiderGLTF ? spiderGLTF.animations : [];
}
export class Spider extends Enemy {
constructor() {
super();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
if (spiderGLTF) {
const model = cloneSkeleton(spiderGLTF.scene);
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
model.scale.set(0.5, 0.5, 0.5);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
}
});
const model = buildSpiderModel(0.5, 0xffffff);
if (model) {
this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
if (getSpiderAnimations().length > 0 && ANIMATIONS_ENABLED) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of spiderGLTF.animations) {
for (const clip of getSpiderAnimations()) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
+408
View File
@@ -0,0 +1,408 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { Enemy } from './Enemy';
import { Spider, buildSpiderModel, getSpiderAnimations } from './Spider';
import { playSound } from './SoundManager';
import { WebPatch } from './WebPatch';
const MAX_HEALTH = 8500;
const WEB_COOLDOWN = 4.5;
const SCREAM_COOLDOWN = 6;
const SPIT_COOLDOWN = 5;
const WEB_RADIUS = 2.2;
const WEB_DURATION = 3;
const WEB_DIRECT_ROOT = 0.5;
const SPIDERLING_COUNT = 3;
const SPIDERLING_COUNT_2 = 5;
const SPIDERLING_MAX_ALIVE = 8;
const SPIT_COUNT = 3;
const SPIT_COUNT_2 = 5;
const SPIT_DAMAGE = 15;
const ENRAGE_RATIO = 0.4;
const HIT_FLASH_TIME = 0.14;
const WEB_BLOB_SPEED = 7;
const WEB_BLOB_PROXIMITY = 1.3;
const WEB_BLOB_DAMAGE = 10;
export class SpiderQueen extends Enemy {
spawnAtCenter = true;
private game: Game | null = null;
private waveTime = 0;
private webTimer = 2;
private screamTimer = 4;
private spitTimer = 5;
private enraged = false;
private effectsReady = false;
private hitFlash = 0;
private model!: THREE.Group;
private tintMats: THREE.MeshToonMaterial[] = [];
private webBlobs: WebBlob[] = [];
private webs: WebPatch[] = [];
constructor() {
super();
this.health = MAX_HEALTH;
this.maxHealth = MAX_HEALTH;
this.isBoss = true;
this.displayName = 'Spinnenkönigin';
this.immovable = true;
this.speed = 0;
this.xp = 240;
this.contactDps = 0;
this.contactRadius = 0;
this.guaranteedDrop = true;
this.model = new THREE.Group();
this.fadeOutModel = this.model;
this.body.add(this.model);
// Königinnen-Modell: bestehende Spinne, größer + violett eingefärbt
const spiderModel = buildSpiderModel(3.2, 0x9a5ac8);
if (spiderModel) {
this.model.add(spiderModel);
spiderModel.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshToonMaterial) {
this.tintMats.push(child.material);
}
});
if (getSpiderAnimations().length > 0) {
this.mixer = new THREE.AnimationMixer(spiderModel);
for (const clip of getSpiderAnimations()) {
this.clips.set(clip.name, this.mixer.clipAction(clip));
}
this.playAnim('stand');
}
} else {
// Fallback (Modell nicht geladen): einfache große Spinne
const skinMat = new THREE.MeshToonMaterial({ color: 0x7a3a8a });
const darkMat = new THREE.MeshToonMaterial({ color: 0x4a2260 });
this.tintMats.push(skinMat, darkMat);
const abdomen = new THREE.Mesh(new THREE.SphereGeometry(1.6, 24, 18), skinMat);
abdomen.scale.set(1, 1.05, 1.35);
abdomen.position.set(0, 2.0, -1.7);
abdomen.castShadow = true;
this.registerFadeMesh(abdomen);
this.model.add(abdomen);
const cephalo = new THREE.Mesh(new THREE.SphereGeometry(1.0, 20, 16), skinMat);
cephalo.scale.set(1, 0.85, 1.1);
cephalo.position.set(0, 1.9, 0.6);
cephalo.castShadow = true;
this.registerFadeMesh(cephalo);
this.model.add(cephalo);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.6, 16, 12), darkMat);
head.scale.set(1, 0.8, 0.9);
head.position.set(0, 2.0, 1.5);
this.registerFadeMesh(head);
this.model.add(head);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff3333 });
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 8, 6), eyeMat);
eye.position.set(side * 0.32, 2.15, 1.85);
this.model.add(eye);
}
const legMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a });
this.tintMats.push(legMat);
for (let i = 0; i < 8; i++) {
const side = i < 4 ? -1 : 1;
const back = i % 2 === 0;
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 1.1, 6), legMat);
leg.position.set(side * (back ? 1.0 : 0.9), 0.6, back ? -0.9 : 0.4);
leg.rotation.z = side * 1.1;
this.registerFadeMesh(leg);
this.model.add(leg);
}
}
}
playAnim(name: string, loop = true, speed = 1) {
if (!this.clips.has(name) || this.currentAnim === name) return;
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.play();
}
updateAnimation(dt: number) {
if (this.mixer) this.mixer.update(dt);
this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 });
}
update(dt: number, game: Game) {
this.game = game;
this.waveTime += dt;
// Hit-Feedback: Materialien kurz weiß aufblitzen
if (this.hitFlash > 0) {
this.hitFlash -= dt;
for (const mat of this.tintMats) mat.color.set(0xffffff);
this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05);
} else {
for (const mat of this.tintMats) mat.color.set(this.enraged ? 0xff6644 : 0x9a5ac8);
this.model.scale.setScalar(1);
}
// Zum Spieler drehen
const playerPos = game.player.body.position;
const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position);
toPlayer.y = 0;
if (toPlayer.length() > 0.1) {
this.body.lookAt(
this.body.position.x + toPlayer.x,
this.body.position.y,
this.body.position.z + toPlayer.z
);
}
// Enrage unter 40% HP
if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) {
this.enraged = true;
playSound('damage', 0.8);
game.triggerCameraShake(0.35, 0.4);
}
// Angriffe
this.webTimer -= dt;
if (this.webTimer <= 0) {
this.webTimer = this.enraged ? WEB_COOLDOWN * 0.65 : WEB_COOLDOWN;
this.spitWeb(game);
}
this.screamTimer -= dt;
if (this.screamTimer <= 0) {
this.screamTimer = this.enraged ? SCREAM_COOLDOWN * 0.6 : SCREAM_COOLDOWN;
this.scream(game);
}
this.spitTimer -= dt;
if (this.spitTimer <= 0) {
this.spitTimer = this.enraged ? SPIT_COOLDOWN * 0.7 : SPIT_COOLDOWN;
this.spitAcid(game);
}
// Netz-Bälle aktualisieren
for (let i = this.webBlobs.length - 1; i >= 0; i--) {
const blob = this.webBlobs[i];
if (!blob.update(dt, game)) {
this.webBlobs.splice(i, 1);
const patch = new WebPatch(
blob.body.position,
this.enraged ? WEB_RADIUS + 0.4 : WEB_RADIUS,
this.enraged ? WEB_DURATION * 1.5 : WEB_DURATION
);
game.scene.add(patch.body);
this.webs.push(patch);
blob.dispose();
playSound('explosion', 0.4);
}
}
// Netze aktualisieren
for (let i = this.webs.length - 1; i >= 0; i--) {
const web = this.webs[i];
if (!web.update(dt, game)) {
this.webs.splice(i, 1);
web.dispose();
}
}
}
private spitWeb(game: Game) {
const from = new THREE.Vector3(this.body.position.x, 2.2, this.body.position.z);
const target = game.player.body.position.clone();
target.y = 0;
const blob = new WebBlob(from, target);
game.scene.add(blob.body);
this.webBlobs.push(blob);
playSound('spawn', 0.5);
}
private scream(game: Game) {
// Nicht endlos viele Spinnlinge anhäufen
let alive = 0;
for (const e of game.enemies) {
if (e instanceof Spiderling && !e.dead) alive++;
}
const count = Math.min(
this.enraged ? SPIDERLING_COUNT_2 : SPIDERLING_COUNT,
SPIDERLING_MAX_ALIVE - alive
);
if (count <= 0) return;
for (let i = 0; i < count; i++) {
const mini = new Spiderling();
const a = Math.random() * Math.PI * 2;
const r = 4 + Math.random() * 4;
game.spawnEnemyInstance(
mini,
this.body.position.x + Math.cos(a) * r,
this.body.position.z + Math.sin(a) * r,
0.1
);
}
playSound('spawn', 0.7);
}
private spitAcid(game: Game) {
const from = new THREE.Vector3(this.body.position.x, 2.4, this.body.position.z);
const target = game.player.body.position.clone();
const count = this.enraged ? SPIT_COUNT_2 : SPIT_COUNT;
const spread = 0.35;
const baseDir = new THREE.Vector3().subVectors(target, from);
baseDir.y = 0;
baseDir.normalize();
const perp = new THREE.Vector3(-baseDir.z, 0, baseDir.x);
for (let i = 0; i < count; i++) {
const off = (i / (count - 1) - 0.5) * 2 * spread;
const dir = new THREE.Vector3()
.addScaledVector(baseDir, 1)
.addScaledVector(perp, off)
.normalize();
const t = from.clone().add(dir.multiplyScalar(20));
t.y = 0;
game.spawnEnemyProjectile(from, t, SPIT_DAMAGE);
}
playSound('spawn', 0.6);
}
override takeDamage(
damage: number,
game: Game,
withKnockback = true,
sourcePos?: THREE.Vector3
) {
super.takeDamage(damage, game, withKnockback, sourcePos);
this.hitFlash = HIT_FLASH_TIME;
}
protected onDeath(_game: Game) {
this.cleanupEffects();
this.beginFadeDeath();
}
override dispose() {
super.dispose();
this.cleanupEffects();
}
private cleanupEffects() {
for (const blob of this.webBlobs) blob.dispose();
this.webBlobs = [];
for (const web of this.webs) web.dispose();
this.webs = [];
for (const mat of this.tintMats) mat.color.set(0x9a5ac8);
this.model.scale.setScalar(1);
}
}
// Spinnling: wie die normale Spinne, stirbt aber sichtbar (schrumpft + sinkt),
// damit keine stehenden Leichen in der Arena zurückbleiben.
export class Spiderling extends Spider {
private fadeDying = false;
private fadeDieTime = 0;
constructor() {
super();
this.health = 25;
this.speed = 4.2;
this.xp = 5;
this.contactDps = 12;
this.contactRadius = 1.5;
// Kein grace: Spider dekrementiert grace nie, sonst wären die Spinnlinge unverwundbar.
this.body.scale.setScalar(0.5);
}
protected onDeath(_game: Game) {
this.fadeDying = true;
}
updateAnimation(dt: number) {
super.updateAnimation(dt);
if (this.fadeDying) {
this.fadeDieTime += dt;
const t = Math.min(this.fadeDieTime / 0.5, 1);
this.body.scale.setScalar(0.5 * (1 - t));
this.body.position.y -= dt * 1.2;
}
}
}
// Netz-Ball: fliegt zum Spieler und hinterlässt beim Auftreffen ein WebPatch
class WebBlob {
body: THREE.Group;
target: THREE.Vector3;
private vel: THREE.Vector3;
private life = 6;
dead = false;
constructor(from: THREE.Vector3, target: THREE.Vector3) {
this.target = target.clone();
this.body = new THREE.Group();
this.body.position.copy(from);
this.vel = new THREE.Vector3().subVectors(target, from);
this.vel.y = 0;
const dist = this.vel.length();
if (dist > 0.01) {
this.vel.normalize().multiplyScalar(WEB_BLOB_SPEED);
}
const glowMat = new THREE.MeshBasicMaterial({
color: 0xddddff,
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 12), glowMat);
this.body.add(glow);
const coreMat = new THREE.MeshToonMaterial({ color: 0xf0f0ff });
const core = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 10), coreMat);
this.body.add(core);
}
update(dt: number, game: Game): boolean {
this.life -= dt;
this.body.position.x += this.vel.x * dt;
this.body.position.z += this.vel.z * dt;
this.body.position.y = 1.4 + Math.sin(this.life * 4) * 0.1;
if (game.player.health > 0) {
const dist = this.body.position.distanceTo(game.player.body.position);
if (dist < WEB_BLOB_PROXIMITY && game.player.rootCooldown <= 0) {
game.player.rootTime = WEB_DIRECT_ROOT;
game.player.rootCooldown = 1.0;
game.player.damage(WEB_BLOB_DAMAGE, this.body.position);
}
}
const dx = this.target.x - this.body.position.x;
const dz = this.target.z - this.body.position.z;
if (this.life <= 0 || dx * dx + dz * dz < 0.25) {
this.dead = true;
return false;
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import * as THREE from 'three';
import type { Game } from './Game';
const ROOT_DURATION = 0.35;
const ROOT_TICK = 1.0;
const ROOT_COOLDOWN = 1.0;
const FADE_TIME = 0.6;
export class WebPatch {
body: THREE.Group;
private radius: number;
private life: number;
private rootTick = 0;
private meshes: THREE.Mesh[] = [];
constructor(position: THREE.Vector3, radius = 3, duration = 6) {
this.radius = radius;
this.life = duration;
this.body = new THREE.Group();
this.body.position.set(position.x, 0.08, position.z);
const mat = new THREE.MeshToonMaterial({
color: 0xdfe6ff,
transparent: true,
opacity: 0.55,
depthWrite: false,
});
const disc = new THREE.Mesh(new THREE.CircleGeometry(radius, 24), mat);
disc.rotation.x = -Math.PI / 2;
disc.position.y = 0.01;
this.body.add(disc);
this.meshes.push(disc);
// Radiale Fäden
const threadMat = new THREE.MeshToonMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const thread = new THREE.Mesh(
new THREE.BoxGeometry(radius * 2, 0.03, 0.05),
threadMat
);
thread.rotation.y = -a;
thread.position.y = 0.02;
this.body.add(thread);
this.meshes.push(thread);
}
// Konzentrische Ringe
for (let r = 1; r <= 3; r++) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(r * radius / 4 - 0.04, r * radius / 4 + 0.04, 24),
threadMat
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.025;
this.body.add(ring);
this.meshes.push(ring);
}
}
update(dt: number, game: Game): boolean {
this.life -= dt;
if (this.life <= 0) return false;
const fade = Math.min(1, this.life / FADE_TIME);
for (const mesh of this.meshes) {
(mesh.material as THREE.MeshToonMaterial).opacity =
(mesh === this.meshes[0] ? 0.55 : 0.7) * fade;
}
if (game.player.health > 0) {
const dist = Math.hypot(
game.player.body.position.x - this.body.position.x,
game.player.body.position.z - this.body.position.z
);
if (dist < this.radius) {
// Gepulste Fessel: kurzer Root, dann Lücke zum Rauslaufen.
// rootCooldown verhindert Endlos-Fessel durch überlappende Netze.
this.rootTick -= dt;
if (this.rootTick <= 0 && game.player.rootCooldown <= 0) {
game.player.rootTime = ROOT_DURATION;
game.player.rootCooldown = ROOT_COOLDOWN;
this.rootTick = ROOT_TICK;
}
} else {
this.rootTick = 0;
}
}
return true;
}
dispose() {
this.body.removeFromParent();
for (const mesh of this.meshes) {
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
}
}
}