Add six new enemies and spawn enemies within view
This commit is contained in:
@@ -36,7 +36,7 @@ npm run dev # Dev-Server mit HMR
|
|||||||
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
|
| `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` |
|
||||||
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` / `SpiderQueen.ts` / `FrostGiant.ts` / `Necromancer.ts` / `Dragon.ts` | Bosse (Welle 10 / 20 / 30 / 40 / 50 / 60 / 70), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Spinnenkönigin: stationär, Netze (`WebPatch.ts`: fesselt via `player.rootTime`), Spinngeschwister (Mini-Spiders), Säuresalven. Frostriese: Eispanzer (70% Schadensreduktion, `armorHp`, bricht + Stagger, regeneriert nach Timer), Eisberst-Ring (nacheinander explodierende Marken), Eiszapfen-Regen, Stampf (überspringbar). Erz-Nekromant: schwebend, Teleport mit Glow-Ring-Telegraf + Ankunftssalve, Beschwörungen mit wachsendem Boden-Glow. Drache: fliegt und ist nur im Lande-Zustand verletzbar (`takeDamage`-Override blockt im Flug), Sturzangriff mit wachsendem Schatten-Telegraf, Feuerspeer (Telegraf-Bogen, überspringbar), Feuerbälle im Flug, Enrage mit Doppel-Sturz. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
|
| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` / `SpiderQueen.ts` / `FrostGiant.ts` / `Necromancer.ts` / `Dragon.ts` | Bosse (Welle 10 / 20 / 30 / 40 / 50 / 60 / 70), registriert in `Game.bossWaves` (`{name, spawn}`). Krake: stationär (`spawnAtCenter`), Tinte (`InkBlob.ts`/`InkPuddle.ts`: Slow + DoT), Tentakel-Griff (5 rote Boden-Marken um den Oktopus → Pull via `player.pullTime`), Sweep (roter Tentakel kreist um den Oktopus, überspringbar), Enrage <30% HP. Spinnenkönigin: stationär, Netze (`WebPatch.ts`: fesselt via `player.rootTime`), Spinngeschwister (Mini-Spiders), Säuresalven. Frostriese: Eispanzer (70% Schadensreduktion, `armorHp`, bricht + Stagger, regeneriert nach Timer), Eisberst-Ring (nacheinander explodierende Marken), Eiszapfen-Regen, Stampf (überspringbar). Erz-Nekromant: schwebend, Teleport mit Glow-Ring-Telegraf + Ankunftssalve, Beschwörungen mit wachsendem Boden-Glow. Drache: fliegt und ist nur im Lande-Zustand verletzbar (`takeDamage`-Override blockt im Flug), Sturzangriff mit wachsendem Schatten-Telegraf, Feuerspeer (Telegraf-Bogen, überspringbar), Feuerbälle im Flug, Enrage mit Doppel-Sturz. Minotaurus: nur von hinten verletzbar (vorn „BLOCK"), Turn-Lag, Dash → Wand-Stun (Sterne) + Wandrisse, Axt-Schwung (überspringbar), Sprung+Stampf (Stun `player.stunTime` + `FallingRock.ts`), Enrage <40% (Doppel-Dash, Kamera-Shake) |
|
||||||
| `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf |
|
| `InkBlob.ts` / `InkPuddle.ts` / `FallingRock.ts` | Boss-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()`; fallender Fels mit Schatten-Telegraf |
|
||||||
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret) | Prozedurale Gegner-Modelle; je `update(dt, game)` |
|
| `*Enemy*.ts` (Spider, Ghost, Frog, Bat, BeeSwarm, Slime, Brute, Crab, Turret, Shaman, Archer, Bomber, Wisp, Turtle, SporeMushroom) | Prozedurale Gegner-Modelle; je `update(dt, game)`. Schamane heilt Gegner im Radius (Prioritätsziel), Bogenschütze hält Distanz + Pfeil mit Boden-Telegraf, Bomber explodiert bei Nähe (AoE), Irrlicht schießt Homing-Sporen (Slow), Schildkröte blockt vorn komplett + Panzer-Rückzug (unverwundbar), Sporen-Pilz wirft Bogen-Sporen mit DoT-Wolke |
|
||||||
| `SoundManager.ts` | Vorab geladene .ogg-Buffer (`playSound(name, volume)`); Sounds: damage, spawn, fireball, explosion, teleport, gameover, sword_hit1, spiderwalking |
|
| `SoundManager.ts` | Vorab geladene .ogg-Buffer (`playSound(name, volume)`); Sounds: damage, spawn, fireball, explosion, teleport, gameover, sword_hit1, spiderwalking |
|
||||||
| `MeshLoader.ts` | `loadGLB(name)` → `web/public/models/*.glb`; Texturen in `web/public/textures/` |
|
| `MeshLoader.ts` | `loadGLB(name)` → `web/public/models/*.glb`; Texturen in `web/public/textures/` |
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,7 @@
|
|||||||
<div id="changelog-panel">
|
<div id="changelog-panel">
|
||||||
<h2>Changelog</h2>
|
<h2>Changelog</h2>
|
||||||
<div id="changelog-list">
|
<div id="changelog-list">
|
||||||
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Sechs neue Gegner: Schamane (heilt), Bogenschütze (Telegraf-Pfeile), Bomber (Explosion), Irrlicht (Slow-Sporen), Schildkröte (Frontpanzer + Rückzug) und Sporen-Pilz (Bogen-Sporen mit DoT-Wolke)</span></div>
|
||||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer</span></div>
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer</span></div>
|
||||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen</span></div>
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen</span></div>
|
||||||
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Frostriese (Welle 50): Eispanzer-Rüstung, Eisberst-Ringe und Eiszapfen-Regen</span></div>
|
<div class="changelog-entry"><span class="changelog-date">20.08.</span><span>Frostriese (Welle 50): Eispanzer-Rüstung, Eisberst-Ringe und Eiszapfen-Regen</span></div>
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const IDEAL_MIN = 8;
|
||||||
|
const IDEAL_MAX = 13;
|
||||||
|
const SHOOT_RANGE = 18;
|
||||||
|
const SHOOT_COOLDOWN = 3;
|
||||||
|
const TELEGRAPH_TIME = 0.6;
|
||||||
|
const ARROW_DAMAGE = 18;
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
|
||||||
|
// Bogenschütze: hält Distanz und schießt Pfeile mit Boden-Telegraf (ausweichbar)
|
||||||
|
export class Archer extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private boneMat!: THREE.MeshToonMaterial;
|
||||||
|
private darkMat!: THREE.MeshToonMaterial;
|
||||||
|
private bowGroup!: THREE.Group;
|
||||||
|
private shootTimer = 1 + Math.random();
|
||||||
|
private telegraphs: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; timer: number }[] = [];
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private hitFlash = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 55;
|
||||||
|
this.speed = 4.2;
|
||||||
|
this.xp = 22;
|
||||||
|
this.contactDps = 15;
|
||||||
|
this.contactRadius = 1.5;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
this.boneMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
|
||||||
|
this.darkMat = new THREE.MeshToonMaterial({ color: 0x8a7a5a });
|
||||||
|
|
||||||
|
// Torso
|
||||||
|
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.55, 0.35), this.boneMat);
|
||||||
|
torso.position.y = 1.0;
|
||||||
|
torso.castShadow = true;
|
||||||
|
this.registerFadeMesh(torso);
|
||||||
|
this.model.add(torso);
|
||||||
|
|
||||||
|
// Beine
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.7, 6), this.boneMat);
|
||||||
|
leg.position.set(side * 0.15, 0.35, 0);
|
||||||
|
this.registerFadeMesh(leg);
|
||||||
|
this.model.add(leg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopf (Schädel)
|
||||||
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 10, 8), this.boneMat);
|
||||||
|
head.position.y = 1.5;
|
||||||
|
this.registerFadeMesh(head);
|
||||||
|
this.model.add(head);
|
||||||
|
|
||||||
|
// Augen (+Z, rot)
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff4422 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), eyeMat);
|
||||||
|
eye.position.set(side * 0.1, 1.53, 0.2);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bogen (hängt an Gruppe, wird beim Schuss angehoben)
|
||||||
|
this.bowGroup = new THREE.Group();
|
||||||
|
this.bowGroup.position.set(0.4, 1.05, 0.15);
|
||||||
|
this.model.add(this.bowGroup);
|
||||||
|
const bow = new THREE.Mesh(new THREE.TorusGeometry(0.28, 0.03, 6, 14, Math.PI * 1.6), this.darkMat);
|
||||||
|
bow.rotation.y = -Math.PI / 2;
|
||||||
|
bow.rotation.z = -0.8;
|
||||||
|
this.registerFadeMesh(bow);
|
||||||
|
this.bowGroup.add(bow);
|
||||||
|
const string = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.5, 0.02), this.darkMat);
|
||||||
|
string.position.set(0.02, 0, 0);
|
||||||
|
this.registerFadeMesh(string);
|
||||||
|
this.bowGroup.add(string);
|
||||||
|
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 0.5, 6), this.boneMat);
|
||||||
|
arm.position.set(0.02, -0.1, 0);
|
||||||
|
this.registerFadeMesh(arm);
|
||||||
|
this.bowGroup.add(arm);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.9, sink: 0.4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
this.game = game;
|
||||||
|
|
||||||
|
// Hit-Feedback
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
this.boneMat.color.set(0xffffff);
|
||||||
|
this.darkMat.color.set(0xffffff);
|
||||||
|
} else {
|
||||||
|
this.boneMat.color.set(0xe8dcc0);
|
||||||
|
this.darkMat.color.set(0x8a7a5a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, 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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Distanz halten: weg, wenn zu nah; hin, wenn zu weit; sonst seitlich
|
||||||
|
let move = new THREE.Vector3();
|
||||||
|
if (dist < IDEAL_MIN) {
|
||||||
|
move.copy(dir).multiplyScalar(-1);
|
||||||
|
} else if (dist > IDEAL_MAX) {
|
||||||
|
move.copy(dir);
|
||||||
|
} else {
|
||||||
|
move.set(-dir.z, 0, dir.x).multiplyScalar(Math.sin(this.time * 2) * 0.6);
|
||||||
|
}
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
move = dir.clone().multiplyScalar(-1);
|
||||||
|
this.knockback -= dt;
|
||||||
|
}
|
||||||
|
if (move.lengthSq() > 0) {
|
||||||
|
move.normalize().multiplyScalar(this.speed);
|
||||||
|
this.body.position.x += move.x * dt;
|
||||||
|
this.body.position.z += move.z * dt;
|
||||||
|
game.clampToArena(this.body.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schuss mit Telegraf
|
||||||
|
this.shootTimer -= dt;
|
||||||
|
if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) {
|
||||||
|
this.shootTimer = SHOOT_COOLDOWN + Math.random() * 0.5;
|
||||||
|
this.startShot(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegrafen ablaufen lassen -> Pfeil los
|
||||||
|
for (let i = this.telegraphs.length - 1; i >= 0; i--) {
|
||||||
|
const t = this.telegraphs[i];
|
||||||
|
t.timer -= dt;
|
||||||
|
if (t.timer <= 0) {
|
||||||
|
this.fireArrow(game, t.mesh.position);
|
||||||
|
game.scene.remove(t.mesh);
|
||||||
|
t.mesh.geometry.dispose();
|
||||||
|
t.mat.dispose();
|
||||||
|
this.telegraphs.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
t.mat.opacity = 0.25 + 0.3 * Math.abs(Math.sin(this.time * 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bogen-Schwung
|
||||||
|
this.bowGroup.rotation.z = Math.sin(this.time * 2) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startShot(game: Game) {
|
||||||
|
// Roter Telegraf an der aktuellen Spielerposition (Zielpunkt des Pfeils)
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xff3344,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const ring = new THREE.Mesh(new THREE.RingGeometry(0.5, 0.8, 24), mat);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
const pos = game.player.body.position;
|
||||||
|
ring.position.set(pos.x, 0.06, pos.z);
|
||||||
|
game.scene.add(ring);
|
||||||
|
this.telegraphs.push({ mesh: ring, mat, timer: TELEGRAPH_TIME });
|
||||||
|
playSound('spawn', 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fireArrow(game: Game, targetPos: THREE.Vector3) {
|
||||||
|
if (game.player.health <= 0) return;
|
||||||
|
const from = this.body.position.clone();
|
||||||
|
from.y += 1.2;
|
||||||
|
const target = targetPos.clone();
|
||||||
|
target.y += 0.6;
|
||||||
|
game.spawnEnemyProjectile(from, target, ARROW_DAMAGE);
|
||||||
|
// Bogen anheben als Recoil
|
||||||
|
this.bowGroup.rotation.z = -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.cleanupTelegraphs(game);
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose() {
|
||||||
|
super.dispose();
|
||||||
|
if (this.game) this.cleanupTelegraphs(this.game);
|
||||||
|
}
|
||||||
|
|
||||||
|
private game: Game | null = null;
|
||||||
|
|
||||||
|
private cleanupTelegraphs(game: Game) {
|
||||||
|
for (const t of this.telegraphs) {
|
||||||
|
game.scene.remove(t.mesh);
|
||||||
|
t.mesh.geometry.dispose();
|
||||||
|
t.mat.dispose();
|
||||||
|
}
|
||||||
|
this.telegraphs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const EXPLODE_DIST = 2.2;
|
||||||
|
const EXPLODE_RADIUS = 4.5;
|
||||||
|
const EXPLODE_DAMAGE = 30;
|
||||||
|
const FLASH_TIME = 0.35;
|
||||||
|
|
||||||
|
// Goblin-Bomber: rast auf den Spieler zu und explodiert in seiner Nähe
|
||||||
|
export class Bomber extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private skinMat!: THREE.MeshToonMaterial;
|
||||||
|
private clothMat!: THREE.MeshToonMaterial;
|
||||||
|
private fuseMat!: THREE.MeshBasicMaterial;
|
||||||
|
private fuse!: THREE.Mesh;
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private exploding = false;
|
||||||
|
private flashTime = 0;
|
||||||
|
private flashRing!: THREE.Mesh;
|
||||||
|
private flashRingMat!: THREE.MeshBasicMaterial;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 45;
|
||||||
|
this.speed = 6.5;
|
||||||
|
this.xp = 18;
|
||||||
|
this.contactDps = 0;
|
||||||
|
this.contactRadius = 0;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
this.skinMat = new THREE.MeshToonMaterial({ color: 0x55aa44 });
|
||||||
|
this.clothMat = new THREE.MeshToonMaterial({ color: 0xaa4433 });
|
||||||
|
this.fuseMat = new THREE.MeshBasicMaterial({ color: 0xffaa33 });
|
||||||
|
|
||||||
|
// Körper
|
||||||
|
const body = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 10), this.skinMat);
|
||||||
|
body.position.y = 0.65;
|
||||||
|
body.scale.set(1, 1.1, 1);
|
||||||
|
body.castShadow = true;
|
||||||
|
this.registerFadeMesh(body);
|
||||||
|
this.model.add(body);
|
||||||
|
|
||||||
|
// Stoff-Höschen
|
||||||
|
const cloth = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.35, 8, 1, true), this.clothMat);
|
||||||
|
cloth.position.y = 0.25;
|
||||||
|
cloth.rotation.x = Math.PI;
|
||||||
|
this.registerFadeMesh(cloth);
|
||||||
|
this.model.add(cloth);
|
||||||
|
|
||||||
|
// Kopf
|
||||||
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), this.skinMat);
|
||||||
|
head.position.y = 1.1;
|
||||||
|
head.castShadow = true;
|
||||||
|
this.registerFadeMesh(head);
|
||||||
|
this.model.add(head);
|
||||||
|
|
||||||
|
// Spitze Ohren
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.3, 6), this.skinMat);
|
||||||
|
ear.position.set(side * 0.22, 1.32, 0.05);
|
||||||
|
ear.rotation.z = -side * 0.4;
|
||||||
|
this.registerFadeMesh(ear);
|
||||||
|
this.model.add(ear);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Augen (+Z)
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.06, 6, 5), eyeMat);
|
||||||
|
eye.position.set(side * 0.1, 1.15, 0.26);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beine
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.4, 6), this.skinMat);
|
||||||
|
leg.position.set(side * 0.15, 0.2, 0);
|
||||||
|
this.registerFadeMesh(leg);
|
||||||
|
this.model.add(leg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zündschnur mit Glut
|
||||||
|
const fuseGeom = new THREE.CylinderGeometry(0.03, 0.03, 0.3, 6);
|
||||||
|
this.fuse = new THREE.Mesh(fuseGeom, this.clothMat);
|
||||||
|
this.fuse.position.set(0, 1.45, 0);
|
||||||
|
this.fuse.rotation.z = 0.5;
|
||||||
|
this.registerFadeMesh(this.fuse);
|
||||||
|
this.model.add(this.fuse);
|
||||||
|
const spark = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 5), this.fuseMat);
|
||||||
|
spark.position.set(0.1, 1.6, 0);
|
||||||
|
this.model.add(spark);
|
||||||
|
|
||||||
|
// Explosions-Ring (Kind des Body, wird in updateAnimation animiert)
|
||||||
|
this.flashRingMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xff8833,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
this.flashRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.1, 32), this.flashRingMat);
|
||||||
|
this.flashRing.rotation.x = -Math.PI / 2;
|
||||||
|
this.flashRing.position.y = 0.07;
|
||||||
|
this.flashRing.visible = false;
|
||||||
|
this.body.add(this.flashRing);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.5, shrink: 0.8 });
|
||||||
|
if (this.exploding && this.flashTime > 0) {
|
||||||
|
this.flashTime -= dt;
|
||||||
|
const t = 1 - Math.max(0, this.flashTime) / FLASH_TIME;
|
||||||
|
this.flashRing.visible = true;
|
||||||
|
this.flashRing.scale.setScalar(0.5 + t * 8);
|
||||||
|
this.flashRingMat.opacity = 0.8 * (1 - t);
|
||||||
|
if (this.flashTime <= 0) {
|
||||||
|
this.flashRing.visible = false;
|
||||||
|
this.flashRingMat.opacity = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
|
||||||
|
// Lauf-Animation: Wippen + Beine
|
||||||
|
this.body.position.y = 0.12 + Math.abs(Math.sin(this.time * 12)) * 0.06;
|
||||||
|
|
||||||
|
// Zündschnur glüht schneller, je näher der Gegner ist
|
||||||
|
const distToPlayer = Math.hypot(
|
||||||
|
game.player.body.position.x - this.body.position.x,
|
||||||
|
game.player.body.position.z - this.body.position.z
|
||||||
|
);
|
||||||
|
const fusePulse = Math.min(1, 1 - distToPlayer / 12);
|
||||||
|
this.fuseMat.color.setHSL(0.08, 1, 0.4 + fusePulse * 0.5);
|
||||||
|
this.fuse.scale.y = 0.8 + Math.abs(Math.sin(this.time * (4 + fusePulse * 16))) * 0.5;
|
||||||
|
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, 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(this.speed);
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
moveDir = dir.multiplyScalar(-this.speed);
|
||||||
|
this.knockback -= dt;
|
||||||
|
}
|
||||||
|
this.body.position.x += moveDir.x * dt;
|
||||||
|
this.body.position.z += moveDir.z * dt;
|
||||||
|
game.clampToArena(this.body.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explosion bei Annäherung
|
||||||
|
if (dist <= EXPLODE_DIST && game.player.health > 0 && !this.dead) {
|
||||||
|
this.explode(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private explode(game: Game) {
|
||||||
|
this.exploding = true;
|
||||||
|
this.flashTime = FLASH_TIME;
|
||||||
|
this.flashRing.scale.setScalar(0.5);
|
||||||
|
this.flashRing.visible = true;
|
||||||
|
this.flashRing.position.set(this.body.position.x, 0.07, this.body.position.z);
|
||||||
|
this.fuseMat.color.set(0xffffff);
|
||||||
|
playSound('explosion', 0.8);
|
||||||
|
game.triggerCameraShake(0.3, 0.35);
|
||||||
|
|
||||||
|
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) < EXPLODE_RADIUS && game.player.body.position.y <= 0.8) {
|
||||||
|
game.player.damage(EXPLODE_DAMAGE, this.body.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.takeDamage(99999, game, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
if (!this.exploding) {
|
||||||
|
playSound('damage', 0.5);
|
||||||
|
}
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose() {
|
||||||
|
super.dispose();
|
||||||
|
this.flashRing.geometry.dispose();
|
||||||
|
this.flashRingMat.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-11
@@ -17,6 +17,12 @@ import { Necromancer } from './Necromancer';
|
|||||||
import { Dragon } from './Dragon';
|
import { Dragon } from './Dragon';
|
||||||
import { Crab } from './Crab';
|
import { Crab } from './Crab';
|
||||||
import { Turret } from './Turret';
|
import { Turret } from './Turret';
|
||||||
|
import { Shaman } from './Shaman';
|
||||||
|
import { Archer } from './Archer';
|
||||||
|
import { Bomber } from './Bomber';
|
||||||
|
import { Wisp } from './Wisp';
|
||||||
|
import { Turtle } from './Turtle';
|
||||||
|
import { SporeMushroom } from './SporeMushroom';
|
||||||
import { EnemyProjectile } from './EnemyProjectile';
|
import { EnemyProjectile } from './EnemyProjectile';
|
||||||
import { Sword } from './Sword';
|
import { Sword } from './Sword';
|
||||||
import { Staff } from './Staff';
|
import { Staff } from './Staff';
|
||||||
@@ -82,6 +88,13 @@ export class Game {
|
|||||||
spawns = 0;
|
spawns = 0;
|
||||||
killed = 0;
|
killed = 0;
|
||||||
|
|
||||||
|
// Stationäre Gegner, die zu lange außerhalb des Bildschirms sind, werden
|
||||||
|
// wieder in die Nähe des Spielers teleportiert (Off-Screen-Reset).
|
||||||
|
private offscreenTimers = new Map<Enemy, number>();
|
||||||
|
private static readonly OFFSCREEN_REPOSITION_TIME = 8;
|
||||||
|
private static readonly SPAWN_VIEW_MIN = 10;
|
||||||
|
private static readonly SPAWN_VIEW_MAX = 24;
|
||||||
|
|
||||||
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
|
// Boss-Wellen: Welle -> Boss (Name fuer Boss-Modus-UI, Factory fuer Spawn)
|
||||||
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
private bossWaves = new Map<number, { name: string; spawn: () => Enemy }>([
|
||||||
[10, { name: 'Golem', spawn: () => new Golem() }],
|
[10, { name: 'Golem', spawn: () => new Golem() }],
|
||||||
@@ -765,6 +778,7 @@ export class Game {
|
|||||||
this.spawns = 0;
|
this.spawns = 0;
|
||||||
this.killed = 0;
|
this.killed = 0;
|
||||||
this.bossFightActive = false;
|
this.bossFightActive = false;
|
||||||
|
this.offscreenTimers.clear();
|
||||||
this.shakeTime = 0;
|
this.shakeTime = 0;
|
||||||
this.pendingPicks = 0;
|
this.pendingPicks = 0;
|
||||||
this.hideLevelUp();
|
this.hideLevelUp();
|
||||||
@@ -931,8 +945,13 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private spawnEnemy() {
|
private spawnEnemy() {
|
||||||
const x = (Math.random() - 0.5) * 80;
|
// Immer im Sichtbereich spawnen (Ring um den Spieler), damit auch
|
||||||
const z = (Math.random() - 0.5) * 80;
|
// stationäre Gegner nicht auf der Arena gesucht werden müssen.
|
||||||
|
const p = this.player.body.position;
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
|
||||||
|
const x = Math.max(-46, Math.min(46, p.x + Math.cos(a) * r));
|
||||||
|
const z = Math.max(-46, Math.min(46, p.z + Math.sin(a) * r));
|
||||||
|
|
||||||
const weights = this.enemyWeights(this.spawns);
|
const weights = this.enemyWeights(this.spawns);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -957,22 +976,33 @@ export class Game {
|
|||||||
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
|
case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break;
|
||||||
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
|
case 'crab': this.spawnEnemyInstance(new Crab(), x, z, 0.1); break;
|
||||||
case 'turret': this.spawnEnemyInstance(new Turret(), x, z, 0.1); break;
|
case 'turret': this.spawnEnemyInstance(new Turret(), x, z, 0.1); break;
|
||||||
|
case 'shaman': this.spawnEnemyInstance(new Shaman(), x, z, 0.1); break;
|
||||||
|
case 'archer': this.spawnEnemyInstance(new Archer(), x, z, 0.1); break;
|
||||||
|
case 'bomber': this.spawnEnemyInstance(new Bomber(), x, z, 0.1); break;
|
||||||
|
case 'wisp': this.spawnEnemyInstance(new Wisp(), x, z, 1.0); break;
|
||||||
|
case 'turtle': this.spawnEnemyInstance(new Turtle(), x, z, 0.1); break;
|
||||||
|
case 'spore': this.spawnEnemyInstance(new SporeMushroom(), x, z, 0.1); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enemyWeights(wave: number): Map<string, number> {
|
private enemyWeights(wave: number): Map<string, number> {
|
||||||
const w = new Map<string, number>();
|
const w = new Map<string, number>();
|
||||||
if (wave >= 7) {
|
if (wave >= 7) {
|
||||||
w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10)
|
w.set('spider', 20).set('ghost', 12).set('frog', 12).set('slime', 8)
|
||||||
.set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5);
|
.set('bat', 8).set('turret', 8).set('crab', 8).set('bee', 5)
|
||||||
|
.set('archer', 6).set('bomber', 6).set('shaman', 4).set('wisp', 4)
|
||||||
|
.set('turtle', 4).set('spore', 5);
|
||||||
} else if (wave >= 6) {
|
} else if (wave >= 6) {
|
||||||
w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10)
|
w.set('spider', 28).set('ghost', 16).set('frog', 12).set('slime', 8)
|
||||||
.set('bat', 10).set('turret', 10);
|
.set('bat', 8).set('turret', 8)
|
||||||
|
.set('archer', 7).set('bomber', 7).set('wisp', 4).set('spore', 5);
|
||||||
} else if (wave >= 5) {
|
} else if (wave >= 5) {
|
||||||
w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10)
|
w.set('spider', 35).set('ghost', 16).set('frog', 12).set('slime', 8)
|
||||||
.set('bat', 10);
|
.set('bat', 8)
|
||||||
|
.set('archer', 7).set('bomber', 7).set('spore', 5);
|
||||||
} else if (wave >= 4) {
|
} else if (wave >= 4) {
|
||||||
w.set('spider', 55).set('ghost', 25).set('frog', 20);
|
w.set('spider', 45).set('ghost', 20).set('frog', 16)
|
||||||
|
.set('archer', 6).set('bomber', 6);
|
||||||
} else if (wave >= 3) {
|
} else if (wave >= 3) {
|
||||||
w.set('spider', 75).set('ghost', 25);
|
w.set('spider', 75).set('ghost', 25);
|
||||||
} else {
|
} else {
|
||||||
@@ -989,8 +1019,18 @@ export class Game {
|
|||||||
const entry = this.bossWaves.get(wave);
|
const entry = this.bossWaves.get(wave);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const enemy = entry.spawn();
|
const enemy = entry.spawn();
|
||||||
const x = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
let x = 0;
|
||||||
const z = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80;
|
let z = 0;
|
||||||
|
if (enemy.spawnAtCenter) {
|
||||||
|
// Stationäre Bosse (Krake, Spinnenkönigin) spawnen in der Arena-Mitte
|
||||||
|
} else {
|
||||||
|
// Alle anderen Bosse im Sichtbereich spawnen
|
||||||
|
const p = this.player.body.position;
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
|
||||||
|
x = Math.max(-46, Math.min(46, p.x + Math.cos(a) * r));
|
||||||
|
z = Math.max(-46, Math.min(46, p.z + Math.sin(a) * r));
|
||||||
|
}
|
||||||
this.spawnEnemyInstance(enemy, x, z, 0.15);
|
this.spawnEnemyInstance(enemy, x, z, 0.15);
|
||||||
playSound('spawn', 0.8);
|
playSound('spawn', 0.8);
|
||||||
}
|
}
|
||||||
@@ -1123,6 +1163,47 @@ export class Game {
|
|||||||
pos.z = Math.max(-47, Math.min(47, pos.z));
|
pos.z = Math.max(-47, Math.min(47, pos.z));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stationäre Gegner (Turret, Sporen-Pilz, Krake, Spinnenkönigin), die zu
|
||||||
|
// lange außerhalb des Bildschirms sind, werden wieder in Sicht teleportiert.
|
||||||
|
private updateOffscreenReposition(dt: number) {
|
||||||
|
for (const enemy of this.enemies) {
|
||||||
|
if (enemy.dead) {
|
||||||
|
this.offscreenTimers.delete(enemy);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!enemy.immovable) continue;
|
||||||
|
if (this.isOnScreen(enemy.body.position)) {
|
||||||
|
this.offscreenTimers.delete(enemy);
|
||||||
|
} else {
|
||||||
|
const t = (this.offscreenTimers.get(enemy) ?? 0) + dt;
|
||||||
|
if (t >= Game.OFFSCREEN_REPOSITION_TIME) {
|
||||||
|
this.offscreenTimers.delete(enemy);
|
||||||
|
this.repositionToView(enemy);
|
||||||
|
} else {
|
||||||
|
this.offscreenTimers.set(enemy, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NDC-Projektion mit Toleranz: |x|,|y| < 1.15 gilt als im Bild
|
||||||
|
private isOnScreen(pos: THREE.Vector3): boolean {
|
||||||
|
const v = pos.clone().project(this.camera);
|
||||||
|
return v.x > -1.15 && v.x < 1.15 && v.y > -1.15 && v.y < 1.15 && v.z < 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private repositionToView(enemy: Enemy) {
|
||||||
|
const p = this.player.body.position;
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const r = Game.SPAWN_VIEW_MIN + Math.random() * (Game.SPAWN_VIEW_MAX - Game.SPAWN_VIEW_MIN);
|
||||||
|
enemy.body.position.set(
|
||||||
|
Math.max(-46, Math.min(46, p.x + Math.cos(a) * r)),
|
||||||
|
enemy.body.position.y,
|
||||||
|
Math.max(-46, Math.min(46, p.z + Math.sin(a) * r))
|
||||||
|
);
|
||||||
|
playSound('spawn', 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
private updateEnemies(dt: number) {
|
private updateEnemies(dt: number) {
|
||||||
for (const enemy of this.enemies) {
|
for (const enemy of this.enemies) {
|
||||||
if (enemy.dead || this.state !== 'playing') {
|
if (enemy.dead || this.state !== 'playing') {
|
||||||
@@ -1369,6 +1450,7 @@ export class Game {
|
|||||||
this.updateGroundPoint();
|
this.updateGroundPoint();
|
||||||
this.updatePlayerMovement(dt);
|
this.updatePlayerMovement(dt);
|
||||||
this.updateEnemies(dt);
|
this.updateEnemies(dt);
|
||||||
|
this.updateOffscreenReposition(dt);
|
||||||
this.updateFireballs(dt);
|
this.updateFireballs(dt);
|
||||||
this.updateHitzones();
|
this.updateHitzones();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Enemy } from './Enemy';
|
|||||||
import { Ghost } from './Ghost';
|
import { Ghost } from './Ghost';
|
||||||
import { Slime } from './Slime';
|
import { Slime } from './Slime';
|
||||||
import { Spider } from './Spider';
|
import { Spider } from './Spider';
|
||||||
|
import { Archer } from './Archer';
|
||||||
|
import { Shaman } from './Shaman';
|
||||||
|
import { Turtle } from './Turtle';
|
||||||
import { playSound } from './SoundManager';
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
const MAX_HEALTH = 13000;
|
const MAX_HEALTH = 13000;
|
||||||
@@ -430,7 +433,8 @@ export class Necromancer extends Enemy {
|
|||||||
|
|
||||||
private startSummon(game: Game) {
|
private startSummon(game: Game) {
|
||||||
const count = this.enraged ? SUMMON_COUNT_2 : SUMMON_COUNT;
|
const count = this.enraged ? SUMMON_COUNT_2 : SUMMON_COUNT;
|
||||||
const types = [() => new Ghost(), () => new Slime(), () => new Spider()];
|
const types = [() => new Ghost(), () => new Slime(), () => new Spider(),
|
||||||
|
() => new Archer(), () => new Shaman(), () => new Turtle()];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const a = Math.random() * Math.PI * 2;
|
const a = Math.random() * Math.PI * 2;
|
||||||
const r = 5 + Math.random() * 8;
|
const r = 5 + Math.random() * 8;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const HEAL_RADIUS = 8;
|
||||||
|
const HEAL_TICK = 0.9;
|
||||||
|
const HEAL_AMOUNT = 6;
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
|
||||||
|
// Schamane: heilt andere Gegner im Radius -> Prioritätsziel
|
||||||
|
export class Shaman extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private robeMat!: THREE.MeshToonMaterial;
|
||||||
|
private darkMat!: THREE.MeshToonMaterial;
|
||||||
|
private healRing!: THREE.Mesh;
|
||||||
|
private healRingMat!: THREE.MeshBasicMaterial;
|
||||||
|
private healTimer = 0.5;
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private hitFlash = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 60;
|
||||||
|
this.speed = 2.8;
|
||||||
|
this.xp = 25;
|
||||||
|
this.contactDps = 10;
|
||||||
|
this.contactRadius = 1.8;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
this.robeMat = new THREE.MeshToonMaterial({ color: 0x33aa55, emissive: 0x0a1f10 });
|
||||||
|
this.darkMat = new THREE.MeshToonMaterial({ color: 0x1c5c2e });
|
||||||
|
|
||||||
|
// Robe (Kegel)
|
||||||
|
const robe = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.1, 10, 1, true), this.robeMat);
|
||||||
|
robe.position.y = 0.5;
|
||||||
|
robe.castShadow = true;
|
||||||
|
this.registerFadeMesh(robe);
|
||||||
|
this.model.add(robe);
|
||||||
|
|
||||||
|
// Oberkörper
|
||||||
|
const body = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), this.robeMat);
|
||||||
|
body.position.y = 1.0;
|
||||||
|
this.registerFadeMesh(body);
|
||||||
|
this.model.add(body);
|
||||||
|
|
||||||
|
// Kopf mit Kapuze
|
||||||
|
const hood = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.5, 10, 1, true), this.darkMat);
|
||||||
|
hood.position.set(0, 1.4, 0.08);
|
||||||
|
this.registerFadeMesh(hood);
|
||||||
|
this.model.add(hood);
|
||||||
|
|
||||||
|
// Leuchtende Augen (+Z)
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x66ff88 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), eyeMat);
|
||||||
|
eye.position.set(side * 0.11, 1.32, 0.26);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stab mit Glühmund
|
||||||
|
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 1.2, 6), this.darkMat);
|
||||||
|
staff.position.set(0.45, 0.8, 0.1);
|
||||||
|
staff.rotation.z = 0.35;
|
||||||
|
this.registerFadeMesh(staff);
|
||||||
|
this.model.add(staff);
|
||||||
|
|
||||||
|
const orbMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x66ff88,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), orbMat);
|
||||||
|
orb.position.set(0.62, 1.45, 0.12);
|
||||||
|
this.model.add(orb);
|
||||||
|
|
||||||
|
// Heil-Aura-Ring am Boden
|
||||||
|
this.healRingMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x44ff77,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
this.healRing = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(HEAL_RADIUS - 0.15, HEAL_RADIUS + 0.15, 32),
|
||||||
|
this.healRingMat
|
||||||
|
);
|
||||||
|
this.healRing.rotation.x = -Math.PI / 2;
|
||||||
|
this.healRing.position.y = 0.05;
|
||||||
|
this.body.add(this.healRing);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 1.0, sink: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
|
||||||
|
// Hit-Feedback
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
this.robeMat.color.set(0xffffff);
|
||||||
|
this.darkMat.color.set(0xffffff);
|
||||||
|
} else {
|
||||||
|
this.robeMat.color.set(0x33aa55);
|
||||||
|
this.darkMat.color.set(0x1c5c2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Langsames Schwebe-Wippen
|
||||||
|
this.model.rotation.z = Math.sin(this.time * 1.5) * 0.04;
|
||||||
|
|
||||||
|
// Zum Spieler laufen
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, 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(this.speed);
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
moveDir = dir.multiplyScalar(-this.speed);
|
||||||
|
this.knockback -= dt;
|
||||||
|
}
|
||||||
|
this.body.position.x += moveDir.x * dt;
|
||||||
|
this.body.position.z += moveDir.z * dt;
|
||||||
|
game.clampToArena(this.body.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heil-Tick
|
||||||
|
this.healTimer -= dt;
|
||||||
|
if (this.healTimer <= 0) {
|
||||||
|
this.healTimer = HEAL_TICK;
|
||||||
|
let healed = false;
|
||||||
|
for (const e of game.enemies) {
|
||||||
|
if (e === this || e.dead) continue;
|
||||||
|
const d = Math.hypot(
|
||||||
|
e.body.position.x - this.body.position.x,
|
||||||
|
e.body.position.z - this.body.position.z
|
||||||
|
);
|
||||||
|
if (d < HEAL_RADIUS && e.health < e.maxHealth) {
|
||||||
|
e.health = Math.min(e.maxHealth, e.health + HEAL_AMOUNT);
|
||||||
|
healed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (healed) playSound('spawn', 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aura-Ring pulsiert mit dem Tick
|
||||||
|
const pulse = 0.15 + Math.max(0, this.healTimer / HEAL_TICK) * 0.25;
|
||||||
|
this.healRingMat.opacity = pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.beginFadeDeath();
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose() {
|
||||||
|
super.dispose();
|
||||||
|
this.healRing.geometry.dispose();
|
||||||
|
this.healRingMat.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const SHOOT_INTERVAL = 2.5;
|
||||||
|
const SHOOT_RANGE = 18;
|
||||||
|
const BOLT_SPEED = 9;
|
||||||
|
const BOLT_DAMAGE = 12;
|
||||||
|
const BOLT_GRAVITY = 10;
|
||||||
|
const CLOUD_RADIUS = 2.4;
|
||||||
|
const CLOUD_DURATION = 3;
|
||||||
|
const CLOUD_TICK = 0.7;
|
||||||
|
const CLOUD_TICK_DAMAGE = 4;
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
|
||||||
|
// Sporen-Pilz: stationär, schießt Bogen-Sporen; am Einschlag entsteht eine Sporenwolke (DoT)
|
||||||
|
export class SporeMushroom extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private stalkMat!: THREE.MeshToonMaterial;
|
||||||
|
private capMat!: THREE.MeshToonMaterial;
|
||||||
|
private glowMat!: THREE.MeshBasicMaterial;
|
||||||
|
private cap!: THREE.Mesh;
|
||||||
|
private shootTimer = 1 + Math.random();
|
||||||
|
private swayTime = Math.random() * Math.PI * 2;
|
||||||
|
private hitFlash = 0;
|
||||||
|
private bolts: SporeBolt[] = [];
|
||||||
|
private clouds: SporeCloud[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 70;
|
||||||
|
this.speed = 0;
|
||||||
|
this.xp = 25;
|
||||||
|
this.contactDps = 0;
|
||||||
|
this.immovable = true;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
this.stalkMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 });
|
||||||
|
this.capMat = new THREE.MeshToonMaterial({ color: 0x55aa44 });
|
||||||
|
this.glowMat = new THREE.MeshBasicMaterial({ color: 0x88ff66 });
|
||||||
|
|
||||||
|
// Stiel
|
||||||
|
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.32, 0.9, 10), this.stalkMat);
|
||||||
|
stalk.position.y = 0.45;
|
||||||
|
stalk.castShadow = true;
|
||||||
|
this.registerFadeMesh(stalk);
|
||||||
|
this.model.add(stalk);
|
||||||
|
|
||||||
|
// Hut
|
||||||
|
this.cap = new THREE.Mesh(new THREE.SphereGeometry(0.6, 18, 14), this.capMat);
|
||||||
|
this.cap.scale.set(1, 0.7, 1);
|
||||||
|
this.cap.position.y = 1.05;
|
||||||
|
this.cap.castShadow = true;
|
||||||
|
this.registerFadeMesh(this.cap);
|
||||||
|
this.model.add(this.cap);
|
||||||
|
|
||||||
|
// Glüh-Flecken
|
||||||
|
for (const [dx, dz] of [[0, 0], [0.3, 0.12], [-0.28, 0.18], [0.05, -0.32]] as const) {
|
||||||
|
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.glowMat);
|
||||||
|
spot.scale.set(1, 0.4, 1);
|
||||||
|
spot.position.set(dx, 1.32, dz);
|
||||||
|
this.model.add(spot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Augen am Stiel (+Z)
|
||||||
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x224422 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 5), eyeMat);
|
||||||
|
eye.position.set(side * 0.16, 0.6, 0.3);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.9, sink: 0.3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.swayTime += dt;
|
||||||
|
|
||||||
|
// Hit-Feedback
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
this.capMat.color.set(0xffffff);
|
||||||
|
this.stalkMat.color.set(0xffffff);
|
||||||
|
} else {
|
||||||
|
this.capMat.color.set(0x55aa44);
|
||||||
|
this.stalkMat.color.set(0xe8dcc0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leichtes Schwanken + Puls
|
||||||
|
this.model.rotation.z = Math.sin(this.swayTime * 1.5) * 0.05;
|
||||||
|
const pulse = 1 + Math.sin(this.swayTime * 4) * 0.05;
|
||||||
|
this.cap.scale.set(1, 0.7 * pulse, 1);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
this.body.lookAt(
|
||||||
|
this.body.position.x + toPlayer.x,
|
||||||
|
this.body.position.y,
|
||||||
|
this.body.position.z + toPlayer.z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shootTimer -= dt;
|
||||||
|
if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) {
|
||||||
|
this.shootTimer = SHOOT_INTERVAL + Math.random() * 0.5;
|
||||||
|
const from = new THREE.Vector3(this.body.position.x, 1.3, this.body.position.z);
|
||||||
|
const target = playerPos.clone();
|
||||||
|
const bolt = new SporeBolt(from, target);
|
||||||
|
game.scene.add(bolt.body);
|
||||||
|
this.bolts.push(bolt);
|
||||||
|
playSound('spawn', 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = this.bolts.length - 1; i >= 0; i--) {
|
||||||
|
const bolt = this.bolts[i];
|
||||||
|
if (!bolt.update(dt, game)) {
|
||||||
|
this.bolts.splice(i, 1);
|
||||||
|
if (bolt.landed) {
|
||||||
|
const cloud = new SporeCloud(bolt.body.position, CLOUD_RADIUS, CLOUD_DURATION);
|
||||||
|
game.scene.add(cloud.body);
|
||||||
|
this.clouds.push(cloud);
|
||||||
|
}
|
||||||
|
bolt.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = this.clouds.length - 1; i >= 0; i--) {
|
||||||
|
const cloud = this.clouds[i];
|
||||||
|
if (!cloud.update(dt, game)) {
|
||||||
|
this.clouds.splice(i, 1);
|
||||||
|
cloud.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bolt of this.bolts) bolt.dispose();
|
||||||
|
this.bolts = [];
|
||||||
|
for (const cloud of this.clouds) cloud.dispose();
|
||||||
|
this.clouds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bogen-Spore: ballistisches Projektil (parabelförmig zum Zielpunkt)
|
||||||
|
class SporeBolt {
|
||||||
|
body: THREE.Group;
|
||||||
|
private vel: THREE.Vector3;
|
||||||
|
private life = 5;
|
||||||
|
dead = false;
|
||||||
|
landed = false;
|
||||||
|
|
||||||
|
constructor(from: THREE.Vector3, target: THREE.Vector3) {
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.copy(from);
|
||||||
|
|
||||||
|
// Ballistik: Flugzeit aus horizontaler Distanz, Abwärts-Geschwindigkeit passend
|
||||||
|
const dx = target.x - from.x;
|
||||||
|
const dz = target.z - from.z;
|
||||||
|
const horiz = Math.hypot(dx, dz);
|
||||||
|
const t = Math.max(0.35, horiz / BOLT_SPEED);
|
||||||
|
const vy = (target.y - from.y + 0.5 * BOLT_GRAVITY * t * t) / t;
|
||||||
|
this.vel = new THREE.Vector3(dx / t, vy, dz / t);
|
||||||
|
|
||||||
|
const glowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x88ff66,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.6,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.24, 8, 6), glowMat);
|
||||||
|
this.body.add(glow);
|
||||||
|
const coreMat = new THREE.MeshToonMaterial({ color: 0x3a8a2a });
|
||||||
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.13, 6, 5), coreMat);
|
||||||
|
this.body.add(core);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.life -= dt;
|
||||||
|
this.vel.y -= BOLT_GRAVITY * dt;
|
||||||
|
this.body.position.addScaledVector(this.vel, dt);
|
||||||
|
|
||||||
|
if (this.body.position.y <= 0.1) {
|
||||||
|
this.body.position.y = 0.1;
|
||||||
|
this.landed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (game.player.health > 0) {
|
||||||
|
const dist = this.body.position.distanceTo(game.player.body.position);
|
||||||
|
if (dist < 1.0) {
|
||||||
|
game.player.damage(BOLT_DAMAGE, this.body.position);
|
||||||
|
this.landed = 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sporenwolke: Boden-DoT wie die Tintenpfütze, aber grün
|
||||||
|
class SporeCloud {
|
||||||
|
body: THREE.Group;
|
||||||
|
private life: number;
|
||||||
|
private tickTimer = 0;
|
||||||
|
private puffs: THREE.Mesh[] = [];
|
||||||
|
|
||||||
|
constructor(position: THREE.Vector3, radius: number, duration: number) {
|
||||||
|
this.life = duration;
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.set(position.x, 0.1, position.z);
|
||||||
|
|
||||||
|
const puffGeom = new THREE.SphereGeometry(1, 10, 8);
|
||||||
|
const puffMat = new THREE.MeshToonMaterial({
|
||||||
|
color: 0x55cc44,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.32,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const puff = new THREE.Mesh(puffGeom, puffMat);
|
||||||
|
const a = (i / 6) * Math.PI * 2;
|
||||||
|
puff.position.set(
|
||||||
|
Math.cos(a) * radius * 0.4,
|
||||||
|
0.35 + Math.random() * 0.3,
|
||||||
|
Math.sin(a) * radius * 0.4
|
||||||
|
);
|
||||||
|
puff.scale.setScalar(radius * (0.2 + Math.random() * 0.12));
|
||||||
|
this.body.add(puff);
|
||||||
|
this.puffs.push(puff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.life -= dt;
|
||||||
|
if (this.life <= 0) return false;
|
||||||
|
|
||||||
|
const fade = Math.min(1, this.life / 0.6);
|
||||||
|
for (const puff of this.puffs) {
|
||||||
|
(puff.material as THREE.MeshToonMaterial).opacity = 0.32 * fade;
|
||||||
|
puff.rotation.y += dt * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 < 2.4) {
|
||||||
|
this.tickTimer -= dt;
|
||||||
|
if (this.tickTimer <= 0) {
|
||||||
|
this.tickTimer = CLOUD_TICK;
|
||||||
|
game.player.damage(CLOUD_TICK_DAMAGE, this.body.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.body.removeFromParent();
|
||||||
|
for (const puff of this.puffs) {
|
||||||
|
puff.geometry.dispose();
|
||||||
|
(puff.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const STRAFE_DIST = 3.2;
|
||||||
|
const SHELL_IN = 0.4;
|
||||||
|
const SHELL_TIME = 2.0;
|
||||||
|
const SHELL_OUT = 0.4;
|
||||||
|
const SHELL_CYCLE = 7;
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
|
||||||
|
type State = 'strafe' | 'shellin' | 'shell' | 'shellout';
|
||||||
|
|
||||||
|
// Schildkröte: Frontpanzerung blockt Schaden komplett, zieht sich periodisch in den Panzer zurück
|
||||||
|
export class Turtle extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private shellMat!: THREE.MeshToonMaterial;
|
||||||
|
private darkMat!: THREE.MeshToonMaterial;
|
||||||
|
private skinMat!: THREE.MeshToonMaterial;
|
||||||
|
private shell!: THREE.Mesh;
|
||||||
|
private headMesh!: THREE.Mesh;
|
||||||
|
private legs: THREE.Mesh[] = [];
|
||||||
|
private eyeMat!: THREE.MeshBasicMaterial;
|
||||||
|
private state: State = 'strafe';
|
||||||
|
private stateTimer = 0;
|
||||||
|
private shellTimer = SHELL_CYCLE * 0.6;
|
||||||
|
private side = Math.random() < 0.5 ? 1 : -1;
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private hitFlash = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 160;
|
||||||
|
this.speed = 2.8;
|
||||||
|
this.xp = 30;
|
||||||
|
this.contactDps = 18;
|
||||||
|
this.contactRadius = 1.8;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
this.shellMat = new THREE.MeshToonMaterial({ color: 0x55aa55 });
|
||||||
|
this.darkMat = new THREE.MeshToonMaterial({ color: 0x2f5c2f });
|
||||||
|
this.skinMat = new THREE.MeshToonMaterial({ color: 0x99bb77 });
|
||||||
|
this.eyeMat = new THREE.MeshBasicMaterial({ color: 0x223322 });
|
||||||
|
|
||||||
|
// Panzer (Kuppel)
|
||||||
|
this.shell = new THREE.Mesh(new THREE.SphereGeometry(0.85, 18, 12, 0, Math.PI * 2, 0, Math.PI / 2), this.shellMat);
|
||||||
|
this.shell.position.y = 0.6;
|
||||||
|
this.shell.castShadow = true;
|
||||||
|
this.registerFadeMesh(this.shell);
|
||||||
|
this.model.add(this.shell);
|
||||||
|
|
||||||
|
// Panzer-Platten
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const plate = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(0.5, 0.08, 0.3),
|
||||||
|
this.darkMat
|
||||||
|
);
|
||||||
|
const a = (i / 4) * Math.PI * 2;
|
||||||
|
plate.position.set(Math.cos(a) * 0.55, 0.85, Math.sin(a) * 0.55);
|
||||||
|
plate.rotation.y = -a;
|
||||||
|
plate.scale.set(1.3, 1, 1);
|
||||||
|
this.registerFadeMesh(plate);
|
||||||
|
this.model.add(plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beine
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
for (const back of [true, false]) {
|
||||||
|
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 0.25, 6), this.skinMat);
|
||||||
|
leg.position.set(side * 0.55, 0.15, back ? -0.45 : 0.5);
|
||||||
|
this.registerFadeMesh(leg);
|
||||||
|
this.model.add(leg);
|
||||||
|
this.legs.push(leg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopf (+Z)
|
||||||
|
this.headMesh = new THREE.Mesh(new THREE.SphereGeometry(0.22, 10, 8), this.skinMat);
|
||||||
|
this.headMesh.scale.set(1, 0.8, 1.3);
|
||||||
|
this.headMesh.position.set(0, 0.35, 0.9);
|
||||||
|
this.registerFadeMesh(this.headMesh);
|
||||||
|
this.model.add(this.headMesh);
|
||||||
|
|
||||||
|
// Augen (+Z)
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), this.eyeMat);
|
||||||
|
eye.position.set(side * 0.12, 0.45, 1.05);
|
||||||
|
this.model.add(eye);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schwanzstummel (-Z)
|
||||||
|
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.3, 6), this.skinMat);
|
||||||
|
tail.rotation.x = Math.PI / 2;
|
||||||
|
tail.position.set(0, 0.2, -0.95);
|
||||||
|
this.registerFadeMesh(tail);
|
||||||
|
this.model.add(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 1.0, sink: 0.4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
|
||||||
|
// Hit-Feedback
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
this.shellMat.color.set(0xffffff);
|
||||||
|
this.skinMat.color.set(0xffffff);
|
||||||
|
} else {
|
||||||
|
this.shellMat.color.set(0x55aa55);
|
||||||
|
this.skinMat.color.set(0x99bb77);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, this.body.position);
|
||||||
|
toPlayer.y = 0;
|
||||||
|
const dist = toPlayer.length();
|
||||||
|
|
||||||
|
switch (this.state) {
|
||||||
|
case 'strafe':
|
||||||
|
this.stateTimer -= dt;
|
||||||
|
this.shellTimer -= dt;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
this.knockback -= dt;
|
||||||
|
const away = dir.clone().multiplyScalar(-this.speed);
|
||||||
|
this.body.position.x += away.x * dt;
|
||||||
|
this.body.position.z += away.z * dt;
|
||||||
|
} else {
|
||||||
|
// Seitwärts kreisen (Panzerfront zeigt zum Spieler)
|
||||||
|
if (Math.random() < dt * 0.25) this.side *= -1;
|
||||||
|
const perp = new THREE.Vector3(-dir.z, 0, dir.x);
|
||||||
|
const move = perp.multiplyScalar(this.side * this.speed);
|
||||||
|
this.body.position.x += move.x * dt;
|
||||||
|
this.body.position.z += move.z * dt;
|
||||||
|
}
|
||||||
|
game.clampToArena(this.body.position);
|
||||||
|
}
|
||||||
|
if (this.shellTimer <= 0) {
|
||||||
|
this.shellTimer = SHELL_CYCLE;
|
||||||
|
this.state = 'shellin';
|
||||||
|
this.stateTimer = SHELL_IN;
|
||||||
|
playSound('spawn', 0.4);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'shellin':
|
||||||
|
this.stateTimer -= dt;
|
||||||
|
this.shell.scale.y = THREE.MathUtils.lerp(this.shell.scale.y, 1.25, 0.25);
|
||||||
|
this.headMesh.visible = this.shell.scale.y < 1.1;
|
||||||
|
if (this.stateTimer <= 0) {
|
||||||
|
this.state = 'shell';
|
||||||
|
this.stateTimer = SHELL_TIME;
|
||||||
|
this.shell.scale.y = 1.25;
|
||||||
|
this.headMesh.visible = false;
|
||||||
|
for (const leg of this.legs) leg.visible = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'shell':
|
||||||
|
this.stateTimer -= dt;
|
||||||
|
if (this.stateTimer <= 0) {
|
||||||
|
this.state = 'shellout';
|
||||||
|
this.stateTimer = SHELL_OUT;
|
||||||
|
this.headMesh.visible = true;
|
||||||
|
for (const leg of this.legs) leg.visible = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'shellout':
|
||||||
|
this.stateTimer -= dt;
|
||||||
|
this.shell.scale.y = THREE.MathUtils.lerp(this.shell.scale.y, 1, 0.25);
|
||||||
|
if (this.stateTimer <= 0) {
|
||||||
|
this.state = 'strafe';
|
||||||
|
this.shell.scale.y = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override takeDamage(
|
||||||
|
damage: number,
|
||||||
|
game: Game,
|
||||||
|
withKnockback = true,
|
||||||
|
sourcePos?: THREE.Vector3
|
||||||
|
) {
|
||||||
|
if (this.dead) return;
|
||||||
|
// Im Panzer komplett unverwundbar
|
||||||
|
if (this.state === 'shell' || this.state === 'shellin') return;
|
||||||
|
|
||||||
|
const src = sourcePos ?? game.player.body.position;
|
||||||
|
const front = new THREE.Vector3(0, 0, 1).applyQuaternion(this.body.quaternion);
|
||||||
|
front.y = 0;
|
||||||
|
front.normalize();
|
||||||
|
const incoming = new THREE.Vector3().subVectors(src, this.body.position);
|
||||||
|
incoming.y = 0;
|
||||||
|
if (incoming.lengthSq() < 0.0001) incoming.copy(front);
|
||||||
|
incoming.normalize();
|
||||||
|
|
||||||
|
// Frontpanzerung blockt kompletten Schaden
|
||||||
|
if (front.dot(incoming) > 0.3) {
|
||||||
|
game.spawnDamageText('PANZER!', this.body.position, '#88cc66');
|
||||||
|
playSound('sword_hit1', 0.5);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
super.takeDamage(damage, game, withKnockback, sourcePos);
|
||||||
|
this.hitFlash = HIT_FLASH_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDeath(_game: Game) {
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Game } from './Game';
|
||||||
|
import { Enemy } from './Enemy';
|
||||||
|
import { playSound } from './SoundManager';
|
||||||
|
|
||||||
|
const IDLE_DIST_MIN = 7;
|
||||||
|
const IDLE_DIST_MAX = 11;
|
||||||
|
const SPORE_COOLDOWN = 3;
|
||||||
|
const SPORE_SPEED = 5;
|
||||||
|
const SPORE_HOMING = 1.6;
|
||||||
|
const SPORE_DAMAGE = 8;
|
||||||
|
const SPORE_SLOW = 1.5;
|
||||||
|
const SPORE_HIT_RADIUS = 1.0;
|
||||||
|
const SPORE_LIFE = 4;
|
||||||
|
const FLOAT_HEIGHT = 1.2;
|
||||||
|
const HIT_FLASH_TIME = 0.14;
|
||||||
|
|
||||||
|
// Irrlicht: schwebender Caster, schießt langsame Homing-Sporen (Slow)
|
||||||
|
export class Wisp extends Enemy {
|
||||||
|
private model: THREE.Group;
|
||||||
|
private glowMat!: THREE.MeshBasicMaterial;
|
||||||
|
private coreMat!: THREE.MeshToonMaterial;
|
||||||
|
private tailMat!: THREE.MeshBasicMaterial;
|
||||||
|
private tail!: THREE.Mesh;
|
||||||
|
private shadow!: THREE.Mesh;
|
||||||
|
private shadowMat!: THREE.MeshBasicMaterial;
|
||||||
|
private sporeTimer = 1.5 + Math.random();
|
||||||
|
private time = Math.random() * Math.PI * 2;
|
||||||
|
private hitFlash = 0;
|
||||||
|
private spores: WispSpore[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.health = 35;
|
||||||
|
this.speed = 3.8;
|
||||||
|
this.xp = 18;
|
||||||
|
this.contactDps = 8;
|
||||||
|
this.contactRadius = 1.6;
|
||||||
|
|
||||||
|
this.model = new THREE.Group();
|
||||||
|
this.fadeOutModel = this.model;
|
||||||
|
this.body.add(this.model);
|
||||||
|
|
||||||
|
this.glowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xaaffcc,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.55,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.45, 12, 10), this.glowMat);
|
||||||
|
this.model.add(glow);
|
||||||
|
|
||||||
|
this.coreMat = new THREE.MeshToonMaterial({ color: 0x66ffaa, emissive: 0x114422 });
|
||||||
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.28, 10, 8), this.coreMat);
|
||||||
|
this.model.add(core);
|
||||||
|
|
||||||
|
// Schweif (verjüngter Kegel, wackelt)
|
||||||
|
this.tailMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x88ffbb,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
this.tail = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.7, 6), this.tailMat);
|
||||||
|
this.tail.rotation.x = Math.PI / 2;
|
||||||
|
this.tail.position.set(0, 0, -0.55);
|
||||||
|
this.model.add(this.tail);
|
||||||
|
|
||||||
|
// Boden-Schatten
|
||||||
|
this.shadowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x000000,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.22,
|
||||||
|
});
|
||||||
|
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.4, 16), this.shadowMat);
|
||||||
|
this.shadow.rotation.x = -Math.PI / 2;
|
||||||
|
this.shadow.renderOrder = 998;
|
||||||
|
this.body.add(this.shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnim() {
|
||||||
|
// Prozedurale Optik
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation(dt: number) {
|
||||||
|
this.updateFadeDeath(dt, { duration: 0.8, shrink: 0.7 });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game) {
|
||||||
|
this.time += dt;
|
||||||
|
|
||||||
|
// Schweben + Pulsieren
|
||||||
|
this.body.position.y = FLOAT_HEIGHT + Math.sin(this.time * 2.4) * 0.25;
|
||||||
|
this.shadow.position.y = 0.06 - this.body.position.y;
|
||||||
|
const pulse = 1 + Math.sin(this.time * 5) * 0.15;
|
||||||
|
this.model.scale.setScalar(pulse);
|
||||||
|
this.glowMat.opacity = 0.4 + Math.abs(Math.sin(this.time * 5)) * 0.2;
|
||||||
|
this.tail.rotation.z = Math.sin(this.time * 3) * 0.3;
|
||||||
|
|
||||||
|
// Hit-Feedback
|
||||||
|
if (this.hitFlash > 0) {
|
||||||
|
this.hitFlash -= dt;
|
||||||
|
this.coreMat.color.set(0xffffff);
|
||||||
|
} else {
|
||||||
|
this.coreMat.color.set(0x66ffaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, 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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Abstand halten: schwebt in mittlerer Reichweite
|
||||||
|
let move = new THREE.Vector3();
|
||||||
|
if (dist < IDLE_DIST_MIN) {
|
||||||
|
move.copy(dir).multiplyScalar(-1);
|
||||||
|
} else if (dist > IDLE_DIST_MAX) {
|
||||||
|
move.copy(dir);
|
||||||
|
}
|
||||||
|
if (this.knockback > 0) {
|
||||||
|
move = dir.clone().multiplyScalar(-1);
|
||||||
|
this.knockback -= dt;
|
||||||
|
}
|
||||||
|
if (move.lengthSq() > 0) {
|
||||||
|
move.normalize().multiplyScalar(this.speed);
|
||||||
|
this.body.position.x += move.x * dt;
|
||||||
|
this.body.position.z += move.z * dt;
|
||||||
|
game.clampToArena(this.body.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spore schießen
|
||||||
|
this.sporeTimer -= dt;
|
||||||
|
if (this.sporeTimer <= 0 && game.player.health > 0) {
|
||||||
|
this.sporeTimer = SPORE_COOLDOWN + Math.random() * 0.5;
|
||||||
|
const from = new THREE.Vector3(this.body.position.x, this.body.position.y, this.body.position.z);
|
||||||
|
const spore = new WispSpore(from);
|
||||||
|
game.scene.add(spore.body);
|
||||||
|
this.spores.push(spore);
|
||||||
|
playSound('spawn', 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sporen aktualisieren
|
||||||
|
for (let i = this.spores.length - 1; i >= 0; i--) {
|
||||||
|
if (!this.spores[i].update(dt, game)) {
|
||||||
|
this.spores[i].dispose();
|
||||||
|
this.spores.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.cleanupSpores();
|
||||||
|
this.beginFadeDeath();
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose() {
|
||||||
|
super.dispose();
|
||||||
|
this.cleanupSpores();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupSpores() {
|
||||||
|
for (const s of this.spores) s.dispose();
|
||||||
|
this.spores = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spore: langsames Homing-Projektil, verlangsamt beim Treffen
|
||||||
|
class WispSpore {
|
||||||
|
body: THREE.Group;
|
||||||
|
private vel: THREE.Vector3;
|
||||||
|
private life = SPORE_LIFE;
|
||||||
|
dead = false;
|
||||||
|
|
||||||
|
constructor(from: THREE.Vector3) {
|
||||||
|
this.body = new THREE.Group();
|
||||||
|
this.body.position.copy(from);
|
||||||
|
this.vel = new THREE.Vector3(
|
||||||
|
(Math.random() - 0.5) * 2,
|
||||||
|
(Math.random() - 0.5) * 2,
|
||||||
|
(Math.random() - 0.5) * 2
|
||||||
|
);
|
||||||
|
|
||||||
|
const glowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x88ffbb,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.5,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 8), glowMat);
|
||||||
|
this.body.add(glow);
|
||||||
|
const coreMat = new THREE.MeshToonMaterial({ color: 0x44cc77 });
|
||||||
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), coreMat);
|
||||||
|
this.body.add(core);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number, game: Game): boolean {
|
||||||
|
this.life -= dt;
|
||||||
|
|
||||||
|
// Sanftes Homing auf den Spieler
|
||||||
|
if (game.player.health > 0) {
|
||||||
|
const toPlayer = new THREE.Vector3()
|
||||||
|
.subVectors(game.player.body.position, this.body.position)
|
||||||
|
.normalize();
|
||||||
|
const desired = toPlayer.multiplyScalar(SPORE_SPEED);
|
||||||
|
this.vel.lerp(desired, Math.min(1, dt * SPORE_HOMING));
|
||||||
|
}
|
||||||
|
this.body.position.addScaledVector(this.vel, dt);
|
||||||
|
this.body.position.y = Math.max(0.3, this.body.position.y);
|
||||||
|
|
||||||
|
if (game.player.health > 0) {
|
||||||
|
const dist = this.body.position.distanceTo(game.player.body.position);
|
||||||
|
if (dist < SPORE_HIT_RADIUS) {
|
||||||
|
game.player.damage(SPORE_DAMAGE, this.body.position);
|
||||||
|
game.player.slowTimer = SPORE_SLOW;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user