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 { Archer } from './Archer'; import { Shaman } from './Shaman'; import { Turtle } from './Turtle'; 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 }); const boneMat = new THREE.MeshToonMaterial({ color: 0xf0e6cf }); const faceMat = new THREE.MeshBasicMaterial({ color: 0x0a0614 }); let boneFadeRegistered = false; const registerBone = (mesh: THREE.Mesh) => { // Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt) if (!boneFadeRegistered) { this.registerFadeMesh(mesh); boneFadeRegistered = true; } }; // --- Robe (Blickrichtung +Z, schwebt: unten offen und zerfetzt) --- const robeProfile = [ new THREE.Vector2(0.75, -1.15), // Saum new THREE.Vector2(0.62, -0.75), new THREE.Vector2(0.45, -0.2), new THREE.Vector2(0.38, 0.25), new THREE.Vector2(0.5, 0.55), // Schulteransatz new THREE.Vector2(0.32, 0.78), // Hals ]; const robe = new THREE.Mesh(new THREE.LatheGeometry(robeProfile, 14), this.robeMat); robe.castShadow = true; this.registerFadeMesh(robe); this.model.add(robe); // Innenschatten im Saum (von unten sichtbar) const hemInner = new THREE.Mesh(new THREE.CircleGeometry(0.7, 14), this.darkMat); hemInner.rotation.x = Math.PI / 2; hemInner.position.y = -1.08; this.registerFadeMesh(hemInner); this.model.add(hemInner); // Zerfetzte Saumfetzen for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; const tatter = new THREE.Mesh( new THREE.ConeGeometry(0.08, 0.4, 4), i % 2 === 0 ? this.robeMat : this.darkMat ); tatter.rotation.x = Math.PI; tatter.position.set(Math.sin(a) * 0.68, -1.28, Math.cos(a) * 0.68); this.registerFadeMesh(tatter); this.model.add(tatter); } // Gürtelkordel + Quaste const belt = new THREE.Mesh(new THREE.TorusGeometry(0.42, 0.035, 6, 16), this.darkMat); belt.rotation.x = Math.PI / 2; belt.position.y = 0.12; this.registerFadeMesh(belt); this.model.add(belt); const tasselCord = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.28, 5), this.darkMat); tasselCord.position.set(-0.3, -0.05, 0.36); this.registerFadeMesh(tasselCord); this.model.add(tasselCord); const tassel = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.14, 5), boneMat); tassel.rotation.x = Math.PI; tassel.position.set(-0.3, -0.24, 0.38); registerBone(tassel); this.model.add(tassel); // 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); // Kragen const collar = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.045, 6, 14), this.darkMat); collar.rotation.x = Math.PI / 2 - 0.2; collar.position.set(0, 0.85, 0.08); this.registerFadeMesh(collar); this.model.add(collar); // Schulterpanzer mit Spitzen for (const side of [-1, 1]) { const pauldron = new THREE.Mesh(new THREE.SphereGeometry(0.26, 12, 8), this.darkMat); pauldron.scale.set(1.25, 0.65, 1.25); pauldron.position.set(side * 0.45, 0.78, 0); this.registerFadeMesh(pauldron); this.model.add(pauldron); const spike = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.22, 5), this.darkMat); spike.position.set(side * 0.55, 0.95, 0); spike.rotation.z = side * -0.5; this.registerFadeMesh(spike); this.model.add(spike); } // Kapuze mit Krempe und schwarzem Gesichts-Leerraum const hood = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.95, 12, 1, true), this.darkMat); hood.position.set(0, 1.18, 0.02); hood.rotation.x = 0.22; hood.castShadow = true; this.registerFadeMesh(hood); this.model.add(hood); const hoodRim = new THREE.Mesh(new THREE.TorusGeometry(0.34, 0.05, 8, 16), this.darkMat); hoodRim.rotation.x = -0.5; hoodRim.position.set(0, 1.05, 0.34); this.registerFadeMesh(hoodRim); this.model.add(hoodRim); const face = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), faceMat); face.scale.set(1, 1.1, 0.8); face.position.set(0, 1.04, 0.26); this.registerFadeMesh(face); this.model.add(face); // Leuchtende Augen in der Kapuze (+Z) for (const side of [-1, 1]) { const eye = new THREE.Mesh(new THREE.SphereGeometry(0.075, 8, 6), this.eyeMat); eye.position.set(side * 0.12, 1.08, 0.48); this.model.add(eye); } // Amulett + Runen auf der Robe (glühen, färben sich im Enrage) const amulet = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), this.glowMat); amulet.position.set(0, 0.6, 0.5); this.model.add(amulet); const runeSpots: [number, number, number][] = [ [0, -0.15, 0.5], [-0.2, -0.5, 0.5], [0.2, -0.5, 0.5], ]; for (const [x, y, z] of runeSpots) { const rune = new THREE.Mesh(new THREE.OctahedronGeometry(0.06), this.glowMat); rune.scale.set(1, 1.7, 0.5); rune.position.set(x, y, z); this.model.add(rune); } // Rechter Arm: Ärmel zum Stab const sleeveR = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.16, 0.55, 8), this.robeMat); sleeveR.position.set(0.65, 0.84, 0.14); sleeveR.rotation.z = 1.2; this.registerFadeMesh(sleeveR); this.model.add(sleeveR); const handR = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat); handR.position.set(0.85, 0.9, 0.23); registerBone(handR); this.model.add(handR); // Linker Arm: Ärmel nach vorn (Beschwörungs-Geste) + Energiekugel const sleeveL = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.18, 0.6, 8), this.robeMat); sleeveL.position.set(-0.55, 0.75, 0.32); sleeveL.rotation.x = -1.25; sleeveL.rotation.z = -0.35; this.registerFadeMesh(sleeveL); this.model.add(sleeveL); const handL = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat); handL.position.set(-0.68, 0.72, 0.62); registerBone(handL); this.model.add(handL); const castOrb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.glowMat); castOrb.position.set(-0.7, 0.92, 0.68); this.model.add(castOrb); // Stab mit Manschetten, Totenkopf und schwebendem Orb const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 2.4, 8), this.darkMat); staff.position.set(0.8, 1.2, 0.28); staff.rotation.z = 0.18; staff.rotation.x = 0.15; this.registerFadeMesh(staff); this.model.add(staff); for (const [x, y, z] of [[0.88, 0.76, 0.21], [0.96, 0.31, 0.15]] as const) { const collarM = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.07, 8), this.darkMat); collarM.position.set(x, y, z); this.registerFadeMesh(collarM); this.model.add(collarM); } const skull = new THREE.Mesh(new THREE.SphereGeometry(0.15, 10, 8), boneMat); skull.position.set(0.58, 2.42, 0.47); registerBone(skull); this.model.add(skull); for (const side of [-1, 1]) { const socket = new THREE.Mesh(new THREE.SphereGeometry(0.03, 6, 4), this.darkMat); socket.position.set(0.58 + side * 0.05, 2.44, 0.6); this.model.add(socket); } 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.14, 10, 8), orbMat); orb.position.set(0.58, 2.66, 0.47); 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(), () => new Archer(), () => new Shaman(), () => new Turtle()]; 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); } }