import * as THREE from 'three'; import type { Game } from './Game'; import { Enemy } from './Enemy'; import { playSound } from './SoundManager'; import { InkBlob } from './InkBlob'; import { InkPuddle } from './InkPuddle'; const MAX_HEALTH = 3200; const INK_COOLDOWN = 4.5; const PULL_COOLDOWN = 7.5; const PULL_MARK_COUNT = 5; const PULL_MARK_RADIUS = 2.2; const PULL_MARK_DIST = 6.5; const PULL_CHARGE_TIME = 2.5; const PULL_DAMAGE = 25; const PULL_TIME = 0.6; const SLAM_TIME = 0.5; const HIT_FLASH_TIME = 0.14; const SWEEP_RADIUS = 11; const SWEEP_DURATION = 3.6; const SWEEP_HIT_WIDTH = 1.2; const SWEEP_DAMAGE = 25; const ENRAGE_RATIO = 0.3; const INK_RADIUS = 3.5; const INK_DURATION = 6; export class Krake extends Enemy { spawnAtCenter = true; private model: THREE.Group; private game: Game | null = null; private waveTime = 0; private tentacles: THREE.Group[] = []; private inkTimer = 2; private pullTimer = 3.5; private sweepTimer = 5; private enraged = false; private effectsReady = false; private slamState: 'none' | 'grab' | 'slam' = 'none'; private slamChain!: THREE.Group; private slamTime = 0; private slamRetractFrom = 1; private pullState: 'idle' | 'charge' = 'idle'; private pullChargeTime = 0; private markOffsetAngle = 0; private pullMarks: { group: THREE.Group; ringMat: THREE.MeshBasicMaterial; discMat: THREE.MeshBasicMaterial; }[] = []; private sweepTents: { group: THREE.Group; angle: number; dir: number; progress: number }[] = []; private sweepActive = false; private sweepHit = false; private inkBlobs: InkBlob[] = []; private puddles: InkPuddle[] = []; private skinMat!: THREE.MeshToonMaterial; private darkMat!: THREE.MeshToonMaterial; private hitFlash = 0; constructor() { super(); this.health = MAX_HEALTH; this.maxHealth = MAX_HEALTH; this.isBoss = true; this.displayName = 'Krake'; this.immovable = true; this.speed = 0; this.xp = 150; this.contactDps = 0; this.contactRadius = 0; this.guaranteedDrop = true; this.model = new THREE.Group(); this.fadeOutModel = this.model; this.body.add(this.model); const skinMat = new THREE.MeshToonMaterial({ color: 0x8a4a9a }); const darkMat = new THREE.MeshToonMaterial({ color: 0x5a2a6a }); const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffee44 }); this.skinMat = skinMat; this.darkMat = darkMat; // Kopf const head = new THREE.Mesh(new THREE.SphereGeometry(2.2, 24, 18), skinMat); head.scale.set(1, 0.85, 1); head.position.y = 3.1; head.castShadow = true; this.registerFadeMesh(head); this.model.add(head); // Augen auf +Z (Blickrichtung) for (const side of [-1, 1]) { const eye = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), eyeMat); eye.position.set(side * 0.8, 3.45, 1.9); this.registerFadeMesh(eye); this.model.add(eye); } // Tentakel um den Kopf herum for (let i = 0; i < 8; i++) { const chain = new THREE.Group(); const a = (i / 8) * Math.PI * 2; for (let j = 0; j < 7; j++) { const seg = new THREE.Mesh( new THREE.SphereGeometry(0.42 - j * 0.04, 12, 10), j % 2 === 0 ? skinMat : darkMat ); seg.position.set(j * 0.55, -j * 0.28, 0); this.registerFadeMesh(seg); chain.add(seg); } chain.position.set(Math.cos(a) * 1.7, 1.1, Math.sin(a) * 1.7); chain.rotation.y = -a; this.model.add(chain); this.tentacles.push(chain); } // Weltraum-Effekte (Pull-Marken, Slam-Tentakel, Sweep-Tentakel) this.buildEffects(); } private buildEffects() { // 5 rote Boden-Marken (Arme) rund um den Oktopus for (let i = 0; i < PULL_MARK_COUNT; i++) { const ringMat = new THREE.MeshBasicMaterial({ color: 0xff3333, transparent: true, opacity: 0, side: THREE.DoubleSide, depthWrite: false, }); const discMat = new THREE.MeshBasicMaterial({ color: 0xff2222, transparent: true, opacity: 0, depthWrite: false, }); const ring = new THREE.Mesh( new THREE.RingGeometry(PULL_MARK_RADIUS - 0.15, PULL_MARK_RADIUS + 0.15, 32), ringMat ); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.05; const disc = new THREE.Mesh(new THREE.CircleGeometry(PULL_MARK_RADIUS, 32), discMat); disc.rotation.x = -Math.PI / 2; disc.position.y = 0.03; const group = new THREE.Group(); group.add(ring, disc); group.visible = false; this.pullMarks.push({ group, ringMat, discMat }); } this.slamChain = new THREE.Group(); for (let j = 1; j <= 10; j++) { const seg = new THREE.Mesh( new THREE.SphereGeometry(0.3, 8, 6), new THREE.MeshToonMaterial({ color: 0x7a3a8a }) ); seg.position.set(0, 0, j * 0.5); this.slamChain.add(seg); } this.slamChain.visible = false; // Kreisende Sweep-Tentakel (dicker roter Arm mit Saugnaepfen, ueber den man springen kann) // Zwei Exemplare: Phase 2 nutzt beide in entgegengesetzte Richtungen for (let i = 0; i < 2; i++) { const group = this.buildSweepTentacle(); group.visible = false; this.sweepTents.push({ group, angle: 0, dir: i === 0 ? 1 : -1, progress: 0 }); } } private buildSweepTentacle(): THREE.Group { const sweepMat = new THREE.MeshToonMaterial({ color: 0xdd4455 }); const cupMat = new THREE.MeshToonMaterial({ color: 0xffb3a0 }); const cupGeom = new THREE.SphereGeometry(1, 10, 6); const group = new THREE.Group(); for (let j = 1; j <= 18; j++) { const r = Math.max(0.22, 0.42 - j * 0.012); const seg = new THREE.Mesh(new THREE.SphereGeometry(r, 12, 10), sweepMat); seg.position.set(j * 0.6, 0.3, 0); group.add(seg); // Saugnapf: flache Scheibe, abwechselnd links/rechts const side = j % 2 === 0 ? 1 : -1; const cup = new THREE.Mesh(cupGeom, cupMat); cup.scale.set(r * 0.5, r * 0.18, r * 0.5); cup.position.set(j * 0.6, 0.3, side * (r + 0.02)); group.add(cup); } return group; } playAnim() { // Prozedurale Optik } updateAnimation(dt: number) { this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); } private ensureEffects(scene: THREE.Scene) { if (this.effectsReady) return; for (const mark of this.pullMarks) scene.add(mark.group); scene.add(this.slamChain); for (const tent of this.sweepTents) scene.add(tent.group); this.effectsReady = true; } update(dt: number, game: Game) { this.game = game; this.ensureEffects(game.scene); this.waveTime += dt; // Hit-Feedback: kurz weiss aufblitzen + leichtes Pulsieren if (this.hitFlash > 0) { this.hitFlash -= dt; this.skinMat.color.set(0xffffff); this.darkMat.color.set(0xffffff); this.model.scale.setScalar(1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05); } else { this.skinMat.color.set(this.enraged ? 0x8a2233 : 0x8a4a9a); this.darkMat.color.set(this.enraged ? 0x551520 : 0x5a2a6a); this.model.scale.setScalar(1); } // Tentakel wippen for (let i = 0; i < this.tentacles.length; i++) { this.tentacles[i].rotation.z = Math.sin(this.waveTime * 2.2 + i * 0.9) * 0.25; } // Zum Spieler drehen const playerPos = game.player.body.position; const toPlayer = new THREE.Vector3().subVectors(playerPos, this.body.position); toPlayer.y = 0; if (toPlayer.length() > 0.1) { this.body.lookAt( this.body.position.x + toPlayer.x, this.body.position.y, this.body.position.z + toPlayer.z ); } // Enrage unter 50% HP if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) { this.enraged = true; // Phase 2: Krake wird dunkelrot this.skinMat.color.set(0x8a2233); this.darkMat.color.set(0x551520); playSound('damage', 0.8); } const inkCd = this.enraged ? INK_COOLDOWN * 0.6 : INK_COOLDOWN; const pullCd = this.enraged ? PULL_COOLDOWN * 0.65 : PULL_COOLDOWN; // Tinte spucken this.inkTimer -= dt; if (this.inkTimer <= 0) { this.inkTimer = inkCd; this.spitInk(game); } // Tentakel-Griff if (this.pullState === 'idle') { this.pullTimer -= dt; if (this.pullTimer <= 0) { this.pullTimer = pullCd; this.startPullCharge(game); } } else { this.updatePullCharge(dt, game); } if (this.slamState === 'grab') { // Tentakel bleibt am Spieler haengen, solange er gezogen wird if (game.player.pullTime > 0) { const playerPos = game.player.body.position; this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z); this.slamChain.lookAt(playerPos.x, 1, playerPos.z); const dist = Math.max(1, Math.hypot( playerPos.x - this.body.position.x, playerPos.z - this.body.position.z )); this.slamChain.scale.set(1, 1, Math.min(dist / 5, 2.5)); } else { // Zug vorbei -> Tentakel zieht sich zurueck this.slamRetractFrom = this.slamChain.scale.z; this.slamState = 'slam'; this.slamTime = SLAM_TIME; } } else if (this.slamState === 'slam') { this.updateSlam(dt); } // Tentakel-Sweep this.updateSweep(dt, game); // Tinten-Blobs aktualisieren for (let i = this.inkBlobs.length - 1; i >= 0; i--) { const blob = this.inkBlobs[i]; if (!blob.update(dt, game)) { this.inkBlobs.splice(i, 1); const puddle = new InkPuddle( blob.body.position, this.enraged ? INK_RADIUS + 0.5 : INK_RADIUS, INK_DURATION ); game.scene.add(puddle.body); this.puddles.push(puddle); blob.dispose(); playSound('explosion', 0.4); } } // Tintenpfützen aktualisieren for (let i = this.puddles.length - 1; i >= 0; i--) { const puddle = this.puddles[i]; if (!puddle.update(dt, game)) { this.puddles.splice(i, 1); puddle.dispose(); } } } private spitInk(game: Game) { const from = new THREE.Vector3(this.body.position.x, 3.0, this.body.position.z); const target = game.player.body.position.clone(); target.y = 0; if (this.enraged) { const t2 = target.clone().add( new THREE.Vector3((Math.random() - 0.5) * 8, 0, (Math.random() - 0.5) * 8) ); const blob2 = new InkBlob(from, t2); game.scene.add(blob2.body); this.inkBlobs.push(blob2); } const blob = new InkBlob(from, target); game.scene.add(blob.body); this.inkBlobs.push(blob); playSound('spawn', 0.5); } private startPullCharge(game: Game) { this.pullState = 'charge'; this.pullChargeTime = PULL_CHARGE_TIME; this.markOffsetAngle = Math.random() * Math.PI * 2; const cx = this.body.position.x; const cz = this.body.position.z; for (let i = 0; i < PULL_MARK_COUNT; i++) { const a = this.markOffsetAngle + (i / PULL_MARK_COUNT) * Math.PI * 2; const mark = this.pullMarks[i]; mark.group.position.set( cx + Math.cos(a) * PULL_MARK_DIST, 0.06, cz + Math.sin(a) * PULL_MARK_DIST ); mark.group.visible = true; } playSound('spawn', 0.8); } private updatePullCharge(dt: number, game: Game) { this.pullChargeTime -= dt; const pulse = Math.abs(Math.sin(this.waveTime * 9)); for (const mark of this.pullMarks) { mark.ringMat.opacity = 0.3 + 0.5 * pulse; mark.discMat.opacity = 0.1 + 0.08 * pulse; } if (this.pullChargeTime <= 0) { this.pullState = 'idle'; const playerPos = game.player.body.position; // Spieler in einer Marke? -> heranziehen let grabbed = false; for (const mark of this.pullMarks) { const dx = playerPos.x - mark.group.position.x; const dz = playerPos.z - mark.group.position.z; if (Math.hypot(dx, dz) < PULL_MARK_RADIUS) { grabbed = true; break; } } for (const mark of this.pullMarks) { mark.group.visible = false; mark.ringMat.opacity = 0; mark.discMat.opacity = 0; } if (grabbed && playerPos.y <= 0.8) { // Slam-Tentakel greift den Spieler und bleibt haengen this.slamChain.position.set(this.body.position.x, 3.2, this.body.position.z); this.slamChain.lookAt(playerPos.x, 1, playerPos.z); this.slamChain.scale.set(1, 1, 1); this.slamChain.visible = true; this.slamState = 'grab'; game.player.damage(PULL_DAMAGE, this.body.position); const dir = new THREE.Vector3().subVectors(this.body.position, playerPos); dir.y = 0; if (dir.lengthSq() < 0.01) dir.set(0, 0, 1); dir.normalize(); game.player.pullDir.copy(dir); game.player.pullTime = PULL_TIME; playSound('damage', 0.9); } } } private updateSlam(dt: number) { this.slamTime -= dt; const s = Math.max(0, this.slamTime / SLAM_TIME); this.slamChain.scale.set(1, 1, this.slamRetractFrom * s); if (this.slamTime <= 0) { this.slamChain.visible = false; this.slamState = 'none'; } } private updateSweep(dt: number, game: Game) { if (!this.sweepActive) { this.sweepTimer -= dt; const cd = this.enraged ? 3.2 : 4.5; if (this.sweepTimer <= 0) { this.sweepTimer = cd; this.sweepActive = true; this.sweepHit = false; const start = Math.random() * Math.PI * 2; for (let i = 0; i < this.sweepTents.length; i++) { const tent = this.sweepTents[i]; tent.angle = start + i * Math.PI; tent.progress = 0; tent.group.position.set(this.body.position.x, 0.06, this.body.position.z); // Tentakel liegt auf lokaler +X; rotation.y = -Winkel zeigt auf (cos, sin) tent.group.rotation.y = -tent.angle; tent.group.visible = i === 0 || this.enraged; } playSound('spawn', 0.6); } return; } const rotSpeed = ((Math.PI * 2) / (this.enraged ? 3.0 : SWEEP_DURATION)) * dt; for (const tent of this.sweepTents) { // Phase 2: zweiter Tentakel kreist gegensinnig if (tent.dir === -1 && !this.enraged) { tent.group.visible = false; continue; } tent.angle += tent.dir * rotSpeed; tent.progress += rotSpeed; tent.group.rotation.y = -tent.angle; if (!this.sweepHit) { const playerPos = game.player.body.position; const dx = playerPos.x - this.body.position.x; const dz = playerPos.z - this.body.position.z; const dist = Math.hypot(dx, dz); if (dist > 0.5 && dist < SWEEP_RADIUS + 0.5) { const dirX = Math.cos(tent.angle); const dirZ = Math.sin(tent.angle); // Abstand des Spielers zur Tentakel-Linie (radial vom Boss) const perp = Math.abs(dx * dirZ - dz * dirX); const along = dx * dirX + dz * dirZ; // Ueberspringbar: nur Treffer, solange der Spieler am Boden ist if (along > 0.3 && perp < SWEEP_HIT_WIDTH && playerPos.y <= 0.6) { this.sweepHit = true; game.player.damage(SWEEP_DAMAGE, this.body.position); playSound('damage', 0.8); } } } } if (this.sweepTents.every(t => t.progress >= Math.PI * 2)) { this.sweepActive = false; for (const tent of this.sweepTents) { tent.group.visible = false; } } } override takeDamage( damage: number, game: Game, withKnockback = true, sourcePos?: THREE.Vector3 ) { super.takeDamage(damage, game, withKnockback, sourcePos); this.hitFlash = HIT_FLASH_TIME; } protected onDeath(_game: Game) { this.cleanupEffects(); this.beginFadeDeath(); } override dispose() { super.dispose(); this.cleanupEffects(); } private cleanupEffects() { for (const blob of this.inkBlobs) { blob.dispose(); } this.inkBlobs = []; for (const puddle of this.puddles) { puddle.dispose(); } this.puddles = []; if (this.game) { for (const mark of this.pullMarks) { this.game.scene.remove(mark.group); } this.game.scene.remove(this.slamChain); for (const tent of this.sweepTents) { this.game.scene.remove(tent.group); } } for (const mark of this.pullMarks) { for (const child of mark.group.children) { if (child instanceof THREE.Mesh) { child.geometry.dispose(); (child.material as THREE.Material).dispose(); } } } this.pullMarks = []; for (const tent of this.sweepTents) { for (const child of tent.group.children) { if (child instanceof THREE.Mesh) { child.geometry.dispose(); (child.material as THREE.Material).dispose(); } } } for (const child of this.slamChain.children) { if (child instanceof THREE.Mesh) { child.geometry.dispose(); (child.material as THREE.Material).dispose(); } } } }