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