diff --git a/AGENTS.md b/AGENTS.md index b4d678d..e304d06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ npm run dev # Dev-Server mit HMR | `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) | | `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 | | `MeshLoader.ts` | `loadGLB(name)` → `web/public/models/*.glb`; Texturen in `web/public/textures/` | diff --git a/web/index.html b/web/index.html index 7c7258d..adbc76f 100644 --- a/web/index.html +++ b/web/index.html @@ -400,6 +400,7 @@

Changelog

+
20.08.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)
20.08.Drachen-Boss (Welle 70): fliegt und ist nur am Boden verletzbar, Sturzangriff mit Schatten, Feuerspeer
20.08.Erz-Nekromant (Welle 60): Teleport mit Telegraf, Projektil-Salven und Beschwörungen
20.08.Frostriese (Welle 50): Eispanzer-Rüstung, Eisberst-Ringe und Eiszapfen-Regen
diff --git a/web/src/Archer.ts b/web/src/Archer.ts new file mode 100644 index 0000000..f02fadc --- /dev/null +++ b/web/src/Archer.ts @@ -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 = []; + } +} diff --git a/web/src/Bomber.ts b/web/src/Bomber.ts new file mode 100644 index 0000000..fc76d5c --- /dev/null +++ b/web/src/Bomber.ts @@ -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(); + } +} diff --git a/web/src/Game.ts b/web/src/Game.ts index 86153dc..80ac078 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -17,6 +17,12 @@ import { Necromancer } from './Necromancer'; import { Dragon } from './Dragon'; import { Crab } from './Crab'; 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 { Sword } from './Sword'; import { Staff } from './Staff'; @@ -82,6 +88,13 @@ export class Game { spawns = 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(); + 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) private bossWaves = new Map Enemy }>([ [10, { name: 'Golem', spawn: () => new Golem() }], @@ -765,6 +778,7 @@ export class Game { this.spawns = 0; this.killed = 0; this.bossFightActive = false; + this.offscreenTimers.clear(); this.shakeTime = 0; this.pendingPicks = 0; this.hideLevelUp(); @@ -931,8 +945,13 @@ export class Game { } private spawnEnemy() { - const x = (Math.random() - 0.5) * 80; - const z = (Math.random() - 0.5) * 80; + // Immer im Sichtbereich spawnen (Ring um den Spieler), damit auch + // 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); let total = 0; @@ -957,22 +976,33 @@ export class Game { case 'brute': this.spawnEnemyInstance(new Brute(), x, z, 0.15); break; case 'crab': this.spawnEnemyInstance(new Crab(), 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 { const w = new Map(); if (wave >= 7) { - w.set('spider', 25).set('ghost', 15).set('frog', 15).set('slime', 10) - .set('bat', 10).set('turret', 10).set('crab', 10).set('bee', 5); + w.set('spider', 20).set('ghost', 12).set('frog', 12).set('slime', 8) + .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) { - w.set('spider', 35).set('ghost', 20).set('frog', 15).set('slime', 10) - .set('bat', 10).set('turret', 10); + w.set('spider', 28).set('ghost', 16).set('frog', 12).set('slime', 8) + .set('bat', 8).set('turret', 8) + .set('archer', 7).set('bomber', 7).set('wisp', 4).set('spore', 5); } else if (wave >= 5) { - w.set('spider', 45).set('ghost', 20).set('frog', 15).set('slime', 10) - .set('bat', 10); + w.set('spider', 35).set('ghost', 16).set('frog', 12).set('slime', 8) + .set('bat', 8) + .set('archer', 7).set('bomber', 7).set('spore', 5); } 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) { w.set('spider', 75).set('ghost', 25); } else { @@ -989,8 +1019,18 @@ export class Game { const entry = this.bossWaves.get(wave); if (!entry) return; const enemy = entry.spawn(); - const x = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80; - const z = enemy.spawnAtCenter ? 0 : (Math.random() - 0.5) * 80; + let x = 0; + 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); playSound('spawn', 0.8); } @@ -1123,6 +1163,47 @@ export class Game { 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) { for (const enemy of this.enemies) { if (enemy.dead || this.state !== 'playing') { @@ -1369,6 +1450,7 @@ export class Game { this.updateGroundPoint(); this.updatePlayerMovement(dt); this.updateEnemies(dt); + this.updateOffscreenReposition(dt); this.updateFireballs(dt); this.updateHitzones(); diff --git a/web/src/Necromancer.ts b/web/src/Necromancer.ts index bbbfaf7..21914e9 100644 --- a/web/src/Necromancer.ts +++ b/web/src/Necromancer.ts @@ -4,6 +4,9 @@ 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; @@ -430,7 +433,8 @@ export class Necromancer extends Enemy { private startSummon(game: Game) { 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++) { const a = Math.random() * Math.PI * 2; const r = 5 + Math.random() * 8; diff --git a/web/src/Shaman.ts b/web/src/Shaman.ts new file mode 100644 index 0000000..f602456 --- /dev/null +++ b/web/src/Shaman.ts @@ -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(); + } +} diff --git a/web/src/SporeMushroom.ts b/web/src/SporeMushroom.ts new file mode 100644 index 0000000..8b06337 --- /dev/null +++ b/web/src/SporeMushroom.ts @@ -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(); + } + } +} diff --git a/web/src/Turtle.ts b/web/src/Turtle.ts new file mode 100644 index 0000000..bcd1348 --- /dev/null +++ b/web/src/Turtle.ts @@ -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(); + } +} diff --git a/web/src/Wisp.ts b/web/src/Wisp.ts new file mode 100644 index 0000000..669c12c --- /dev/null +++ b/web/src/Wisp.ts @@ -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(); + } + } + } +}