From 1f67aaa982fa47099bd9b6d4d41a4c765d659b58 Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 19 Aug 2026 14:41:02 +0200 Subject: [PATCH] Add Minotaurus boss with front armor, dash stun and jumpable axe swing --- AGENTS.md | 7 +- web/src/FallingRock.ts | 112 ++++++ web/src/Game.ts | 36 +- web/src/Minotaurus.ts | 769 +++++++++++++++++++++++++++++++++++++++++ web/src/Player.ts | 28 ++ 5 files changed, 943 insertions(+), 9 deletions(-) create mode 100644 web/src/FallingRock.ts create mode 100644 web/src/Minotaurus.ts diff --git a/AGENTS.md b/AGENTS.md index 44b7122..a6be78f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,8 @@ npm run dev # Dev-Server mit HMR | `Trapper.ts` | Falle (skill1) + Gaswolke (skill2) | | `Aura.ts` / `Boomerang.ts` | Passive Skills, lesen Level live aus `game.skillSystem` | | `Trap.ts` / `GasCloud.ts` | Platzierbare Effekte; `update(dt, game): boolean` (false = entfernen), `dispose()` | -| `Golem.ts` / `Krake.ts` | Bosse (Welle 10 / 20), 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 <50% HP | -| `InkBlob.ts` / `InkPuddle.ts` | Krake-Effekte: Tintenprojektil → Pfütze mit Slow (`player.slowTimer`) + DoT + `game.triggerInkVignette()` | +| `Golem.ts` / `Krake.ts` / `Minotaurus.ts` | Bosse (Welle 10 / 20 / 30), 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. 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)` | | `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/` | @@ -50,6 +50,7 @@ npm run dev # Dev-Server mit HMR - **Effekt-Lifecycle**: Neue Effekte mit eigenem `update(dt, game)`-Rückgabewert (false = aufräumen) und `dispose()`; in `Game.resetWorld()` ALLES aufräumen (sonst Geister-Objekte nach „Nochmal spielen"). - **Gegner-Festhalten**: `enemy.rootTime > 0` friert `enemy.update()` ein (siehe `Game.updateEnemies`) — so funktioniert die Falle, ohne jede Gegner-Klasse anzufassen. - **Boss-Wellen & Boss-Modus**: `Game.bossWaves` (Map: Welle → `{name, spawn}`) steuert Boss-Wellen (10, 20 …) — Boss-Welle startet erst, wenn die Arena geräumt ist (`enemies.every(e => e.dead)`); nach Boss-Tod läuft die nächste normale Welle an. Cheat-Code **„idkfa"** im Hauptmenü blendet den Boss-Modus ein (`#boss-mode`, Buttons werden aus `bossWaves` erzeugt) → `startBossFight(wave)` mit Zufalls-Skill-Boost (`grantRandomSkills`). `spawnAtCenter`-Flag lässt stationäre Bosse in der Arena-Mitte spawnen. Effekte, die ein Boss selbst verwaltet (Tinten-Blobs/-Pfützen, Ringe), müssen in `onDeath`/`dispose` aufgeräumt werden (Dead-Enemy-Removal ruft `dispose` NICHT auf). -- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung) und `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung). Beides wird in `Player.reset()` zurückgesetzt. +- **Spieler-Status-Effekte** (in `Player.ts` + `Game.updatePlayerMovement`): `slowTimer`/`slowFactor` (Tinten-Slow, Lila-Tönung), `pullTime`/`pullDir` (Tentakel-Griff: Eingabe wird ignoriert, Geschwindigkeit = Pull-Richtung) und `stunTime` (Stampf-Schockwelle: keine Eingaben, gelbe Sterne kreisen um den Kopf). Alles wird in `Player.reset()` zurückgesetzt. +- **Kamera-Shake**: `game.triggerCameraShake(duration, amp)` → in `animate()` wird die Kameraposition kurz zufällig versetzt (kein Postprocessing nötig). - **Kontaktschaden** (Gegner berührt Spieler) läuft in `Game.ts` (Close-Encounter-Loop) mit `contactDps * dt`; Dornen-Skill ebendort als Gegenschlag. - **`playSound`** erzeugt pro Aufruf einen AudioBufferSourceNode — bei hochfrequenten Effekten (Aura-Ticks, Bumerang-Hits) ggf. throtteln. diff --git a/web/src/FallingRock.ts b/web/src/FallingRock.ts new file mode 100644 index 0000000..3f67a16 --- /dev/null +++ b/web/src/FallingRock.ts @@ -0,0 +1,112 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; + +const TELEGRAPH_TIME = 0.9; +const IMPACT_RADIUS = 1.8; +const ROCK_DAMAGE = 30; +const DUST_TIME = 0.4; + +export class FallingRock { + body: THREE.Group; + private shadow: THREE.Mesh; + private rock: THREE.Mesh; + private phase: 'telegraph' | 'fall' | 'dust' = 'telegraph'; + private timer = TELEGRAPH_TIME; + private dust: THREE.Mesh[] = []; + private dustDirs: THREE.Vector3[] = []; + private dustMat: THREE.MeshBasicMaterial; + + constructor(pos: THREE.Vector3) { + this.body = new THREE.Group(); + this.body.position.set(pos.x, 0, pos.z); + + // Schatten-Telegraf (waechst kurz vor dem Aufprall) + const shadowMat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0.3, + depthWrite: false, + }); + this.shadow = new THREE.Mesh(new THREE.CircleGeometry(1.6, 24), shadowMat); + this.shadow.rotation.x = -Math.PI / 2; + this.shadow.position.y = 0.06; + this.shadow.scale.setScalar(0.4); + this.body.add(this.shadow); + + // Fels von der Decke + const rockMat = new THREE.MeshToonMaterial({ color: 0x777788 }); + this.rock = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.9, 0.9), rockMat); + this.rock.position.y = 16; + this.rock.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3); + this.body.add(this.rock); + + this.dustMat = new THREE.MeshBasicMaterial({ + color: 0x888899, + transparent: true, + opacity: 0, + depthWrite: false, + }); + } + + update(dt: number, game: Game): boolean { + this.timer -= dt; + + if (this.phase === 'telegraph') { + const t = 1 - this.timer / TELEGRAPH_TIME; + this.shadow.scale.setScalar(0.4 + t * 0.6); + (this.shadow.material as THREE.MeshBasicMaterial).opacity = 0.2 + t * 0.2; + if (this.timer <= 0) { + this.phase = 'fall'; + this.timer = 2; + } + } else if (this.phase === 'fall') { + this.rock.position.y -= 26 * dt; + this.rock.rotation.x += dt * 3; + this.rock.rotation.z += dt * 2; + if (this.rock.position.y <= 0.15) { + this.rock.position.y = 0.15; + this.rock.visible = false; + this.shadow.visible = false; + + 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) < IMPACT_RADIUS) { + game.player.damage(ROCK_DAMAGE, this.body.position); + } + } + + // Staubwolke + for (let i = 0; i < 5; i++) { + const a = (i / 5) * Math.PI * 2; + const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a)); + const puff = new THREE.Mesh(new THREE.SphereGeometry(0.25, 6, 5), this.dustMat); + this.body.add(puff); + this.dust.push(puff); + this.dustDirs.push(dir); + } + this.phase = 'dust'; + this.timer = DUST_TIME; + } + } else { + const t = 1 - this.timer / DUST_TIME; + for (let i = 0; i < this.dust.length; i++) { + this.dust[i].position.copy(this.dustDirs[i]).multiplyScalar(0.5 + t * 1.8); + this.dust[i].position.y = 0.2 + t * 0.8; + } + this.dustMat.opacity = 0.5 * (1 - t); + if (this.timer <= 0) return false; + } + return true; + } + + dispose() { + this.body.removeFromParent(); + this.body.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry.dispose(); + (obj.material as THREE.Material).dispose(); + } + }); + } +} diff --git a/web/src/Game.ts b/web/src/Game.ts index e586d66..b2a1b7e 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -10,6 +10,7 @@ import { Slime } from './Slime'; import { Brute } from './Brute'; import { Golem } from './Golem'; import { Krake } from './Krake'; +import { Minotaurus } from './Minotaurus'; import { Crab } from './Crab'; import { Turret } from './Turret'; import { EnemyProjectile } from './EnemyProjectile'; @@ -81,8 +82,11 @@ export class Game { private bossWaves = new Map Enemy }>([ [10, { name: 'Golem', spawn: () => new Golem() }], [20, { name: 'Krake', spawn: () => new Krake() }], + [30, { name: 'Minotaurus', spawn: () => new Minotaurus() }], ]); private bossFightActive = false; + private shakeTime = 0; + private shakeAmp = 0; // Debug hitzone visualization private showHitzones = false; @@ -298,7 +302,8 @@ export class Game { e.preventDefault(); return; } - if (e.code === 'Space' && this.state === 'playing' && this.player.pullTime <= 0) { + if (e.code === 'Space' && this.state === 'playing' + && this.player.pullTime <= 0 && this.player.stunTime <= 0) { this.player.jump(); e.preventDefault(); } @@ -728,6 +733,7 @@ export class Game { this.spawns = 0; this.killed = 0; this.bossFightActive = false; + this.shakeTime = 0; this.pendingPicks = 0; this.hideLevelUp(); this.skillSystem = new SkillSystem(this.seed ?? 0); @@ -754,15 +760,24 @@ export class Game { } // Treffer-Feedback: Schadenszahl bei Bossen einblenden if (target?.isBoss && !target.dead) { - this.spawnDamageNumber(Math.round(damage), target.body.position); + this.spawnDamageText(`${Math.round(damage)}`, target.body.position); } } - spawnDamageNumber(damage: number, pos: THREE.Vector3, color?: string) { - const num = new DamageNumber(this.scene, `${damage}`, pos, color); + spawnDamageText(text: string, pos: THREE.Vector3, color?: string) { + const num = new DamageNumber(this.scene, text, pos, color); this.damageNumbers.push(num); } + spawnDamageNumber(damage: number, pos: THREE.Vector3, color?: string) { + this.spawnDamageText(`${damage}`, pos, color); + } + + triggerCameraShake(duration: number, amp: number) { + this.shakeTime = duration; + this.shakeAmp = amp; + } + private openLevelUp() { this.offers = this.skillSystem.rollOffers(); if (this.offers.length === 0) { @@ -1012,7 +1027,10 @@ export class Game { if (p.slowTimer > 0) speed *= p.slowFactor; let isMoving = false; - if (p.pullTime > 0) { + if (p.stunTime > 0) { + // Gestunnt (Stampf-Schockwelle): keine Eingaben + p.velocity.set(0, 0, 0); + } else if (p.pullTime > 0) { // Tentakel-Griff: Spieler wird komplett zum Boss gezogen p.pullTime -= dt; p.velocity.copy(p.pullDir).multiplyScalar(20); @@ -1042,7 +1060,7 @@ export class Game { } // Jump / gravity - if (!p.onGround && p.pullTime <= 0) { + if (!p.onGround && p.pullTime <= 0 && p.stunTime <= 0) { p.jumpVelocity -= 20 * dt; p.body.position.y += p.jumpVelocity * dt; if (p.body.position.y <= 0.5) { @@ -1430,6 +1448,12 @@ export class Game { 25, this.player.body.position.z - 15 ); + if (this.shakeTime > 0) { + this.shakeTime -= dt; + const k = this.shakeTime * this.shakeAmp; + this.camera.position.x += (Math.random() - 0.5) * k; + this.camera.position.z += (Math.random() - 0.5) * k; + } this.camera.lookAt( this.player.body.position.x, 0, diff --git a/web/src/Minotaurus.ts b/web/src/Minotaurus.ts new file mode 100644 index 0000000..b9ce44e --- /dev/null +++ b/web/src/Minotaurus.ts @@ -0,0 +1,769 @@ +import * as THREE from 'three'; +import type { Game } from './Game'; +import { Enemy } from './Enemy'; +import { playSound } from './SoundManager'; +import { FallingRock } from './FallingRock'; + +const MAX_HEALTH = 6000; +const HIT_FLASH_TIME = 0.14; +const MODEL_SCALE = 1.7; +const IDEAL_DIST = 4.5; +const MIN_DIST = 3.2; +const MOVE_SPEED = 5; +const TURN_SPEED_1 = 2.2; +const TURN_SPEED_2 = 3.5; +const DASH_COOLDOWN = 8; +const DASH_COOLDOWN_2 = 4; +const DASH_WINDUP = 0.6; +const DASH_SPEED = 34; +const DASH_MAX_DIST = 30; +const DASH_DAMAGE = 35; +const WALL_LIMIT = 46; +const BOSS_STUN_TIME = 3.5; +const SWING_COOLDOWN = 5; +const SWING_COOLDOWN_2 = 3.5; +const SWING_WINDUP = 0.45; +const SWING_TIME = 0.3; +const SWING_RANGE = 5.2; +const SWING_ARC = (90 * Math.PI) / 180; +const SWING_DAMAGE = 60; +const STOMP_COOLDOWN = 10; +const STOMP_COOLDOWN_2 = 8; +const STOMP_JUMP_TIME = 1.0; +const STOMP_JUMP_HEIGHT = 4; +const STOMP_RADIUS = 11; +const STOMP_DAMAGE = 15; +const STOMP_STUN = 1.2; +const ENRAGE_RATIO = 0.4; +const ROCK_COUNT = 7; +const ROCK_COUNT_2 = 10; + +type State = 'idle' | 'dashwindup' | 'dash' | 'dashstop' | 'stunned' | 'swingwindup' | 'swing' | 'jump'; + +export class Minotaurus extends Enemy { + private model: THREE.Group; + private game: Game | null = null; + private waveTime = 0; + private state: State = 'idle'; + private stateTime = 0; + private yaw = 0; + private enraged = false; + private hitFlash = 0; + + private dashTimer = 3.5; + private swingTimer = 2; + private stompTimer = 6; + private doubleDashLeft = 0; + + private dashDir = new THREE.Vector3(); + private dashDist = 0; + private dashHitPlayer = false; + + private swingHit = false; + + private jumpFrom = new THREE.Vector3(); + private jumpTo = new THREE.Vector3(); + + private axeGroup: THREE.Group; + private eyeMat!: THREE.MeshBasicMaterial; + private furMat!: THREE.MeshToonMaterial; + private darkMat!: THREE.MeshToonMaterial; + private starsGroup: THREE.Group; + private telegraphArc!: THREE.Mesh; + private swingArc!: THREE.Mesh; + private dashLine!: THREE.Mesh; + private shockRing!: THREE.Mesh; + private shockRingMat!: THREE.MeshBasicMaterial; + private legs: THREE.Group[] = []; + private moving = false; + + private rocks: FallingRock[] = []; + private cracks: { group: THREE.Group; life: number }[] = []; + + constructor() { + super(); + this.health = MAX_HEALTH; + this.maxHealth = MAX_HEALTH; + this.isBoss = true; + this.displayName = 'Minotaurus'; + this.speed = 0; + this.xp = 200; + this.contactDps = 0; + this.contactRadius = 0; + this.guaranteedDrop = true; + + this.model = new THREE.Group(); + this.model.scale.setScalar(MODEL_SCALE); + // Kein fadeOutModel: beim Tod schrumpft der ganze Body inkl. Effekten + this.body.add(this.model); + + this.furMat = new THREE.MeshToonMaterial({ color: 0x6b4a2f }); + this.darkMat = new THREE.MeshToonMaterial({ color: 0x4a3220 }); + const hornMat = new THREE.MeshToonMaterial({ color: 0xd8c8a8 }); + const axeMat = new THREE.MeshToonMaterial({ color: 0x9999aa }); + const metalMat = new THREE.MeshToonMaterial({ color: 0x8a8a99 }); + const darkMetalMat = new THREE.MeshToonMaterial({ color: 0x555566 }); + this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xffcc33 }); + + // Beine (Gruppen mit Hueft-Gelenk, laufen animiert) + for (const side of [-1, 1]) { + const legGroup = new THREE.Group(); + legGroup.position.set(side * 0.45, 0.75, 0); + const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.3, 0.8, 10), this.darkMat); + leg.position.y = -0.4; + leg.castShadow = true; + this.registerFadeMesh(leg); + legGroup.add(leg); + const hoof = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.16, 0.42), metalMat); + hoof.position.set(0, -0.78, 0.06); + this.registerFadeMesh(hoof); + legGroup.add(hoof); + const guard = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.4, 0.1), metalMat); + guard.position.set(0, -0.4, 0.2); + this.registerFadeMesh(guard); + legGroup.add(guard); + this.model.add(legGroup); + this.legs.push(legGroup); + } + + // Torso + const torso = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.3, 1.0), this.furMat); + torso.position.y = 1.15; + torso.castShadow = true; + this.registerFadeMesh(torso); + this.model.add(torso); + + // Vorder-Panzerung: Brustplatte + const chest = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.8, 0.1), metalMat); + chest.position.set(0, 1.18, 0.48); + chest.castShadow = true; + this.registerFadeMesh(chest); + this.model.add(chest); + + // Schultern (gepanzert vorn) + for (const side of [-1, 1]) { + const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 10), this.furMat); + shoulder.position.set(side * 0.85, 1.6, 0); + shoulder.castShadow = true; + this.registerFadeMesh(shoulder); + this.model.add(shoulder); + const pauldron = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.3, 0.4), metalMat); + pauldron.position.set(side * 0.85, 1.68, 0.3); + this.registerFadeMesh(pauldron); + this.model.add(pauldron); + } + + // Brustpanzer-Gürtel + const belt = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.16, 0.75), darkMetalMat); + belt.position.set(0, 0.68, 0); + this.registerFadeMesh(belt); + this.model.add(belt); + + // Linker Arm + const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.26, 0.9, 8), this.furMat); + arm.position.set(-0.85, 0.85, 0); + arm.castShadow = true; + this.registerFadeMesh(arm); + this.model.add(arm); + + // Rechter Arm mit Axt (schwingt beim Angriff) + this.axeGroup = new THREE.Group(); + this.axeGroup.position.set(0.85, 1.0, 0); + this.model.add(this.axeGroup); + const axeArm = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.26, 0.9, 8), this.furMat); + axeArm.position.y = -0.15; + axeArm.castShadow = true; + this.registerFadeMesh(axeArm); + this.axeGroup.add(axeArm); + const handle = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.8, 8), this.darkMat); + handle.rotation.x = Math.PI / 2; + handle.position.z = 0.6; + handle.castShadow = true; + this.registerFadeMesh(handle); + this.axeGroup.add(handle); + const blade = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.7, 0.12), axeMat); + blade.position.z = 1.35; + blade.castShadow = true; + this.registerFadeMesh(blade); + this.axeGroup.add(blade); + + // Kopf + const head = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.6, 0.75), this.furMat); + head.position.y = 2.05; + head.castShadow = true; + this.registerFadeMesh(head); + this.model.add(head); + + // Schnauze (+Z) + const snout = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.24, 0.4), this.darkMat); + snout.position.set(0, 1.92, 0.55); + this.registerFadeMesh(snout); + this.model.add(snout); + + // Hörner + for (const side of [-1, 1]) { + const horn = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.55, 8), hornMat); + horn.position.set(side * 0.28, 2.35, 0.1); + horn.rotation.set(-1.1, 0, side * 0.45); + horn.castShadow = true; + this.registerFadeMesh(horn); + this.model.add(horn); + } + + // Augen (+Z, gelb -> rot in Phase 2) + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.eyeMat); + eye.position.set(side * 0.2, 2.12, 0.4); + this.registerFadeMesh(eye); + this.model.add(eye); + } + + // Stirnpanzer vorn + const brow = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.22, 0.08), metalMat); + brow.position.set(0, 2.24, 0.38); + this.registerFadeMesh(brow); + this.model.add(brow); + + // --- Effekte an den Body haengen (nicht skaliert, drehen aber mit) --- + + // Stun-Sterne um den Kopf + this.starsGroup = new THREE.Group(); + const starGeom = new THREE.OctahedronGeometry(0.14); + const starMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 }); + for (let i = 0; i < 4; i++) { + const a = (i / 4) * Math.PI * 2; + const star = new THREE.Mesh(starGeom, starMat); + star.position.set(Math.cos(a) * 1.4, 3.7, Math.sin(a) * 1.4); + this.starsGroup.add(star); + } + this.starsGroup.visible = false; + this.body.add(this.starsGroup); + + // Axt-Telegraf-Bogen (Trefferzone) auf dem Boden. + // Achtung: RingGeometry-Winkel starten an lokaler +X -> um 90° versetzen, + // damit der Halbkreis nach vorn (+Z, Blickrichtung) zeigt. + const arcMat = new THREE.MeshBasicMaterial({ + color: 0xff4444, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + const arcGeom = new THREE.RingGeometry( + SWING_RANGE - 0.7, SWING_RANGE - 0.2, 48, 1, + -SWING_ARC - Math.PI / 2, SWING_ARC * 2 + ); + this.telegraphArc = new THREE.Mesh(arcGeom, arcMat); + this.telegraphArc.rotation.x = -Math.PI / 2; + this.telegraphArc.position.y = 0.05; + this.telegraphArc.visible = false; + this.body.add(this.telegraphArc); + + // Axt-Sweep-Visual: heller Bogen fegt von rechts nach links ueber den Boden + const swingArcMat = new THREE.MeshBasicMaterial({ + color: 0xffaa88, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + this.swingArc = new THREE.Mesh(arcGeom, swingArcMat); + this.swingArc.rotation.x = -Math.PI / 2; + this.swingArc.position.y = 0.05; + this.swingArc.visible = false; + this.body.add(this.swingArc); + + // Dash-Richtungslinie + const lineMat = new THREE.MeshBasicMaterial({ + color: 0xff3333, + transparent: true, + opacity: 0, + depthWrite: false, + }); + this.dashLine = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.02, 12), lineMat); + this.dashLine.position.y = 0.06; + this.dashLine.position.z = 8; + this.dashLine.visible = false; + this.body.add(this.dashLine); + + // Schockwellen-Ring + this.shockRingMat = new THREE.MeshBasicMaterial({ + color: 0xff5533, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + this.shockRing = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.0, 48), this.shockRingMat); + this.shockRing.rotation.x = -Math.PI / 2; + this.shockRing.position.y = 0.06; + this.shockRing.visible = false; + this.body.add(this.shockRing); + } + + playAnim() { + // Prozedurale Optik + } + + updateAnimation(dt: number) { + this.updateFadeDeath(dt, { duration: 1.2, sink: 0.5 }); + } + + update(dt: number, game: Game) { + this.game = game; + this.waveTime += dt; + + // Hit-Feedback: kurz weiss aufblitzen + Puls + if (this.hitFlash > 0) { + this.hitFlash -= dt; + this.furMat.color.set(0xffffff); + this.darkMat.color.set(0xffffff); + this.model.scale.setScalar(MODEL_SCALE * (1 + (this.hitFlash / HIT_FLASH_TIME) * 0.05)); + } else { + this.furMat.color.set(0x6b4a2f); + this.darkMat.color.set(0x4a3220); + this.model.scale.setScalar(MODEL_SCALE); + } + + // Phase 2 ab 40% HP + if (!this.enraged && this.health < MAX_HEALTH * ENRAGE_RATIO) { + this.enraged = true; + this.eyeMat.color.set(0xff2222); + playSound('damage', 0.9); + game.triggerCameraShake(0.35, 0.5); + } + + switch (this.state) { + case 'idle': this.updateIdle(dt, game); break; + case 'dashwindup': this.updateDashWindup(dt, game); break; + case 'dash': this.updateDash(dt, game); break; + case 'dashstop': this.updateDashStop(dt); break; + case 'stunned': this.updateStunned(dt); break; + case 'swingwindup': this.updateSwingWindup(dt, game); break; + case 'swing': this.updateSwing(dt, game); break; + case 'jump': this.updateJump(dt, game); break; + } + + // Stun-Sterne animieren + this.starsGroup.visible = this.state === 'stunned'; + if (this.starsGroup.visible) { + this.starsGroup.rotation.y += dt * 3; + for (const star of this.starsGroup.children) { + star.rotation.x += dt * 5; + star.rotation.y += dt * 4; + star.position.y = 3.7 + Math.sin(this.waveTime * 6) * 0.1; + } + } + + // Bein-Animation: Laufen / Dash-Haltung / sonst still + if (this.state === 'dash' || this.state === 'dashwindup') { + for (const leg of this.legs) leg.rotation.x = 0.5; + } else if (this.state === 'jump') { + for (const leg of this.legs) leg.rotation.x = 0.25; + } else if (this.state === 'idle' && this.moving) { + const step = Math.sin(this.waveTime * 9); + this.legs[0].rotation.x = step * 0.55; + this.legs[1].rotation.x = -step * 0.55; + } else { + for (const leg of this.legs) leg.rotation.x = 0; + } + + // Schockwellen-Ring expandieren + if (this.shockRing.visible) { + const s = this.shockRing.scale.x + dt * 26; + this.shockRing.scale.setScalar(s); + this.shockRingMat.opacity = Math.max(0, 0.5 - (s / 11) * 0.5); + if (s >= 11) this.shockRing.visible = false; + } + + // Felsen + for (let i = this.rocks.length - 1; i >= 0; i--) { + if (!this.rocks[i].update(dt, game)) { + this.rocks[i].dispose(); + this.rocks.splice(i, 1); + } + } + + // Wandrisse verblassen + for (let i = this.cracks.length - 1; i >= 0; i--) { + const c = this.cracks[i]; + c.life -= dt; + const op = Math.max(0, c.life / 2.5) * 0.6; + c.group.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + (obj.material as THREE.Material & { opacity: number }).opacity = op; + } + }); + if (c.life <= 0) { + game.scene.remove(c.group); + this.disposeGroup(c.group); + this.cracks.splice(i, 1); + } + } + } + + private updateIdle(dt: number, game: Game) { + const playerPos = game.player.body.position; + const dx = playerPos.x - this.body.position.x; + const dz = playerPos.z - this.body.position.z; + const dist = Math.hypot(dx, dz); + + // Turn-Lag: langsam zum Spieler drehen + const target = Math.atan2(dx, dz); + const turnSpeed = this.enraged ? TURN_SPEED_2 : TURN_SPEED_1; + this.yaw = this.lerpAngle(this.yaw, target, Math.min(1, turnSpeed * dt)); + this.body.rotation.y = this.yaw; + + // Abstand halten: nie zu nah, nie zu weit + this.moving = false; + const moveDir = new THREE.Vector3(); + if (dist > IDEAL_DIST + 1 && dist > 0.01) { + moveDir.set(dx / dist, 0, dz / dist); + this.moving = true; + } else if (dist < MIN_DIST && dist > 0.01) { + moveDir.set(-dx / dist, 0, -dz / dist); + this.moving = true; + } + if (moveDir.lengthSq() > 0) { + this.body.position.x += moveDir.x * MOVE_SPEED * dt; + this.body.position.z += moveDir.z * MOVE_SPEED * dt; + game.clampToArena(this.body.position); + } + + this.dashTimer -= dt; + this.swingTimer -= dt; + this.stompTimer -= dt; + if (this.dashTimer <= 0) { + this.dashTimer = this.enraged ? DASH_COOLDOWN_2 : DASH_COOLDOWN; + this.doubleDashLeft = this.enraged ? 1 : 0; + this.startDashWindup(game); + } else if (this.swingTimer <= 0) { + this.swingTimer = this.enraged ? SWING_COOLDOWN_2 : SWING_COOLDOWN; + this.startSwingWindup(); + } else if (this.stompTimer <= 0) { + this.stompTimer = this.enraged ? STOMP_COOLDOWN_2 : STOMP_COOLDOWN; + this.startJump(game); + } + } + + // --- Dash --- + + private startDashWindup(game: Game) { + this.state = 'dashwindup'; + this.stateTime = DASH_WINDUP; + this.dashDir.set( + game.player.body.position.x - this.body.position.x, + 0, + game.player.body.position.z - this.body.position.z + ); + if (this.dashDir.lengthSq() < 0.01) this.dashDir.set(0, 0, 1); + this.dashDir.normalize(); + this.dashHitPlayer = false; + this.yaw = Math.atan2(this.dashDir.x, this.dashDir.z); + this.body.rotation.y = this.yaw; + this.dashLine.visible = true; + playSound('spawn', 0.6); + } + + private updateDashWindup(dt: number, game: Game) { + this.stateTime -= dt; + (this.dashLine.material as THREE.MeshBasicMaterial).opacity = + 0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 14)); + if (this.stateTime <= 0) { + this.dashLine.visible = false; + this.state = 'dash'; + this.dashDist = 0; + playSound('spawn', 0.8); + } + } + + private updateDash(dt: number, game: Game) { + const step = DASH_SPEED * dt; + this.dashDist += step; + this.body.position.x += this.dashDir.x * step; + this.body.position.z += this.dashDir.z * step; + + if (!this.dashHitPlayer && 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) < 1.7) { + this.dashHitPlayer = true; + game.player.damage(DASH_DAMAGE, this.body.position); + playSound('damage', 0.9); + } + } + + if (Math.abs(this.body.position.x) > WALL_LIMIT || Math.abs(this.body.position.z) > WALL_LIMIT) { + game.clampToArena(this.body.position); + this.hitWall(game); + return; + } + if (this.dashDist >= DASH_MAX_DIST) { + this.endDash(game); + } + } + + private endDash(game: Game) { + if (this.doubleDashLeft > 0) { + this.doubleDashLeft--; + this.startDashWindup(game); + } else { + this.state = 'dashstop'; + this.stateTime = 0.3; + } + } + + private updateDashStop(dt: number) { + this.stateTime -= dt; + if (this.stateTime <= 0) this.state = 'idle'; + } + + private hitWall(game: Game) { + this.state = 'stunned'; + this.stateTime = BOSS_STUN_TIME; + this.spawnWallCracks(game); + playSound('explosion', 0.5); + } + + private updateStunned(dt: number) { + this.stateTime -= dt; + // Schwindel-Wackeln + this.model.rotation.z = Math.sin(this.waveTime * 20) * 0.08; + if (this.stateTime <= 0) { + this.model.rotation.z = 0; + this.state = 'idle'; + } + } + + private spawnWallCracks(game: Game) { + const group = new THREE.Group(); + const crackMat = new THREE.MeshBasicMaterial({ + color: 0x111111, + transparent: true, + opacity: 0.6, + depthWrite: false, + }); + const onXWall = Math.abs(this.body.position.x) > Math.abs(this.body.position.z); + for (let i = 0; i < 4; i++) { + const len = 0.8 + Math.random() * 1.2; + const geo = new THREE.BoxGeometry( + onXWall ? 0.06 : len, + 0.06, + onXWall ? len : 0.06 + ); + const crack = new THREE.Mesh(geo, crackMat); + if (onXWall) { + crack.position.set( + Math.sign(this.body.position.x) * 49.2, + 0.5 + Math.random() * 1.4, + this.body.position.z + (Math.random() - 0.5) * 2.4 + ); + } else { + crack.position.set( + this.body.position.x + (Math.random() - 0.5) * 2.4, + 0.5 + Math.random() * 1.4, + Math.sign(this.body.position.z) * 49.2 + ); + } + crack.rotation.y = Math.random() * Math.PI; + group.add(crack); + } + game.scene.add(group); + this.cracks.push({ group, life: 2.5 }); + } + + // --- Axt-Schwung --- + + private startSwingWindup() { + this.state = 'swingwindup'; + this.stateTime = SWING_WINDUP; + this.swingHit = false; + // Axt anheben (leicht), bereit fuer den horizontalen Sweep + this.axeGroup.rotation.x = -0.35; + this.axeGroup.rotation.y = 1.0; + this.telegraphArc.visible = true; + playSound('spawn', 0.5); + } + + private updateSwingWindup(dt: number, game: Game) { + this.stateTime -= dt; + (this.telegraphArc.material as THREE.MeshBasicMaterial).opacity = + 0.3 + 0.4 * Math.abs(Math.sin(this.waveTime * 12)); + if (this.stateTime <= 0) { + this.telegraphArc.visible = false; + this.state = 'swing'; + this.stateTime = SWING_TIME; + } + } + + private updateSwing(dt: number, game: Game) { + this.stateTime -= dt; + const t = 1 - this.stateTime / SWING_TIME; + + // Axt fegt horizontal von rechts nach links vor dem Koerper + this.axeGroup.rotation.y = 1.0 - t * 2.0; + this.axeGroup.rotation.x = -0.35 + t * 0.35; + + // Hell: Halbkreis fegt ueber den Boden + this.swingArc.visible = true; + this.swingArc.rotation.y = SWING_ARC * 0.85 * (1 - 2 * t); + const fade = Math.min(1, t * 6, (1 - t) * 6); + (this.swingArc.material as THREE.MeshBasicMaterial).opacity = fade * 0.7; + + // Treffer in der Mitte des Schwungs + if (!this.swingHit && t >= 0.35) { + this.swingHit = true; + const playerPos = game.player.body.position; + const dx = playerPos.x - this.body.position.x; + const dz = playerPos.z - this.body.position.z; + const dist = Math.hypot(dx, dz); + if (dist < SWING_RANGE) { + const ang = Math.atan2(dx, dz); + // Ueberspringbar: Treffer nur am Boden + if (Math.abs(this.angleDiff(this.yaw, ang)) < SWING_ARC && playerPos.y <= 0.6) { + game.player.damage(SWING_DAMAGE, this.body.position); + playSound('damage', 0.9); + } + } + } + + if (this.stateTime <= 0) { + this.axeGroup.rotation.x = 0; + this.axeGroup.rotation.y = 0; + this.swingArc.visible = false; + this.state = 'idle'; + } + } + + // --- Sprung + Stampf --- + + private startJump(game: Game) { + this.state = 'jump'; + this.stateTime = STOMP_JUMP_TIME; + this.jumpFrom.copy(this.body.position); + this.jumpTo.set(game.player.body.position.x, 0, game.player.body.position.z); + playSound('spawn', 0.7); + } + + private updateJump(dt: number, game: Game) { + this.stateTime -= dt; + const t = 1 - Math.max(0, this.stateTime) / STOMP_JUMP_TIME; + this.body.position.x = this.jumpFrom.x + (this.jumpTo.x - this.jumpFrom.x) * t; + this.body.position.z = this.jumpFrom.z + (this.jumpTo.z - this.jumpFrom.z) * t; + this.body.position.y = 0.15 + Math.sin(t * Math.PI) * STOMP_JUMP_HEIGHT; + + if (this.stateTime <= 0) { + this.body.position.y = 0.15; + this.landStomp(game); + } + } + + private landStomp(game: Game) { + // Schockwelle: stunnt den Spieler (ueberspringbar) + this.shockRing.visible = true; + this.shockRing.scale.setScalar(0.2); + this.shockRingMat.opacity = 0.5; + playSound('explosion', 0.7); + + const playerPos = game.player.body.position; + const dist = Math.hypot( + playerPos.x - this.body.position.x, + playerPos.z - this.body.position.z + ); + if (dist < STOMP_RADIUS && playerPos.y <= 0.6) { + game.player.stunTime = STOMP_STUN; + game.player.damage(STOMP_DAMAGE, this.body.position); + } + + // Steinschlag um den Spieler + const count = this.enraged ? ROCK_COUNT_2 : ROCK_COUNT; + for (let i = 0; i < count; i++) { + const a = Math.random() * Math.PI * 2; + const r = 2 + Math.random() * 6; + const rock = new FallingRock(new THREE.Vector3( + playerPos.x + Math.cos(a) * r, + 0, + playerPos.z + Math.sin(a) * r + )); + game.scene.add(rock.body); + this.rocks.push(rock); + } + + this.state = 'idle'; + } + + // --- Schaden --- + + override takeDamage( + damage: number, + game: Game, + withKnockback = true, + sourcePos?: THREE.Vector3 + ) { + const src = sourcePos ?? game.player.body.position; + const fwd = new THREE.Vector3(Math.sin(this.yaw), 0, Math.cos(this.yaw)); + const incoming = new THREE.Vector3().subVectors(src, this.body.position); + incoming.y = 0; + if (incoming.lengthSq() < 0.0001) incoming.copy(fwd); + incoming.normalize(); + + // Nur von hinten verletzbar: vorn wird geblockt + if (fwd.dot(incoming) > 0.15) { + game.spawnDamageText('BLOCK', this.body.position, '#99aabb'); + playSound('sword_hit1', 0.6); + return; + } + + 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() { + this.starsGroup.visible = false; + this.dashLine.visible = false; + this.telegraphArc.visible = false; + this.swingArc.visible = false; + this.shockRing.visible = false; + this.furMat.color.set(0x6b4a2f); + this.darkMat.color.set(0x4a3220); + for (const rock of this.rocks) rock.dispose(); + this.rocks = []; + if (this.game) { + for (const c of this.cracks) { + this.game.scene.remove(c.group); + this.disposeGroup(c.group); + } + } + this.cracks = []; + } + + private disposeGroup(group: THREE.Group) { + group.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry.dispose(); + (obj.material as THREE.Material).dispose(); + } + }); + } + + private lerpAngle(a: number, b: number, t: number): number { + return a + this.angleDiff(a, b) * t; + } + + private angleDiff(a: number, b: number): number { + let diff = b - a; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + return diff; + } +} diff --git a/web/src/Player.ts b/web/src/Player.ts index 5bf77e8..c1d3dfe 100644 --- a/web/src/Player.ts +++ b/web/src/Player.ts @@ -34,6 +34,7 @@ export class Player { slowFactor = 0.6; pullTime = 0; pullDir = new THREE.Vector3(); + stunTime = 0; stats: PlayerStats = { maxHealth: 100, regen: 0, @@ -52,6 +53,7 @@ export class Player { private bodyMaterials: THREE.MeshToonMaterial[] = []; private hurtCooldown = 0; private flashTime = 0; + private stunStars: THREE.Group; private static readonly BASE_COLOR = 0x44aa44; private static readonly HURT_COLOR = 0xff5555; private static readonly SLOW_COLOR = 0x9944cc; @@ -60,6 +62,19 @@ export class Player { constructor() { this.body = new THREE.Group(); + // Stun-Sterne: kreisen um den Kopf, solange der Spieler gestunnt ist + this.stunStars = new THREE.Group(); + const starGeom = new THREE.OctahedronGeometry(0.13); + const starMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 }); + for (let i = 0; i < 3; i++) { + const a = (i / 3) * Math.PI * 2; + const star = new THREE.Mesh(starGeom, starMat); + star.position.set(Math.cos(a) * 0.55, 1.35, Math.sin(a) * 0.55); + this.stunStars.add(star); + } + this.stunStars.visible = false; + this.body.add(this.stunStars); + // Helmet const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2); const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 }); @@ -223,6 +238,18 @@ export class Player { if (this.mixer) this.mixer.update(dt); if (this.hurtCooldown > 0) this.hurtCooldown -= dt; if (this.slowTimer > 0) this.slowTimer -= dt; + if (this.stunTime > 0) this.stunTime -= dt; + + // Stun-Sterne animieren + this.stunStars.visible = this.stunTime > 0; + if (this.stunStars.visible) { + this.stunStars.rotation.y += dt * 5; + for (const star of this.stunStars.children) { + star.rotation.x += dt * 8; + star.rotation.y += dt * 6; + star.position.y = 1.35 + Math.sin(this.stunTime * 10) * 0.08; + } + } let color: THREE.Color; if (this.flashTime > 0) { @@ -332,6 +359,7 @@ export class Player { this.shieldTimer = 0; this.slowTimer = 0; this.pullTime = 0; + this.stunTime = 0; this.body.position.set(0, 0.5, 0); this.velocity.set(0, 0, 0); this.jumpVelocity = 0;