525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
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 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;
|
|
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|